npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

bunnyquery

v1.17.1

Published

Embeddable BunnyQuery AI chat widget + its framework-agnostic chat engine

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.com dashboard chat. 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 bunnyquery.com dashboard chat consumes, so you can build your own chat UI on top of it. See Importing the chat engine.

Current release: 1.16.0 on npm. The release-by-release changelog is HISTORY.md.

Features

  • AI chat against your project's configured agent (Claude or OpenAI under the hood), with animated pending indicators and a background indexing queue. A project that never picked a model runs on Claude Haiku 5.5 or GPT-6 Luna.
  • Live replies: the answer is painted into its bubble as the model writes it, always. A host that owns its skapi instance can also have each piece delivered over skapi's websocket the moment it is relayed (liveStreaming).
  • Grounded answers: the agent answers only from the project's data, says so when the data does not have it, and names the file and page an answer came from as citation chips under the reply. The open web is off unless the project's owner turns it on (webAccess).
  • 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 are read window by window in parallel lanes, spreadsheet rows are stored by the server directly, PDF pages are read as text where they have a text layer and rendered to page images where they do not, emails are read as their headers, body and attachment text, and everything else extractable is inlined as text. A file the signed-in user already uploaded (same name, same size) is not uploaded or indexed again (skipDuplicateFiles). 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, and its confirm dialog can take every other file being indexed down with it.
  • Stop waiting: a reply that is taking too long can be given up on. The "Thinking" bubble carries a small x that cancels the running request on the server, marks the question as cancelled, and lets the next message go out at once instead of queueing behind it. A message still waiting in the queue has the same control on its own bubble.
  • 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 localStorage and falls back to the OS preference.

