bunnyquery
v1.11.0
Published
Embeddable BunnyQuery AI chat widget + its framework-agnostic chat engine
Maintainers
Readme
BunnyQuery Widget
An embeddable, dependency-free AI chat widget for Skapi-powered projects. Drop it into any web page and your users get a full chat experience: account login/signup, conversation history, file & folder uploads, and a settings panel, all talking to your project's BunnyQuery AI agent.
BunnyQuery is a standalone vanilla-JS port of the BunnyQuery (www.bunnyquery.com) agent
chatbox. The widget ships as a single IIFE that exposes window.BunnyQuery plus one
stylesheet. Drop it in via <script>, no build step or framework required.
The package also exports the framework-agnostic chat engine that powers it
(bunnyquery/engine), the same DOM-free core the Skapi admin chatbox consumes, so
you can build your own chat UI on top of it. See
Importing the chat engine.
Features
- AI chat against your project's configured agent (Claude or OpenAI under the hood), with animated pending indicators and a background indexing queue.
- Authentication: email/password login, optional signup, password change, email verification, account recovery, and "Sign in with Google".
- Conversation history: paginated, with "Fetching history…" indicators on first load and on scroll-up.
- Attachments: drag-and-drop files and folders, per-file upload status (uploading / failed / indexed), overflow collapsing for large batches, and a prompt when an upload hits a file that already exists (skip / reindex only / overwrite, with "apply to all remaining"). Images are read with vision/OCR, large documents and spreadsheets are read window by window, PDFs are rendered to page images, emails are read as their headers, body and attachment text, and everything else extractable is inlined as text. See Supported file types.
- Background indexing: an uploaded file is indexed in the background, across as many passes as it takes. A file's passes collapse into a single status row in the chat that can be expanded, and stopped: "Stop" cancels every queued and running pass at once and ends the continuation chain.
- Attachment parser plugins: register a client-side parser so the widget extracts text in the browser from formats the model can't otherwise read, and indexes it directly. See Attachment parser plugins.
- Settings panel, in place inside the chat: light/dark theme, account details, newsletter subscription, clear history, and remove account.
- Theming: light and dark modes via CSS custom properties; the choice is
remembered in
localStorageand falls back to the OS preference.
Requirements
- A BunnyQuery project (you need its project ID).
- The
skapi-jsSDK loaded on the page. - A mount element with an explicit height (the widget fills its container).
Quick start
Add the two BunnyQuery files and the Skapi SDK, give it a sized container, then
call BunnyQuery.init():
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Skapi SDK + BunnyQuery -->
<script src="https://cdn.jsdelivr.net/npm/skapi-js@latest/dist/skapi.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bunnyquery@latest/bunnyquery.css" />
<script src="https://cdn.jsdelivr.net/npm/bunnyquery@latest/bunnyquery.js"></script>
</head>
<body style="margin: 0">
<!-- The widget fills this element, so give it a height -->
<div id="chatbox" style="width: 100%; height: 100dvh"></div>
<script>
// 1. Create your Skapi instance
const skapi = new Skapi("<your-project-id>", { autoLogin: true });
// 2. Mount BunnyQuery into the container
BunnyQuery.init(skapi, "chatbox", {
theme: "light",
signup: true,
});
</script>
</body>
</html>That's it. BunnyQuery takes over the #chatbox element and renders the login or
chat view depending on the user's session.
The widget works with whichever skapi-js your page loads. On a current SDK it calls the
forwardRequest family, and on an earlier one it falls back to the clientSecretRequest family,
deciding by whether the SDK has forwardRequestHistory. It never decides by forwardRequest alone,
because on an earlier SDK that name belongs to a different, retired method.
What's in the package
| Path | Purpose |
| ----------------------------- | -------------------------------------------------------------------------------- |
| bunnyquery.js | The widget IIFE. Exposes the global window.BunnyQuery. CDN / <script> drop-in. |
| bunnyquery.css | The widget's full stylesheet, scoped under .bq-agent / [data-bq-theme]. |
| bunnyquery/engine | The framework-agnostic chat engine. Ships as ESM + CJS with TypeScript types. |
| bunnyquery/styles/chat.css | The shared chat-surface styles (bubbles, markdown, links) for an engine-built UI. |
The two widget files can be hosted yourself (same origin recommended) or loaded from a
CDN, no npm needed. The engine / styles subpaths are for bundler consumers
(npm install bunnyquery); see Importing the chat engine.
API
BunnyQuery.init(skapi, target, opts?)
Mounts the widget. Returns the BunnyQuery object.
| Argument | Type | Description |
| -------- | --------------------- | ----------------------------------------------------------------- |
| skapi | Skapi | A constructed Skapi instance. Required. |
| target | string \| Element | The mount element, or the id of one. Required. |
| opts | object | Options (see below). Optional. |
Options
| Option | Type | Default | Description |
| ------------------------ | --------- | -------- | -------------------------------------------------------------------------------------------- |
| theme | string | "light"| Initial theme, "light" or "dark". Overridden by a remembered choice or OS preference. |
| title | string | null | Header title text. null keeps "BunnyQuery · <project name>"; any string replaces the whole line, and "" leaves the header title empty. It is one element, so it ellipsizes as a unit. |
| inputPlaceholder | string | null | Placeholder in the chat composer. null keeps "Ask anything about: <project name>"; "" shows no placeholder. |
| showLogin | boolean | true | Whether an anonymous visitor is offered a Login button in the chat header. Signed-in users are unaffected: they get the settings gear in that slot either way. Set false for an embed that handles its own auth, or that does not want visitors making accounts. |
| bubbleFace | string | null | Image for the little face on assistant chat bubbles. Any value a CSS url() accepts: an https URL or a data: URI. null keeps the bundled bunny. Applied as the --bq-bubble-face custom property on the widget's own root, so two widgets on one page can carry different faces. |
| signup | boolean | false | Enable signup flows (and account remove/recover). When false, only existing users can log in. |
| googleClientId | string | null | Google OAuth client ID. Set this to show "Sign in with Google". |
| googleClientSecretName | string | "ggl" | The Skapi client-secret name holding your Google OAuth secret. |
| signupConfirmationUrl | string | null | Link target used in the signup confirmation email. Defaults to the current page URL. |
| dev | boolean | false | Use the development MCP host and skapi.app db-CDN host instead of production. |
| mcpBaseUrl | string | null | Override the MCP OAuth server base URL entirely (advanced). |
| hostDomain | string | null | db-CDN host for temporary file URLs. Defaults to skapi.app (dev) / skapi.com (prod). |
| attachmentParsers | array | null | Client-side attachment parsers. See Attachment parser plugins. |
| windowedIndexing | boolean | true | Server-driven windowed indexing for text and grid files (see file types). Pass false to fall back to agent-driven paging, which keeps the traversal inside the model's turn budget and the tab open. |
| allowAnonymous | boolean | null | Open the chat with no login for visitors without an account. null follows the project's own "Allow anonymous users" setting (getConnectionInfo().conf.require_login); true/false pins it. |
| liveStreaming | boolean | false | Paint a chat answer into its bubble as it arrives, instead of at the end. A request, not a switch: the widget honours it only when your page's skapi-js actually carries skapi's half of the stream flag (it checks for forwardRequestStream and forwardRequestFinalize, or their older names clientSecretRequestStream and clientSecretRequestFinalize on an earlier skapi-js), and otherwise warns once and falls back to buffered replies. An older SDK silently drops the flag, which would leave the destination streaming SSE into a buffered row that reads back empty. It still also needs a polling worker that relays the response bytes, which the widget cannot check, so leave it off until the region you talk to is deployed. |
| liveStreamingRealtime | boolean | false | Deliver streamed chunks over skapi's websocket instead of waiting for the next poll tick. Requires liveStreaming. Off unless you ask for it: skapi's joinRealtime replaces the connection's group, so for the length of a turn it takes the room out from under whatever else your app uses realtime for. Purely an accelerator; with it off the reply still streams, on the poll's cadence. |
Rebranding the widget
Every piece of visible chrome the widget owns can be replaced from init(),
with no stylesheet override:
BunnyQuery.init(skapi, "chatbox", {
title: "Acme Support", // the header line, as ONE string
inputPlaceholder: "Ask us anything...", // the composer's placeholder
showLogin: false, // no Login button for anonymous visitors
bubbleFace: "https://acme.example/avatar.png", // our face on assistant bubbles
theme: "dark", // and the colour scheme
});The header is a single element reading BunnyQuery · <project name> by default;
title replaces that whole string rather than a part of it. For anything past
these four, the widget is themed with CSS custom properties; see
Theming.
Methods
The BunnyQuery global also exposes:
| Method | Description |
| ---------------------------------- | ----------------------------------------------------------------------------------- |
| setTheme(theme) | Apply "light" or "dark" and persist it. |
| toggleTheme() | Switch between light and dark. |
| logout() | Sign the current user out and return to the login view. |
| registerAttachmentParser(parser) | Register a client-side attachment parser. May be called before or after init(). See Attachment parser plugins. |
| version | The widget's package version string. Also logged to the console on init(). |
BunnyQuery.setTheme("dark");
BunnyQuery.toggleTheme();
BunnyQuery.logout();
init()is idempotent: calling it twice logs a warning and returns the existing instance rather than re-mounting. On a successful mount it logs its version, e.g.[bunnyquery] v1.8.3.
See HISTORY.md for the release-by-release changelog.
Supported file types
When a user attaches a file, BunnyQuery makes its contents available to the AI automatically, detected by extension (with a MIME-type fallback), nothing to configure.
An attachment is used in two places, and they take different routes:
- In the chat message. Extractable files are inlined as text; anything else (PDFs, images) is handed over as a temporary link. Server-side re-minting of chat links is deliberately off (an S3 presign is signed for GET only and 403s the HEAD probe OpenAI sends before downloading), so the CDN link is left in place; the turn is instead dispatched only once the indexing queue has drained, which is what keeps the link fresh.
- In background indexing, where the file is read in full and saved into the project's knowledge. This is the path with the window and page loops below.
The routes are tried in this order: a parser plugin, then PDF page rendering, then windowed or paged reading, then server-side extraction, then a plain link.
1. Images: read directly by the model (vision + OCR)
.jpg · .jpeg · .png · .gif · .webp
The image is attached to the request inline, so the model both describes the picture and reads any text in it (OCR). Works on both Claude and OpenAI. Only images referenced in the most recent message are inlined (older links may have expired).
2. PDFs: rendered to page images
.pdf
PDF text layers are often absent or unreliable, so a PDF is indexed visually: the proxy worker renders a window of pages to images and injects them as image blocks in the indexing message. The window is five pages on Claude and on the OpenAI models that accept full-resolution images, and two on OpenAI's downsampled and nano tiers. Tool-result images render on neither provider, which is why the pages have to be in the message itself. That makes scanned PDFs work as well as digital ones.
The worker advances the window itself, off its renderer's true page count, and enqueues the next pass. Indexing a long document therefore does not depend on the browser tab staying open, and does not depend on the model correctly declaring itself finished.
3. Large documents, spreadsheets & data: read window by window
.xls .xlsx .xlsm grids: sheet-by-sheet row windows, plus embedded photos
.ods OpenDocument sheets: character windows, plus photos
.csv .tsv .tab row-bounded windows with absolute row numbers
.doc .docx .docm word processor documents
.ppt .pptx .pptm slide decks
.hwp .hwpx Hancom word processor
.odt .odp OpenDocument text and slides
.epub .rtf .html .htm other long-form documents
.eml email: headers, body, attachment text
.txt .md .markdown .log plain text
.json .jsonl .ndjson .xml .yaml .ymlThese are read one window at a time and continued until the file is
exhausted, rather than inlined once. Whole-file extraction is capped at 200,000
characters, and against real files that cap was discarding most of every large
upload: a 5MB .txt indexed 4.0% of its content, a 4.8MB .json 4.2%, a
1.9M-character Korean .txt 10.5%, a .docx 70.6%. Nothing surfaced the loss,
because the agent received a plausible-looking document with no way to know most
of it was missing.
Two drivers exist for this loop. With windowedIndexing (the widget's default)
the worker reads a window per request and continues from the reader's own
cursor, so the traversal neither has to fit inside the model's turn budget nor
depends on the tab staying open. Without it the agent pages the file itself with
the readFileContent tool. Pass windowedIndexing: false to init() to opt
out; engine consumers opt in via configureChatEngine (see
Importing the chat engine).
4. Everything else extractable: inlined as text server-side
The skapi proxy downloads the file, extracts its text server-side, and inlines that text into the request, so the model reads it directly with no fetching. This keeps indexing consistent across model providers.
Office, e-book & email (binary/zip/MIME, parsed; includes legacy binary
.doc/.xls/.ppt and the macro-enabled .docm/.xlsm/.pptm):
.doc · .docx · .docm · .xls · .xlsx · .xlsm · .ppt · .pptx · .pptm
· .hwp · .hwpx · .ods · .odt · .odp · .epub · .eml
An .eml email yields its header block, its body and the text of every attached
document (spreadsheet, document, csv, calendar, the text layer of a PDF) inline;
pictures attached to or embedded in it are extracted into __MEDIA__ like the
pictures in any other document, and every other attachment is listed by name
only, never saved as a separate file.
Text, data, markup & source code (decoded as text; .html/.htm have their
tags stripped and .rtf is parsed, control words and non-text groups discarded):
.csv .tsv .tab .txt .text .log .md .markdown .rst .json .ndjson .jsonl .geojson
.xml .yaml .yml .toml .ini .conf .cfg .properties .env .rtf .html .htm
.js .mjs .cjs .ts .tsx .jsx .py .rb .go .rs .java .kt .c .h .cpp .cc .hpp .cs
.php .swift .sh .bash .zsh .sql .css .scss .less .vue .svelte .tex .srt .vttPlus a MIME fallback: any file whose content type is text-like (text/*,
application/json, application/xml, *+json, *+xml, *+yaml, …) is decoded
even when its extension isn't in the list above.
Encoding is auto-detected: a UTF-32 or UTF-16 BOM is taken as definitive,
otherwise UTF-8 (BOM-aware), CP949/EUC-KR (Korean) and Latin-1 are all decoded
and scored, and the one producing the least mojibake wins. It is a scoring pass,
not a first-that-succeeds ladder, so one stray byte in a clean Korean file no
longer dumps the whole file into Latin-1. Extracted text is capped at 200,000
characters; longer files are truncated with a ...[truncated for length;
showing the first 200000 of N characters. To read and index the WHOLE file, call
the readFileContent tool with this file's storage path; it returns the file
window by window (with images for scanned/photo content).] marker. (The separate
client-side parser-plugin cap uses the shorter ...[truncated for length;
original N characters] marker.) The formats listed in section 3 are windowed
precisely so they never hit that cap.
Note the overlap between sections 3 and 4 is deliberate: a .docx or a .csv
is windowed when it is indexed, and extracted whole when it rides along in a
chat message.
5. Anything else: a plain link
A file that is none of the above is handed to the model as a temporary link,
which it opens with its built-in web tool: Claude via web_fetch, OpenAI
via web_search (external web access is enabled).
A provider's web tool opens document/page-style URLs, but not necessarily a bare data-file download (e.g. a raw
.csv/.tsvlink). That is why those data formats are extracted server-side instead of being left to the model.
Caveats
- Anything else: a format covered by none of the above is indexed from its metadata. To support it, register your own Attachment parser plugin, which runs in the browser and feeds parsed text straight into indexing.
Re-indexing an existing file
Uploading over a file that already exists prompts for skip, "reindex only", or
overwrite. Choosing either of the latter two deletes the file's existing
src::<path> index record first, and the skapi backend cascades that delete to
the record's reference-linked children, so re-indexing replaces the file's
knowledge rather than duplicating it.
Filenames
Storage keys preserve Unicode letters, digits and spaces, NFC-normalized, so Korean, Japanese and accented Latin filenames survive upload intact. Only genuinely unsafe characters are replaced. The original name is always kept for display.
Attachment parser plugins
By default the chat agent reads images with vision/OCR, renders PDF pages to images, reads large documents and spreadsheets window by window, and extracts Office/OpenDocument/EPUB/email and text/data/code files on the server. See Supported file types. For any format read by none of these (e.g. a proprietary binary format), register a parser plugin: it runs in the browser, turns the uploaded file into text (or an HTML string), and the widget sends that content inline for indexing. A parser plugin takes precedence over every other route, so nothing is fetched or extracted for that file.
BunnyQuery ships only the mechanism. You bring the parsing library (so the widget stays lean and you choose which formats and which library).
A parser is a plain object:
interface AttachmentParser {
name?: string; // label, used in logs
match: (file: { name: string; mime?: string }) => boolean; // handle this file?
parse: (file: File) => string | null | undefined | Promise<string | null | undefined>; // text or HTML; falsy = skip
}The first parser whose match returns true wins. A parser that throws or
returns nothing is ignored, and the file falls back to its normal path. Output is
capped (~200k chars) before it is inlined.
Example
Load whatever parsing library reads your format, then register a parser that
turns a File into text:
<!-- bring your own parsing library, e.g. from a CDN -->
<script src="https://cdn.example.com/my-format-parser.js"></script>
<script>
BunnyQuery.registerAttachmentParser({
name: "my-format",
match: (file) => /\.myext$/i.test(file.name),
parse: async (file) => {
const bytes = new Uint8Array(await file.arrayBuffer());
return window.myFormatParser.toText(bytes); // return plain text OR an HTML string
},
});
BunnyQuery.init(skapi, "chatbox", { theme: "light" });
</script>Equivalent one-shot form via init options:
BunnyQuery.init(skapi, "chatbox", {
attachmentParsers: [ myParser ],
});Bundler consumers can import the same registry from the engine:
import { registerAttachmentParser } from "bunnyquery/engine";
registerAttachmentParser(myParser);Theming
BunnyQuery is themed with CSS custom properties (--bq-*) under a
[data-bq-theme="light"|"dark"] attribute that the widget sets on its own root.
To customize colors, override the variables in your own stylesheet after
bunnyquery.css, scoped to .bq-agent:
.bq-agent {
--bq-main: #ff4fa3;
--bq-ink: #111;
}The active theme is saved to localStorage, so a returning user keeps their choice.
Importing the chat engine
bunnyquery.js is the ready-made widget. Under it sits a framework-agnostic,
DOM-free chat engine, the same core that powers both this widget and the Skapi
admin chatbox. Import it from bunnyquery/engine when you want to build your own chat
UI (React, Vue, Svelte, vanilla…) while reusing the engine's message/queue/typewriter/
cache state machine, request builders, markdown-message composition, and prompts.
Install the package, plus the skapi-js SDK (for the transport) and, if you don't
already have one, a markdown renderer such as marked:
npm install bunnyquery skapi-js markedimport {
configureChatEngine,
ChatSession,
composeUserMessage,
type ChatHost,
} from 'bunnyquery/engine';
// Shared chat-surface styles (message bubbles, rendered markdown, links).
// Pair it with your own container/layout CSS and the --bq-* design tokens.
import 'bunnyquery/styles/chat.css';
// 1. Inject the skapi transport + MCP endpoint ONCE at startup.
configureChatEngine({
forwardRequest: (form, opts) => skapi.forwardRequest(form, opts),
forwardRequestHistory: (params, fetchOptions) =>
skapi.forwardRequestHistory(params, fetchOptions),
mcpBaseUrl: 'https://mcp.broadwayinc.computer',
poll: 0, // see the note below
});
// 2. Implement a ChatHost (identity, render/scroll hooks, the skapi
// cancel/refresh surface) for your view, then drive a ChatSession.
const session = new ChatSession(host); // host: ChatHost
await session.loadHistory();
session.dispatchComposedMessage('Hello!'); // send a messageThe engine owns chat state and logic and calls back into your view through the
ChatHost interface (render, scroll, identity, cancel/refresh). It has no bundled
runtime dependencies: you inject the skapi transport via configureChatEngine() and
render markdown yourself (e.g. with marked). Everything is fully typed: ChatSession,
ChatHost, ChatMessage, ChatIdentity, ChatState, composeUserMessage, the request
builders (callClaudeWithPublicMcp / callOpenAIWithPublicMcp, getChatHistory,
notifyAgentSaveAttachment), the prompt builders, and the token-budget / link / history
helpers. See the .d.ts shipped with bunnyquery/engine.
configureChatEngine options:
| Option | Type | Description |
| ----------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------ |
| forwardRequest | function | skapi.forwardRequest, bound to your Skapi instance. The engine calls it as forwardRequest(null, opts). Required, or its deprecated alias below. |
| forwardRequestHistory | function | skapi.forwardRequestHistory, bound to your Skapi instance. Required, or its deprecated alias below. |
| mcpBaseUrl | string | MCP server base URL (you resolve prod vs dev). Required. |
| poll | number? | Value attached as poll on every request. Omit it if your forwardRequest already resolves with the final body; pass 0 for the deployed skapi-js@latest (needed for the early ack + a manual .poll() handle that powers queued-send cancel, the widget's case). |
| attachmentParsers | array? | Client-side attachment parsers, registered at configure time. More can be added later with registerAttachmentParser(). See Attachment parser plugins. |
| windowedIndexing | boolean? | Opt in to server-driven windowed indexing for text and grid files (see file types). Off by default in the engine; the widget passes it as true. The deployed skapi workers support it; only leave it off against a self-hosted worker that does not yet strip the _skapi_window directive, where it would reach the provider as an unknown body field and fail the call terminally with no retry. |
| liveStreaming | boolean? | Opt in to live streaming of chat turns. Off by default, and the backend ships first: a streamed row settles with a status and NO body (the answer was the stream), so against a worker that does not relay, the turn reads back empty. Pair it with forwardRequestFinalize and forwardRequestStream, and gate it on skapiSupportsStreaming(skapi). |
| forwardRequestFinalize | function? | skapi.forwardRequestFinalize, bound to your Skapi instance. Stores the version of a streamed turn that history keeps (the engine sends the assembled provider body, so it reads back exactly like a buffered turn) and releases that request's chunks. Without it a streamed turn is never finalized and its row stays empty. |
| forwardRequestStream | function? | skapi.forwardRequestStream, bound to your Skapi instance. The second half of the durability guarantee: a row that settles while no poll is attached (closed tab, discarded background tab, slept device) is never finalized, so its answer stays in the chunk store and its history row is terminal and empty. Given the request id this drains that turn's chunks in one pass; the engine parses them exactly as it parses a live stream and finalizes what it read, so each row is recovered at most once. Without it the engine mints no recovery marker at all and behaves as it did before streaming. |
| onLiveStreamUpdate | function? | Observation hook for a streaming turn ({ serverItemId, ownerKey, phase, text, thinkingText, toolNames, complete, errored }). The engine already paints the answer text itself, so this is only for affordances it does not decide the presentation of. Never throw from it. |
| liveStreamingRealtime | boolean? | Push relayed chunks over skapi's websocket as well. Requires liveStreaming. Off by default: joinRealtime replaces the connection's group for the length of a turn, so only a host that owns its skapi instance should opt in. |
| streamRecovery | boolean? | Set false to force the read-back of already-streamed turns off, even though the chunk reader is injected. There is no need to set it to turn recovery on: injecting forwardRequestStream is what arms it. |
| mintIndexDoneMarker | function? | Write the durable "indexing finished" marker (done::<path>, reference src::<path>, table __INDEXING__) for the runs this client knows are complete. Best-effort, must never throw. Without it the engine falls back to inference. |
| upsertIndexRunRecord | function? | Create-or-update the per-file run record (run::<path>, reference src::<path>, table __INDEXING__), which is what lets chat rows and files-page badges paint without scanning background history. You implement the upsert (the records API has none) and the status precedence: 'working' must never overwrite a terminal status. Without it the engine uses the legacy scan/probe path. |
| csrHistoryItemLookup | function? | Single-item csr-poll point lookup, used by ChatSession.hydrateCompactItems to fetch a compact history stub's real body when an indexing row is expanded. Without it stubs keep their server-extracted heads. |
Deprecated config keys. clientSecretRequest, clientSecretRequestHistory,
clientSecretRequestFinalize and clientSecretRequestStream are still accepted, so a host written
against an earlier release keeps working unchanged. When both spellings are given the new one wins.
Through the old clientSecretRequest key the engine sends the secret's name as clientSecretName,
exactly as before, and through forwardRequest it sends secretName. Every request also names the
project it runs against in service and owner, and both keys pass those through.
Display and paging helpers
Two shared transforms exist so that a second chat UI behaves identically to the widget rather than approximately:
buildChatDisplayListcollapses a file's many background-indexing turns into one status row per indexing run, wherever those turns sit in the conversation, rendered at that run's newest turn. It is pure and view-agnostic; you render the resultingDisplayEntrylist. Pair it withChatSession.cancelIndexingGroup(group)to give the row a working Stop button, which cancels every queued and running pass of that file at once and ends the continuation chain.fillHistoryViewport/createHistoryFillerkeep older history reachable. Paging is triggered only by scrolling to the top of the message box, so a box too short to scroll has no trigger at all, which is the normal state once a page of history collapses into a single indexing row. Implement the optionalChatHost.onHistoryLoadedhook, measure your own box, and let the loop page until the reader genuinely gained reachable content.
Other optional ChatHost hooks worth implementing: deleteExistingFileRecord
(so a reindex replaces the file's knowledge instead of duplicating it) and
promptOverwrite (the skip / reindex / overwrite prompt).
ChatSession.pausePolling(reason) and resumePolling(reason) stop background
indexing polls when nobody is looking (a hidden tab, a detached view). Replies
the user is waiting on keep polling deliberately, so their results still land in
the cache. Server-side work is untouched either way, so pausing drops traffic,
never progress.
OAuth & redirects
BunnyQuery connects to your AI agent through an MCP OAuth server
(mcp.broadwayinc.computer in production, mcp-dev.broadwayinc.computer when
dev: true). After authorization, the OAuth server redirects back to the current
host page: BunnyQuery reads the ?code=…&state=… parameters, completes the
exchange, and cleans them from the URL automatically. No dedicated callback page is
needed; just make sure the page that hosts the widget is a stable, reachable URL.
Once granted, the connection is kept alive silently. When the stored grant
ages out, BunnyQuery refreshes it through the OAuth refresh_token flow with no
redirect, so an embedded widget never yanks the host page away mid-chat. It also
refreshes on tab focus, because returning to a backgrounded tab after the grant
expired would otherwise disconnect the next message. The full redirect is only a
boot-time fallback for when the silent path cannot refresh.
Notes
- The widget fills its mount element. Give that element a real height (e.g.
height: 100dvh) or it will collapse. - File and folder uploads are stored in your Skapi project's database storage and
served from a temporary db-CDN URL (
hostDomain); links in chat refresh on expiry. Links a background indexing pass carries are re-minted server-side immediately before the upstream call (_skapi_file_urls), so a pass that waits days behind a bulk upload never hands the model a dead URL. Chat-message links are not re-minted; a chat turn waits for the indexing queue to drain instead. - The number of files attachable to a single message is capped, and beyond a point the chips collapse into a "...(n) more" pill rather than being rendered. Very large batches belong on a dedicated upload page, not the chat composer.
- When your service database is frozen, the attach button and drag-and-drop are hidden for non-admin users, mirroring the backend's own upload gate, so there is no upload path that fails only at the end.
- The agent shown in the header (
BunnyQuery · <project name>) reflects the project configured for your Skapi service.