Requirements

  • A BunnyQuery project (you need its project ID).
  • The skapi-js SDK 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. | | webAccess | boolean | null | Whether the chat's answers may reach the open web (Claude web_fetch, OpenAI web_search). The project's own Web access for answers switch on its Project Settings page (the web_access key of the project's bq::settings record, off unless the owner turned it on) is the ceiling, and this option may only restrict below it: effective = projectSetting && webAccess !== false. null or true follows the project; false turns the web off for this page whatever the project says; a value above the project setting has no effect. With the web off, answers come only from the project's files and database. Indexing is not affected. | | skipDuplicateFiles | boolean | null | Whether a chat attachment the signed-in user already uploaded to this project (same stored file name, same size in bytes, same uploader, found from the file's src:: record) is skipped: it is neither uploaded nor indexed again, the chip reads "already uploaded", and the message uses the stored copy's url. The project's Skip duplicate files setting (the skip_duplicate_files key of bq::settings, on unless the owner turned it off) is the ceiling, and this option may only restrict below it: effective = projectSetting && skipDuplicateFiles !== false. null or true follows the project; false lets this page re-upload duplicates; a value above the project setting has no effect. The check never blocks an upload: a failed lookup uploads as usual. | | liveStreaming | boolean | false | Deliver the streamed reply over skapi's websocket as well as the poll, so each piece lands the moment the server relays it instead of on the next one-second poll tick. Streaming itself is not an option: the widget streams every reply whenever the page's skapi-js can carry skapi's half of the stream flag (it checks for forwardRequestStream and forwardRequestFinalize, or their older clientSecretRequestStream and clientSecretRequestFinalize names), and otherwise warns once and buffers. 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. Before 1.16.0 this key meant "stream at all" and the socket was liveStreamingRealtime; that name is now ignored. |

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.16.0.

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: text pages as text, the rest as page images

.pdf

A PDF is read two ways, page by page. A run of pages that carries a text layer and no pictures or drawings is read as text, fifteen pages per window. Every other page (a scan, a photo, a page with figures) is indexed visually: the proxy worker renders a window of pages to images and injects them as image blocks in the indexing message. That 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. Scanned PDFs work as well as digital ones, and a born-digital PDF takes about a third of the passes it used to.

The worker advances the windows itself, off its renderer's true page count, and reads several windows at once in parallel lanes sized to what the AI platform's rate limits allow. 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. The verbatim text of every page (the text layer, or the model's transcription of a scanned page) is stored with the file, so the agent can later search it for an exact phrase or number and cite the page.

3. Large documents, spreadsheets & data: read window by window

.xls .xlsx .xlsm           grids: rows stored by the server directly, 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 .yml

These 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).

Spreadsheet rows take a shorter road still: the server reads the sheet and stores one record per row itself, with no model call and no tokens spent on the rows. Word, text and e-mail windows are read in parallel lanes like PDF pages, and a project indexes up to three documents at once.

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 .vtt

Plus 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. The model can open it only with its built-in web tool (Claude via web_fetch, OpenAI via web_search), and those tools are attached only when the project's Web access for answers switch is on, which it is not by default. With the web off, such a file is indexed from its name and metadata alone; register an attachment parser plugin to read it in the browser.

A provider's web tool opens document/page-style URLs, but not necessarily a bare data-file download (e.g. a raw .csv/.tsv link). 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 bunnyquery.com dashboard chat. 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 marked
import {
  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 message

The 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. | | webAccess | boolean? | Whether a chat turn may reach the open web: Claude's web_fetch and OpenAI's web_search are attached to a chat request only when this resolves to true. The project's own web_access setting (the bq::settings record, read through configureProjectSettings; off unless the project owner turned it on) is the ceiling and this option may only restrict below it: effective = projectSetting && webAccess !== false. Leave it out or pass true to follow the project; false turns the web off for your host; a value above the project setting has no effect. webAccessEnabled(serviceId) and resolveWebAccess(serviceId, override?) answer the resolved switch (sync from the cache, or awaiting the settings fetch; override is a per-call host value that restricts the same way). Indexing passes keep their tools regardless. Pass the same value to buildChatSystemPrompt({ webAccess }) so the prompt matches the tool list. | | skipDuplicateFiles | boolean? | Whether an attachment the signed-in user already uploaded to the project (same stored name, same size, same uploader) is skipped by ChatSession's upload path: not uploaded, not indexed, the turn using the stored copy's url. The project's skip_duplicate_files setting (on unless the owner turned it off) is the ceiling and this option may only restrict: effective = projectSetting && skipDuplicateFiles !== false. The check runs only when the host implements the optional findDuplicateUpload(file) hook; findDuplicateUpload(skapi, { name, size }, userId, { service? }) from the barrel is the ready-made implementation (an ends-with getUniqueId lookup narrowed to src::<name> and src::.../<name>, then the first record whose data.size_bytes and user_id match; it never throws, a failed lookup answers null and the file uploads as usual). skipDuplicateFilesEnabled(serviceId) and resolveSkipDuplicateFiles(serviceId) answer the resolved switch. | | 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. Together with forwardRequestStream it is what turns streaming on. | | 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 without the pair the engine does not stream. | | 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. | | liveStreaming | boolean? | Deliver streamed chunks over skapi's websocket as well. 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. Streaming itself has no key: the engine streams every chat turn whenever forwardRequestFinalize and forwardRequestStream are both handed over (liveStreamingEnabled()), and a host that wants buffered replies hands over neither. Before 1.16.0 this key meant "stream at all"; liveStreamingRealtime is now ignored. | | 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:

  • buildChatDisplayList collapses 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 resulting DisplayEntry list. Pair it with ChatSession.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 / createHistoryFiller keep 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 optional ChatHost.onHistoryLoaded hook, measure your own box, and let the loop page until the reader genuinely gained reachable content.
  • citationsFor / citationLinkPart turn the retrieval note the engine keeps for a reply (retrievalNoteOfAnswer) into the file-and-page citations the widget shows under an answer; answerUsedNoDataTool says whether a reply read no data at all.
  • ChatSession.cancelPendingReply(msg, idx) is the "stop waiting" control: handed the pending assistant bubble, it cancels the running request on the server, marks the question cancelled and promotes the next queued message. cancelQueuedMessage does the same for a message still in the queue.
  • findDuplicateUpload and the configureProjectSettings reader back the skipDuplicateFiles and webAccess switches; see the configureChatEngine table above.

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.