# ondocs > Make your docs answerable by people and by agents, from one static index: a cited Ask box, an MCP server and llms.txt, on your own function and key, with no vector database and no vendor. --- # Package API Source: https://ondocs.vercel.app/docs/api The package has one CLI and these imports. Each is tree-shakeable, and only `ondocs/node`, `ondocs/integration`, the framework plugins and the CLI touch Node.js built-ins, so the rest runs on edge runtimes, in workers and in browsers. | Import | For | | ------------------------------- | --------------------------------------------------------------------------------------------------------------- | | `ondocs` | Loaders, chunking, `buildIndex`, `checkIndex`, `loadIndex`, `retrieve`, `buildLlmsFiles`, `mcpInstallLinks` | | `ondocs/node` | `loadDirectory`, `readIndexFile`, `writeIndexFile`, and the config module's types | | `ondocs/integration` | The build helpers the plugins are made of, for [writing an integration](/docs/writing-an-integration) | | `ondocs/server` | `createAskHandler`, `createMcpHandler`, `remoteIndex`, the rate limiters, the budget and answer cache stores | | `ondocs/react` | `AskDialog`, `useAsk`, `AskAnswer`, `McpInstall` and `loadAskDialog`; needs `@radix-ui/react-dialog` and `cmdk` | | `ondocs/embed` | `mountAskDialog`: the dialog and its button without writing React | | `ondocs/docusaurus` | The [Docusaurus plugin](/docs/docusaurus) | | `ondocs/astro` | The [Astro integration](/docs/astro) | | `ondocs/starlight` | The [Starlight plugin](/docs/astro) | | `ondocs/vitepress` | The [VitePress plugin](/docs/vitepress): `withOndocs` | | `ondocs/astro/McpInstall.astro` | The "Add to Cursor / VS Code / Claude" block for an Astro or Starlight page | | `ondocs/mock` | `mockEmbeddingModel` and `mockLanguageModel`, for tests and offline demos | | `ondocs` (bin) | [`ondocs index`, `init` and `dev`](/docs/cli) | The handlers are documented in [The ask endpoint](/docs/ask-endpoint) and [MCP server](/docs/mcp), and the React components in [The ask dialog](/docs/ask-dialog). ## Build and search an index yourself The plugins and the CLI are built on these functions, which work on content from anywhere: ```ts import { buildIndex, fromMarkdown, loadIndex, retrieve, serializeIndexFile } from 'ondocs'; const doc = fromMarkdown(source, { id: 'guide.md', url: '/guide' }); const { index, stats } = await buildIndex({ documents: [doc], embeddingModel, previous }); const loaded = loadIndex(serializeIndexFile(index)); const { hits, answerable, best } = retrieve(loaded, { text: question, vector }); ``` `buildIndex` takes the previous index as `previous`, to reuse the vectors of chunks whose text is unchanged. `retrieve` returns the hits that passed the [relevance gate](/docs/retrieval), whether any did, and the best raw signals, for tuning the gate's thresholds. ## Also exported - **Loaders:** `fromHtml` (`root` picks the part of a page to read, as a selector such as `[data-pagefind-body]`, `ignore` what to leave out, and `markdown` keeps links and tables for a Markdown copy), `fromMarkdown` (`mdx`, `markdown`) and `fromDocuments`. - **The index file:** `chunkDocument`, `checkIndex`, `parseIndexFile`, `validateIndexFile` and `serializeIndexFile`. - **The relevance gate:** `gateHits`, which checks hits from anywhere (a reranker, a retriever of your own) against the index, as `createAskHandler` does after its [retrieval hooks](/docs/retrieval#rerank-or-retrieve-your-own-way). - **Text:** `tokenize`, and `createSlugger`, to use in your renderer so citations' anchors always match your headings. - **Search's building blocks:** `Bm25Index`, `VectorIndex`, `reciprocalRankFusion`, `quantizeInt8`, `encodeVector` and `decodeVector`. - **Bot check:** `turnstile` from `ondocs/server`, for the ask endpoint's `challenge`: see [Bot check with Turnstile](/docs/bot-challenge). - **For agents:** `buildLlmsFiles` and `markdownPath`, which build `llms.txt`, `llms-full.txt` and the pages' `.md` copies from any list of pages, and `mcpInstallLinks`, the install links and commands for an MCP server's URL. --- # The ask dialog Source: https://ondocs.vercel.app/docs/ask-dialog `AskDialog` from `ondocs/react` is a command palette built on Radix Dialog and cmdk. The Docusaurus, Astro and Starlight plugins and the script tag render the same component, so everything here applies to them too, through their `dialog` options or `data-*` attributes. ## AskDialog props | Prop | Default | Notes | | ------------------------------------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------- | | `endpoint` | `/api/ask` | Where questions are posted. Also `headers` and a custom `fetch`. | | `open`, `defaultOpen`, `onOpenChange` | uncontrolled | Controlled or uncontrolled. | | `shortcut` | `"k"` | Opens the dialog with ⌘ or Ctrl; `false` disables it. | | `trigger` | none | An element that opens the dialog, such as a search button. | | `launcher` | `false` | `true` or a label: a floating "Ask AI" button with the shortcut on it. | | `suggestions` | `[]` | Questions offered before typing, filtered as you type. | | `onNavigate` | browser navigation | `(url, event)` for citations and sources; call `preventDefault()` to route yourself. | | `theme` | `"system"` | `"light"` or `"dark"` to pin it. | | `classNames` | none | Extra classes per part: `overlay`, `content`, `input`, `list`, `item`, `answer`, `sources`, `footer`. | | `title` | "Ask this site" | The dialog's accessible name. | | `placeholder` | "Ask a question…" | The input's placeholder. | | `footer` | a disclaimer and keyboard hints | Replaces the footer. | | `links` | `"all"` | `"sources"` keeps only the answer's links to its source pages; citations always link. | | `labels` | English | Every string the dialog and its button show or announce; see [Labels and languages](#labels-and-languages). | | `locale` | none | The page's locale, sent with each question, for an endpoint with an index per locale. | | `followUps` | `true` | Send the thread with each question, so the endpoint can answer follow-ups; `false` asks each alone. | | `challenge` | none | `{ turnstile: '' }`: solve a [bot check](/docs/bot-challenge) before each question. | ## Opening the dialog In React, the dialog opens with ⌘K on macOS and Ctrl+K elsewhere. The plugins and the script tag use ⌘I or Ctrl+I instead, so ⌘K stays with the site's search. The shortcut does nothing while a text field or editor has focus. To change the keyboard shortcut, pass another key, as in `shortcut="j"`, or turn it off with `shortcut={false}` and open the dialog from a `trigger` element or the `launcher` button instead. In the plugins, the same option is `dialog: { shortcut: 'j' }`, and with the script tag it is `data-shortcut="j"`. ## When the dialog's code loads A page carries only the dialog's shortcut and, with `launcher` or `trigger`, its button. The dialog itself, Radix Dialog, cmdk and the answer, is a separate chunk that your bundler splits off, and it loads the first time the dialog is wanted: a pointer over or focus on its button or trigger, the shortcut, or `open`. A shortcut pressed while it loads is kept, so the dialog opens, with focus in its input, once it arrives; Escape before then cancels it. Focus goes back to where it was when the dialog closes, or to the button that opened it. Call `loadAskDialog()` from `ondocs/react` to load it sooner, such as when the page is idle. In the plugins' sites this takes the JavaScript ondocs adds to each page from about 22 KB to about 3 KB with Docusaurus, and from about 88 KB to under 2 KB with Starlight, which has no React of its own; see [Benchmarks](/docs/benchmarks#bundle-size). ## The launcher button `launcher` adds a floating "Ask AI" button in the corner of the page, with the shortcut on it. Pass a string to change its label, as in `launcher="Ask the docs"`, and import `ondocs/embed/launcher.css` for its look, or style `.ondocs-launcher` yourself. ## Citations and navigation Citations in the answer and the entries in the sources list link to the exact section they came from. Pass `onNavigate` to route with your framework's client router, for example `router.push(url)` in Next.js. The dialog closes after a click either way. ## Follow-up questions The dialog keeps the thread. Once an answer is on screen, typing another question offers "Follow up", and the answer stays in view while you type. The new question goes to the endpoint with the last three questions and their answers, so it can lean on them, as in "and on Netlify?": the endpoint rewrites it into one that stands on its own before it searches (see [Follow-up questions](/docs/ask-endpoint#follow-up-questions)). Earlier questions and answers stay above the current one, each with its own citations, which open its own sources. "New question" starts a new thread, with an empty input. `followUps={false}` asks every question on its own. ## Rating answers When the endpoint takes feedback (its `onFeedback`), the dialog asks "Was this helpful?" under each answer, with thumbs up and down. After a rating it thanks the reader and offers "Add a comment", a one-line field that Enter sends. Both go to the endpoint, with the answer's id, and nowhere else. `useAsk` has the same as `rate(rating, comment?)`, with `feedbackEnabled` and `rating` in its state. See [Measure and improve answers](/docs/quality#feedback-from-readers). ## Labels and languages Every string the dialog and its button show or announce, visible or for screen readers, is a label: pass any of them as `labels`, and the rest stay in English. A `{name}` in a label is filled in: `{count}` and `{n}` with numbers, `{title}` with a page's title, `{status}` with an HTTP status. `title`, `placeholder` and `launcher` win over their labels. ```tsx ``` The endpoint's own words, its "I don't know" answer, its rate limit and budget messages, its other errors and the message for an answer the model failed to write, show as it sends them, so set them there (`noAnswerMessage`, `budget.message`), or replace them in the dialog with `noAnswer`, `rateLimited`, `budgetExceeded`, `serverError` and `answerFailed`. The plugins take labels per locale; see [Docusaurus](/docs/docusaurus#locales-and-versions) and [Astro and Starlight](/docs/astro#locales-in-starlight). | Label | Default | | -------------------- | -------------------------------------------------------------------------------------------- | | `launcher` | Ask AI | | `controlKey` | Ctrl | | `title` | Ask this site | | `description` | Type a question and press Enter. Answers are generated from this site’s pages and cite them. | | `placeholder` | Ask a question… | | `ask` | Ask | | `followUp` | Follow up | | `suggested` | Suggested | | `list` | Suggestions | | `stop` | Stop | | `escapeKey` | esc | | `footer` | Answers come from this site and can be wrong. Check the sources. | | `footerAsk` | ask | | `footerClose` | close | | `searching` | Searching the site… | | `writing` | Writing an answer… | | `noAnswerFound` | No answer found on this site. | | `answerReadyOne` | Answer ready, citing 1 source. | | `answerReadyMany` | Answer ready, citing {count} sources. | | `answerTruncated` | Answer ready, but cut short at its length limit. | | `truncatedNotice` | This answer reached its length limit and may be incomplete. | | `retry` | Retry | | `sources` | Sources | | `citation` | Source {n}: {title} | | `newQuestion` | New question | | `helpfulQuestion` | Was this helpful? | | `helpful` | Yes, it helped | | `notHelpful` | No, it did not help | | `thanks` | Thanks for the feedback. | | `addComment` | Add a comment | | `comment` | Comment | | `commentPlaceholder` | What was missing or wrong? | | `send` | Send | | `thanksComment` | Thanks for the comment. | | `feedbackFailed` | The feedback could not be sent. | | `errorNetwork` | Could not reach the server. Check your connection. | | `errorUnavailable` | Answers aren’t available here right now. | | `errorNoBody` | The response had no body. | | `errorInterrupted` | The answer was interrupted. Please try again. | | `errorCutOff` | The answer was cut off. Please try again. | | `challengeRequired` | The check that you’re not a bot didn’t load. Reload the page and try again. | | `challengeFailed` | The check that you’re not a bot failed. Please try again. | | `errorUnknown` | Something went wrong. | | `noAnswer` | the endpoint's own words | | `rateLimited` | the endpoint's own words | | `budgetExceeded` | the endpoint's own words | | `serverError` | the endpoint's own words | | `answerFailed` | the endpoint's own words | ## Theming the dialog Import `ondocs/react/styles.css` for the default theme, which follows the system's light or dark setting. To change the dialog's colors, set its CSS custom properties: every color, radius and font is one, so `.ask-dialog { --ask-accent: #7c3aed; --ask-radius: 8px; }` is a complete rebrand. To style it from scratch, skip the stylesheet: every part has a stable `ask-*` class, and `classNames` adds your own, which suits Tailwind. ## Accessibility Focus moves into the dialog when it opens and returns to where it was when it closes. The answer region is `aria-live="polite"` and `aria-busy` while text streams, and a separate status region announces when the answer is ready. All animation is turned off under `prefers-reduced-motion`. The test suite runs axe on the dialog before and after it answers, and checks that every text color in the light and dark themes keeps a contrast of at least 4.5:1. ## While an answer streams Sources arrive before the first word, and the answer renders as it streams, batched to one update per animation frame. Closing the dialog, by Escape, a click outside or its parent, stops the answer in flight, so the model is not left generating for nobody. When an answer stops at the model's output limit, the dialog says it may be incomplete. ## The useAsk hook `useAsk({ endpoint })` is the hook behind the dialog, for building your own interface. It returns `ask`, `stop` and `reset`, plus `status` (`idle`, `loading`, `streaming`, `done` or `error`), `question`, `answer`, `sources`, `refused`, `truncated`, `retrieval`, `error` and `turns`, the thread's earlier questions with their answers and sources. `ask` adds the answer on screen to `turns` and sends the thread with the next question, and `reset` starts a new thread; `followUps: false` keeps no thread. `error.kind` is `rate-limited`, `http`, `network` or `stream`, with `retryAfter` for rate limits. `stop()` keeps the partial answer. `AskAnswer` renders an answer with clickable citations from `answer` and `sources`. It renders a small Markdown subset, never HTML, and only turns `[n]` into a link when source `n` exists. --- # The ask endpoint Source: https://ondocs.vercel.app/docs/ask-endpoint `createAskHandler(options)` from `ondocs/server` returns the endpoint: a function that takes a Web `Request` and returns a `Promise`. It runs in Next.js route handlers, Hono, Bun, Deno, Cloudflare Workers and Vercel or Netlify Functions. ## What happens per request 1. The body must be JSON (`content-type: application/json`); anything else gets a 415. 2. The rate limiter runs: 10 questions a minute per client IP unless you pass your own, or `false`. 3. The body is validated: `{ "question": string }` of up to 500 characters, or the conversation as `{ messages }`, in at most 64 KiB. 4. With `challenge`, the question's [bot check](/docs/bot-challenge) token is checked with Turnstile, and a question without a good one gets a 403. 5. A follow-up that does not stand on its own is rewritten into a question that does, with one short model call, and counts against the `budget` first. Everything after works from the rewritten question. 6. With `answerCache`, a question asked before is answered from the cache, and nothing else runs. 7. With `budget`, the question counts against the day's questions. 8. The question is embedded, and hybrid retrieval runs over the in-memory index. 9. If nothing clears the relevance gate, the "I don't know" message streams back and the model is never called. 10. Otherwise the top sources are numbered and, if the answer's worst case fits in the day's token `budget`, sent to the model with grounding instructions, and the answer streams back with its citations. ## Handler options | Option | Default | Notes | | -------------------------- | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `index` | required | Parsed JSON, its text, a `loadIndex` result, a function returning one, or a `remoteIndex(url)`, which is checked again every five minutes. Loaded once; a failed load is retried. | | `indexes` | none | The index of each other locale, by the locale the dialog sends: `{ fr: () => readFile('build/fr/ask-index.json', 'utf8') }`. | | `model` | required | Any AI SDK language model, or an AI Gateway model id. | | `embeddingModel` | none | Must match the index, which is checked. Without it, retrieval is keyword-only. | | `embeddingProviderOptions` | none | Must match the build, such as `{ openai: { dimensions: 512 } }`. | | `siteName` | `"this site"` | Used in the instructions and the "I don't know" message. | | `instructions` | grounded defaults | A string, or `(defaults) => string` to extend them. | | `retrieval` | tuned | `{ topK, candidates, rrfK, minSimilarity, minKeywordCoverage, similarityNeedsKeyword }`, and the [`rerank` and `retrieve` hooks](/docs/retrieval#rerank-or-retrieve-your-own-way). | | `maxContextChars` | `8000` | Characters of source text sent to the model. | | `maxQuestionLength` | `500` | Longer questions get a 400. | | `maxBodyBytes` | 64 KiB | Counted while reading the body. | | `noAnswerMessage` | "I don't know. I couldn't…" | Streamed when nothing is relevant. | | `rateLimit` | 10 a minute per IP | `memoryRateLimit()`; any `(request) => { success, … }` replaces it, and `false` turns it off. | | `rateLimitFailure` | `"closed"` | When `rateLimit` throws: `"closed"` answers 503, `"open"` answers anyway. | | `challenge` | none | `turnstile({ secretKey })`: a [bot check](/docs/bot-challenge) on each question, before anything is spent on it. A 403 without a good token. | | `generation` | `{ maxOutputTokens: 800 }` | Passed to `streamText`: `temperature`, `providerOptions`, `timeout`, `telemetry`… | | `budget` | none | `{ requestsPerDay, tokensPerDay, store }`: a daily cap, then a 429 with code `budget_exceeded`. | | `answerCache` | off | `true`, or `{ store, ttlSeconds }`: repeated questions are answered from the cache. | | `followUps` | on | `{ model, always, maxTurns, instructions }` for rewriting follow-ups; `false` answers every question on its own. | | `headers` | none | Added to every response, such as CORS headers. | | `onFeedback` | none | Called with a reader's rating of an answer; with it, the dialog offers thumbs up and down and a comment. | | `onFinish` | none | Called after each answer with `{ id, question, answer, sources, refused, lowConfidence, retrieval, usage, followUp }`. | | `onError` | `console.error` | Handled errors: embedding fallbacks, model failures, a failing limiter, misconfiguration. | ## Logging questions and answers `onFinish` runs once per answered request, after the answer has streamed, with the answer's `id`, the question, the answer, its sources, whether it was refused, whether the model cited none of its sources (`lowConfidence`) and the token usage. Use it to log the questions your docs don't answer, as in [Measure and improve answers](/docs/quality#questions-your-docs-do-not-answer), or to send analytics. If it throws, the error goes to `onError`, and the answer the visitor got is unaffected. ## Feedback With `onFeedback`, the stream's metadata says `feedback: true`, and the dialog offers a rating under each answer. It posts `{ "feedback": { "rating": "up" | "down", "comment"?, "id", "question", "answer", "sources" } }` to the endpoint, which hands it to `onFeedback` and answers 204; a comment comes as a second post with the same `id`. The rating goes through the rate limit, costs no model call, and gets a 400 without `onFeedback`, or a 503 if `onFeedback` throws. ## Request body Send `{ "question": "…" }` as `application/json` for a question on its own, or the conversation as `{ messages }`, as the dialog and the AI SDK's `useChat` send it: the last user message is the question, and the messages before it are the conversation. Either can carry the page's `locale`, which the plugins and the dialog send on a page in a locale other than the default: the handler answers it from `indexes[locale]`, and from `index` for a locale it has no index for. With `challenge`, either must also carry the [bot check](/docs/bot-challenge)'s token as `challenge`. ## Follow-up questions A follow-up such as "and on Netlify?" leans on the conversation, and searched on its own it finds the wrong pages or none. So before retrieval, the handler rewrites it into a question that stands on its own ("How do I deploy the endpoint to Netlify?") with one short call to the model: the last three questions and their answers in, at most 200 tokens out, with minimal reasoning. Retrieval, the relevance gate, the answer cache and the answer then work from the rewritten question, and each answer cites only its own sources. A question that already stands on its own is not rewritten, so it costs nothing extra. That check reads English: it looks for at least four words and none that point back, such as "it", "that" or "there". For a site whose visitors ask in other languages, `followUps: { always: true }` rewrites every question asked after another. `followUps.model` makes the rewrites with a smaller model than the answers, and `followUps: false` answers every question on its own. The rewrite counts against the `budget`: the question is admitted before it, and its tokens are reserved and settled like the answer's. If the rewrite fails, the question is answered as it was asked, and the error goes to `onError`. `onFinish` gets the rewritten question and the rewrite's usage as `followUp`. ## Errors Errors are JSON, `{ "error": { "code", "message" } }`, with status 400 (invalid body or question), 403 (the bot check's token is missing, code `challenge_required`, or rejected, code `challenge_failed`), 405 (not a POST), 413 (body too large), 415 (not JSON), 429 (rate limited, or the daily `budget` spent, with `Retry-After`), 500 (misconfigured, such as an embedding model that does not match the index) or 503 (the rate limiter, the budget store or the bot check failed). A client that disconnects before the answer starts gets a bare 499 and is not reported as an error. Model errors during an answer are masked in the stream, so provider details never reach the browser. ## Streaming protocol The response is an AI SDK UI message stream over Server-Sent Events. It starts with metadata, then one `source-url` part per numbered source, then the answer text: ``` data: {"type":"start","messageMetadata":{"refused":false,"retrieval":"hybrid"}} data: {"type":"source-url","sourceId":"1","url":"/docs/retrieval#saying-i-dont-know","title":"How retrieval works › Saying \"I don't know\""} data: {"type":"text-delta","id":"…","delta":"When no chunk clears the gate, "} data: {"type":"finish"} data: [DONE] ``` Because sources arrive before the text, citations are clickable as soon as they appear. The same stream works with `useChat` from `@ai-sdk/react`, and `readAskStream` from `ondocs/react` parses it without the AI SDK. ## Using it outside Next.js The handler is the whole integration in any runtime: `app.post('/api/ask', (c) => handler(c.req.raw))` in Hono, `Bun.serve({ fetch: handler })` in Bun, `Deno.serve(handler)` in Deno, or `export default { fetch: handler }` in a Cloudflare Worker. It answers `OPTIONS` with a 204 and your `headers`, for CORS preflights. --- # Astro and Starlight Source: https://ondocs.vercel.app/docs/astro `ondocs/starlight` is a Starlight plugin, and `ondocs/astro` is the Astro integration under it, for any other Astro site. Like the Docusaurus plugin, they build the index with the site and add the dialog to every page. They work with Astro 5, 6 and 7, and Starlight 0.32 and later. ## Install for Astro The dialog is a React component, so install React and its two dependencies along with the AI SDK: ```sh npm i ondocs ai @ai-sdk/openai react react-dom @radix-ui/react-dialog cmdk ``` ## Add it to Starlight To add ondocs to Starlight, put the plugin in Starlight's `plugins` array in `astro.config.mjs`: ```js // astro.config.mjs import starlight from '@astrojs/starlight'; import ondocs from 'ondocs/starlight'; import { defineConfig } from 'astro/config'; export default defineConfig({ integrations: [starlight({ title: 'Acme Docs', plugins: [ondocs()] })], }); ``` ## Add it to any Astro site Without Starlight, add the integration from `ondocs/astro` to `integrations`: ```js // astro.config.mjs import ondocs from 'ondocs/astro'; import { defineConfig } from 'astro/config'; export default defineConfig({ integrations: [ondocs()] }); ``` ## What Astro and Starlight index After `astro build`, the index is written to `dist/ask-index.json`, or to `dist/client/ask-index.json` on a site with an adapter, where Astro puts the static files it serves. It is at the URLs Astro serves: with your `base`, and with or without a trailing slash as `trailingSlash` and `build.format` have it. The Starlight plugin reads what Starlight's own search reads: the part of each page marked `data-pagefind-body`, which is the title and the Markdown, notes and tips included, without the header, sidebar, table of contents, edit link or pagination. Pages Starlight's search leaves out are left out too, such as the 404 page and pages with `pagefind: false`. Mark anything else with `data-pagefind-ignore`. The Astro integration reads each page's `
`. Choose another part with `content: '.prose'` (a tag, `#id`, `.class` or `[attribute]`, or a comma-separated list) and leave parts out with `ignore: '.toc'`. 404 and 500 pages, redirects and `noindex` pages are skipped. Unchanged pages reuse their vectors from the previous build, cached in `node_modules/.cache/ondocs`. ## The dialog in Astro Both add a floating "Ask AI" button and open the dialog with ⌘I or Ctrl+I, so ⌘K stays with Starlight's search. In Starlight the button sits in the corner of the table of contents column, in Starlight's colors, and the dialog follows its light or dark theme. On other Astro sites the dialog follows `data-theme` on ``, or the system setting. Vite bundles the dialog with your pages, so it shares React with your own islands. With ``, the dialog stays across navigations and citations navigate through the router. Pick another key with `dialog: { shortcut: 'j' }`, turn the shortcut off with `shortcut: false`, or hide the button with `buttonLabel: false`. `dialog: { links: 'sources' }` keeps only the answer's links to its source pages. ## Embeddings in Astro Embeddings work as in the Docusaurus plugin. OpenAI's `text-embedding-3-small` at 512 dimensions is the default when `OPENAI_API_KEY` is set at build time, or the same model through AI Gateway with `AI_GATEWAY_API_KEY`; without either key, the index is keyword-only and the build prints a warning. Choose the model with `embedding` and `dimensions`, for example `ondocs({ embedding: 'openai:text-embedding-3-small', dimensions: 512 })`, `embedding: 'workers-ai:@cf/baai/bge-small-en-v1.5'` for the [Workers AI Worker](/docs/workers-ai), or `embedding: 'mock'` to build without a key. ## Deploy the endpoint for Astro With an SSR adapter, there is nothing to deploy apart from the site: the integration serves `POST /api/ask` and `/api/mcp` itself, from the index it builds, and the dialog posts there. Set `OPENAI_API_KEY` where the site builds and where it runs. `route` changes the model and the limits, and `route: false` turns the routes off; see [Astro with an adapter](/docs/deployment#astro-with-an-adapter). With `dialog.challenge.turnstile`, the ask route checks each question's [bot check](/docs/bot-challenge) token, with the secret in `TURNSTILE_SECRET_KEY`. A static site, without an adapter, needs the endpoint as a function on its host. Write it with `npx ondocs init`, which reads `dist/ask-index.json` on Vercel, Netlify or Cloudflare, or writes a Cloudflare Worker for GitHub Pages; see [Deploying](/docs/deployment). Astro writes `base` into URLs, not folders, so with `base: '/docs'` the file is still `dist/ask-index.json` and is served at `/docs/ask-index.json`. To try it locally, build the site once and start `astro dev`: with an adapter, the integration's routes answer from the index the build wrote. Without one, run `npx ondocs dev`, which answers from `dist/ask-index.json` or `dist/client/ask-index.json`, and start `astro dev` with `ASK_ENDPOINT=http://localhost:8787/api/ask`. ## Astro and Starlight options | Option | Default | What it does | | -------------------------------------------- | ---------------------------------- | ----------------------------------------------------------------------------------------------- | | `endpoint` | `ASK_ENDPOINT`, else `/api/ask` | Where the dialog posts questions; `ASK_ENDPOINT` overrides it. | | `embedding`, `dimensions` | OpenAI when a key is set | The embedding model, named as the CLI names it. | | `embeddingModel`, `embeddingProviderOptions` | none | An AI SDK model object and its options, instead of `embedding`. | | `chunking` | `{ maxChars: 1200, overlap: 150 }` | Chunk size and overlap, in characters. | | `indexFile` | `ask-index.json` | Where the index is written in `dist/` and served from. | | `exclude` | `[]` | Path prefixes to leave out, relative to `base` and the locale. | | `dialog` | | `title`, `placeholder`, `suggestions`, `shortcut`, `buttonLabel`, `theme`, `labels`, `locales`. | | `route` | on with an SSR adapter | The endpoints the integration serves: `{ model, rateLimit, budget, mcp }`, or `false`. | | `content`, `ignore` | `main` | Astro integration only: what to read from each page, and what to skip. | | `mcp` | none | The [MCP endpoint](/docs/mcp)'s URL or path, for `McpInstall.astro`. | | `llmsTxt` | on | Write [llms.txt, llms-full.txt and .md copies](/docs/llms-txt); `false` turns them off. | ## Locales in Starlight Starlight builds every locale at once, so the plugin writes one index per locale: `dist/ask-index.json` for the root locale and `dist/fr/ask-index.json` for French, served at `/fr/ask-index.json`. A page not yet translated is indexed with the fallback content Starlight shows for it, as Starlight's search does. The Astro integration splits the index the same way along Astro's `i18n` locales. On a locale's pages, the dialog takes the locale from the page's path and sends it with each question, and the endpoint answers from that locale's index: the routes the integration serves with an adapter do, and so do the endpoints `npx ondocs init` writes, which read the locales from the config. Give the dialog its words in each language with `dialog.locales`, by the locale's path (`fr`, `pt-br`), over the dialog's own options: `ondocs({ dialog: { locales: { fr: { title: 'Demander à Acme', labels: { launcher: 'Demander' } } } } })`. Without a `title` of its own, a locale's dialog is named after the site's title in its language. Every label is listed in [The ask dialog](/docs/ask-dialog#labels-and-languages). --- # Benchmarks Source: https://ondocs.vercel.app/docs/benchmarks `pnpm bench` in the repository builds the package and measures it on synthetic, documentation-shaped corpora. These are the numbers the rest of these docs quote. ## Method The corpora have a Zipf-distributed vocabulary and chunks of 600 to 1,000 characters grouped into pages, with 512-dimension embeddings clustered around shared topics, as one site's embeddings are. Query latency covers hybrid retrieval end to end, BM25, the cosine scan and fusion, and excludes the embedding API call, which is network-bound and the same for any design. The runs below are from an Apple M1 Max on Node 24.18, with 1,000 queries per size after warm-up. ## Results Size and load, per server instance: | Chunks | Index | gzip | As float JSON | Cold load | Heap retained | Process memory (peak) | | -----: | ------: | ------: | ------------: | --------: | ------------: | --------------------: | | 1,000 | 1.6 MB | 0.9 MB | 7.5 MB | 32 ms | 3.2 MB | +23 MB (+31 MB) | | 10,000 | 15.7 MB | 8.5 MB | 75.4 MB | 339 ms | 32.8 MB | +238 MB (+294 MB) | | 50,000 | 78.7 MB | 42.6 MB | 377.1 MB | 1.72 s | 123.9 MB | +759 MB (+1,130 MB) | Query latency and recall: | Chunks | p50 | p95 | p99 | Recall@10 | | -----: | -------: | -------: | -------: | --------: | | 1,000 | 0.71 ms | 0.76 ms | 0.91 ms | 99.9% | | 10,000 | 6.96 ms | 7.32 ms | 7.84 ms | 99.1% | | 50,000 | 37.11 ms | 39.01 ms | 44.03 ms | 98.5% | At 10,000 chunks, the BM25 half of a query takes 2.3 ms and the vector scan 4.6 ms at the median. Cold load is a one-time cost per server instance: parse the file, decode the vectors and build the inverted index. Heap retained is what the loaded index holds. Process memory is how much a fresh Node.js process grows to load the index from its JSON text, once the text is collected and at the peak of parsing: that, not the heap, counts against a platform's memory limit, so on a 128 MB Cloudflare Worker keep the index to a few thousand chunks. Process memory was measured later, on the same machine. Recall@10 is the overlap between the int8 top 10 and the exact float32 top 10. ## How fast is search Search over 1,000 chunks takes under a millisecond, 0.71 ms at the median, and about 7 ms over 10,000 chunks. Everything is linear in corpus size, so a site's numbers can be read off the table: roughly 1.6 MB of index, 32 ms of cold load and 0.7 ms per query for every 1,000 chunks. In a real request, retrieval is the small part: nearly all of the time goes to the embedding call for the question and the model's answer, which are network calls to your provider. ## Bundle size Each page carries only the dialog's button and shortcut; the dialog loads the first time it is wanted (see [when the dialog's code loads](/docs/ask-dialog#when-the-dialogs-code-loads)). Measured in headless Chrome on the example sites with `node scripts/js-weight.mjs`, as the JavaScript a docs page loads before any interaction, gzipped at level 9, in KB of 1,024 bytes: | Site | The site on its own | With ondocs | When the dialog opens | | -------------------------- | ------------------: | ----------: | --------------------: | | Docusaurus 3.10 | 171.9 KB | 174.7 KB | +24.0 KB | | Starlight 0.42 | 33.7 KB | 35.1 KB | +90.0 KB | | Script tag on a plain page | 0 | 2.7 KB | +94.0 KB | Starlight's dialog brings its own React, as the script tag's does; Docusaurus's shares the site's. Before the dialog loaded on demand, these pages carried 192.7 KB, 121.2 KB and 91.2 KB. CI measures the example sites the same way, with `node scripts/js-weight.mjs --check`, and fails a change that goes over each site's budget, or that stops a shortcut pressed while the dialog loads from opening it with focus in its input, or focus from coming back when it closes. The handler adds no client code at all. ## Run it yourself ```sh pnpm install pnpm bench ``` The [benchmark source](https://github.com/dgesteves/ondocs/blob/main/bench/run.mjs) documents the method. --- # Bot check with Turnstile Source: https://ondocs.vercel.app/docs/bot-challenge A rate limit is per client, and a daily `budget` caps the whole endpoint, but a fleet of addresses can still use up the day's budget, leaving none for your readers. A bot check stops that: the dialog gets a token from [Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/) before each question, and the endpoint checks the token with Cloudflare before it spends anything on the question. For most visitors the check is invisible. It is off by default. Turn it on in two places: the dialog, with the widget's site key, and the endpoint, with its secret key. ## Create a widget In the Cloudflare dashboard, under Turnstile, add a widget for your site's hostnames. Choose the "Invisible" widget mode: visitors see nothing. With "Managed", Turnstile sometimes asks a visitor to click a checkbox, and the dialog shows it in place. You get a site key, which is public, and a secret key, which stays on the server. ## The endpoint Pass `challenge: turnstile({ secretKey })` to `createAskHandler`: ```ts import { openai } from '@ai-sdk/openai'; import { createAskHandler, turnstile } from 'ondocs/server'; import index from './ask-index.json'; export const POST = createAskHandler({ index, model: openai('gpt-5.4-mini'), embeddingModel: openai.embedding('text-embedding-3-small'), challenge: turnstile({ secretKey: process.env.TURNSTILE_SECRET_KEY! }), }); ``` Each question must then carry a token, as `challenge` in its JSON body. The endpoint checks it after the rate limit and before anything else: before the answer cache, the embedding and the model. | Request | Response | | --------------------------------------------------- | --------------------------------------------------------------------- | | No token | 403, code `challenge_required` | | A token Turnstile rejects (bad, used, expired) | 403, code `challenge_failed` | | Turnstile cannot be reached, or the secret is wrong | 503, reported to `onError`; with `rateLimitFailure: 'open'`, answered | | A good token | the answer | A token is good for one question and five minutes. Ratings sent to `onFeedback` need no token: they cost nothing. The endpoints `ondocs init` writes turn the check on by themselves once `TURNSTILE_SECRET_KEY` is set where they run, and so does the [Cloudflare Worker template](/docs/workers-ai): `npx wrangler secret put TURNSTILE_SECRET_KEY`. ## The dialog Give the dialog the widget's site key with `challenge: { turnstile: '' }`, wherever you set its options: ```ts // docusaurus.config.ts plugins: [['ondocs/docusaurus', { dialog: { challenge: { turnstile: '0x4AAAAAAA…' } } }]], // astro.config.mjs, for Astro, and for Starlight in its plugins ondocs({ dialog: { challenge: { turnstile: '0x4AAAAAAA…' } } }), // .vitepress/config.mts export default withOndocs(defineConfig({ … }), { dialog: { challenge: { turnstile: '0x4AAAAAAA…' } } }); ``` ```tsx // React ``` ```html ``` When the dialog opens, it loads Turnstile's script from `challenges.cloudflare.com` and solves a token, so the first question rarely waits for one. It sends a fresh token with each question and solves the next one meanwhile. Pages load nothing of Turnstile until the dialog is opened, and nothing at all without `challenge`. If Turnstile's script cannot load, the question goes without a token and the dialog says the check did not load, in the words of its `challengeRequired` label; a rejected token shows `challengeFailed`. Both are labels like the others, for your language. A site with a Content-Security-Policy must allow `https://challenges.cloudflare.com` in `script-src` and `frame-src`. ## Astro and Starlight When the integration serves the endpoint itself, setting `dialog.challenge.turnstile` turns the check on there too. The route reads the secret from `TURNSTILE_SECRET_KEY` through `astro:env`; without it, questions get a 503 and the server log says why. ## Your own client With `useAsk`, pass `challengeToken`: a function that returns a fresh token before each question, from a Turnstile widget you render yourself. A plain `useChat` client sends no token, so it cannot ask an endpoint with `challenge` set. ## Agents and MCP The [MCP endpoint](/docs/mcp) is not checked: agents cannot solve a challenge, and their searches are keyword-only, so they call no model. It keeps its own rate limit, `rateLimit` on `createMcpHandler`. ## Testing Cloudflare's test keys work on any hostname, `localhost` included, and always give the same result. The site key `1x00000000000000000000BB` always passes, invisibly, and `2x00000000000000000000BB` always fails. The secret key `1x0000000000000000000000000000000AA` accepts their token, `2x0000000000000000000000000000000AA` rejects it, and `3x0000000000000000000000000000000AA` says it was used before. Test secret keys accept only the test token, and real ones reject it, so use test keys on both sides in development. --- # CLI and config file Source: https://ondocs.vercel.app/docs/cli The `ondocs` command has four subcommands: `index` builds or checks the index, `init` writes the endpoint for your host, `dev` serves the endpoint on your machine, and `eval` scores retrieval against questions whose answers you know. Run them with `npx ondocs`, or from a `package.json` script. ## ondocs index `ondocs index [dir] [options]` builds `ask-index.json` from the Markdown, MDX and HTML files in `dir`. | Flag | Default | What it does | | --------------------------------------------------------- | ------------------------ | -------------------------------------------------------------------------------------------- | | `-o, --out ` | `ask-index.json` | Where to write the index. | | `--check` | off | Exit 1 if the index is stale. Never calls a model. | | `--base-url ` | `/` | URL prefix for pages, such as `/docs` or a full origin. | | `-e, --embedding ` | OpenAI when a key is set | `openai:`, `workers-ai:@cf/`, `/`, `mock[:]` or `none`. | | `--dimensions ` | the model's | Vector size, for models that support it. With `--check`, the size the index must have. | | `--chunk-size ` | `1200` | Maximum characters per chunk. | | `--chunk-overlap ` | `150` | Characters shared by consecutive chunks of a section. | | `--ignore ` | none | Skip matching files. Repeatable. | | `--framework ` | detected | `docusaurus`, `starlight`, `next` or `none`: how file paths become URLs. | | `--clean-urls` | off | Drop `.html` from HTML files' URLs. | | `--llms-txt ` | off | Also write [llms.txt, llms-full.txt and a .md per page](/docs/llms-txt) into `dir`. | | `--no-llms-index`, `--no-llms-full`, `--no-llms-markdown` | | Leave out one of those. | | `--llms-title`, `--llms-description` | `package.json` name | The site's name and summary, at the top of `llms.txt`. | | `--site-url`, `--mcp-url` | none | The site's origin, for absolute links, and its MCP endpoint, for `llms.txt`. | | `-c, --config ` | none | A module whose default export is an `AskConfig`. | | `-q, --quiet` | off | Only print errors. | | `-h, --help`, `-v, --version` | | Print the help, or the version. | ## Choosing the embedding model Without `--embedding`, the CLI uses `openai:text-embedding-3-small` when `OPENAI_API_KEY` is set, or `openai/text-embedding-3-small` through AI Gateway when only `AI_GATEWAY_API_KEY` is. With neither, it fails rather than quietly building a keyword-only index, and suggests `-e mock` to try it without a key. `-e none` builds a keyword-only index on purpose. `-e workers-ai:@cf/baai/bge-small-en-v1.5` embeds with Workers AI over Cloudflare's REST API, with `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN`, for the [Workers AI Worker](/docs/workers-ai); its vector size is fixed, so `--dimensions` does not apply. ## Environment and exit codes The CLI reads `.env` and `.env.local` from the working directory, without overriding variables that are already set. It exits with 0 on success, 1 when the build fails or `--check` finds the index stale, and 2 on a usage error. ## ondocs init `ondocs init [options]` writes the ask endpoint and the MCP endpoint for the site in the working directory and the host it deploys to: Vercel, Netlify, Cloudflare (Pages, or a Worker with static assets) or GitHub Pages, whose endpoint is a Cloudflare Worker of its own. It detects the site from its config (Docusaurus, Starlight, Astro, Next.js, VitePress, Hugo, MkDocs, Jekyll or Eleventy) and the host from `vercel.json`, `netlify.toml`, a wrangler config or a GitHub Pages workflow, and asks for the host when it cannot tell. [Deploying](/docs/deployment#write-the-endpoint-with-init) shows what it writes for each. | Flag | Default | What it does | | ------------------- | ---------------------- | ------------------------------------------------------------------------------ | | `--host ` | detected, else asked | `vercel`, `netlify`, `cloudflare` or `github-pages`. | | `--provider ` | `workers-ai` | For `github-pages`: `workers-ai`, with no API key, or `openai`. | | `--site-url ` | from the site's config | The site's public URL with its base path, which the GitHub Pages Worker reads. | | `--name ` | the site's title | The site's name, in the model's instructions. | | `--out ` | detected | A static site's build folder, where the index goes. | | `--no-mcp` | the MCP endpoint is on | Write the ask endpoint only. | | `--dry-run` | off | Print every file it would write, and write nothing. | | `-y, --yes` | asks | Overwrite files that differ without asking. | | `-h, --help` | | Print the help. | It prints each file it created, updated or left unchanged, the environment variables to set and where, and the steps left: the packages to install, the dialog to add and the deploy. It exits with 0 when everything is written, 1 when it left a file as it was or an edit for you to make by hand, and 2 on a usage error. It reads no `.env` file and no credential, and it deploys nothing. An Astro site with an SSR adapter gets no file: its integration serves the endpoints. ## ondocs dev `ondocs dev [options]` serves `POST /api/ask` on 127.0.0.1, answering from a built index, for the dialog on your site's dev server. It serves the same index as an [MCP server](/docs/mcp) at `/api/mcp`, keyword-only, for your editor's agent. | Flag | Default | What it does | | ------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------- | | `--index ` | `ask-index.json`, then the build output of Docusaurus, Astro or VitePress | The index to serve. | | `--port ` | `8787` | Port to listen on. | | `--allow-origin ` | localhost | Another origin allowed to call it, or `*`. Repeatable. | | `--model ` | `gpt-5.4-mini` | OpenAI model for answers, when `OPENAI_API_KEY` is set. | | `-h, --help` | | Print the help. | It embeds questions with the model the index records, answers with OpenAI when `OPENAI_API_KEY` is set and with the mock model otherwise, and reloads the index when the file changes. It only answers pages on this machine (localhost, 127.0.0.1 and [::1] origins, on any port) and requests addressed to localhost, so a site you visit cannot spend your key through it, and it caps request bodies at 64 KiB. [Try it locally](/docs/local-development) shows it with each integration. ## ondocs eval `ondocs eval [options]` scores retrieval against a file of questions with the pages that answer them, and questions marked `unanswerable`: hit@1, hit@3, refusal precision and refusal recall, question by question. It exits 1 when a score is under its threshold. [Measure and improve answers](/docs/quality#score-retrieval-with-eval) has the file's format. | Flag | Default | What it does | | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------- | | `--index ` | the file's `index`, then `ask-index.json`, `build/`, `dist/`, `dist/client/`, `.vitepress/dist/` | The index to score. | | `--min-hit-at-1`, `--min-hit-at-3`, `--min-refusal-precision`, `--min-refusal-recall` | the file's `thresholds` | Thresholds from 0 to 1. | | `--keyword-only` | off | Search by keywords only, embedding nothing. | | `--json` | off | Print the report as JSON. | | `-h, --help` | | Print the help. | It embeds each question with the model the index records, as `ondocs dev` does, and calls no language model. It loads `.env` and `.env.local` like `index`, and exits 2 on a usage error or a file it cannot read. ## The config file For an embedding provider the specs don't cover, or content that is not on disk, pass a config module with `--config`. Every field is optional: ```js // ondocs.config.mjs import { cohere } from '@ai-sdk/cohere'; import { getPosts } from './lib/cms.mjs'; /** @type {import('ondocs/node').AskConfig} */ export default { embeddingModel: cohere.embedding('embed-multilingual-v3.0'), baseUrl: '/docs', documents: async () => (await getPosts()).map((post) => ({ id: post.slug, url: `/blog/${post.slug}`, title: post.title, content: post.markdown, })), }; ``` | Field | What it does | | -------------------------- | ----------------------------------------------------------------------------------- | | `embeddingModel` | An AI SDK embedding model, or `null` for keyword-only. | | `embeddingProviderOptions` | Passed to the embedding model, such as `{ openai: { dimensions: 512 } }`. | | `chunking` | `{ maxChars, overlap }`. | | `baseUrl` | URL prefix for pages loaded from the folder. | | `ignore` | Glob patterns of files to skip. | | `framework` | `'auto'` (the CLI's default), `'docusaurus'`, `'starlight'`, `'next'` or `'none'`. | | `cleanUrls` | Drop `.html` from HTML files' URLs. | | `documents` | `{ id, url, title, content }` records from elsewhere, or a function returning them. | `defineConfig` from `ondocs/node` types the object without a JSDoc comment. Share the config with your route handler, as the ondocs website does, so the index and the queries always use the same embedding model. --- # Deploying Source: https://ondocs.vercel.app/docs/deployment The endpoint deploys wherever your site's server code runs, as one function. There is no database to provision and nothing to keep in sync: the index is a file built with the site. `npx ondocs init` writes that function for your host. ## Write the endpoint with init Run `npx ondocs init` in your site's folder. It works out the site and the host from their config files, writes the ask endpoint and the [MCP endpoint](/docs/mcp), and prints what it wrote, the environment variables to set and what is left to do: ```sh npx ondocs init # detects the site and the host, or asks which host npx ondocs init --dry-run # prints every file it would write, and writes nothing ``` | Host | Detected from | What init writes | | -------------------------------------- | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | | Vercel | `vercel.json` or `.vercel/` | `api/ask.ts` and `api/mcp.ts`, and `includeFiles` for the index in `vercel.json` | | Netlify | `netlify.toml` or `.netlify/` | `netlify/functions/ask.mts` and `mcp.mts`, and `included_files` for the index in `netlify.toml` | | Cloudflare Pages | a wrangler config with `pages_build_output_dir`, or `--host cloudflare` without one | `functions/api/ask.ts` and `mcp.ts`, which read the index through the `ASSETS` binding | | Cloudflare Workers with static assets | a wrangler config with `assets` | `worker/ondocs.ts`, which answers `/api/*` and serves the files, and `main` and the `ASSETS` binding in the config | | GitHub Pages, or any other static host | a Pages workflow, a `gh-pages` script or `docusaurus deploy` | `ondocs-worker/`, a Cloudflare Worker of its own that reads the index from the live site and needs no API key | For a Next.js app it writes `app/api/ask/route.ts` and `app/api/mcp/route.ts` instead, which import the index, with the client IP header of the host. An Astro site with an SSR adapter needs no file at all: see [Astro with an adapter](#astro-with-an-adapter). On a site with several locales, read from Docusaurus's or Astro's `i18n` or Starlight's or VitePress's `locales`, the ask endpoint gets each locale's index as `indexes`, bundled with it, so the dialog on a locale's pages is answered from that locale's docs. Every endpoint it writes has a rate limit keyed on the client IP header the host sets (10 questions and 60 tool calls a minute), a daily budget of 500 questions and 1.5 million model tokens, the answer cache, and the CORS preflight answered. The one on another origin than the pages, the GitHub Pages Worker, sends CORS headers for the site's origin only. The files are plain code you own: edit the model, the limits or the budget there. Run it again whenever you like. Files that are already as it would write them are left alone, a file that differs is only overwritten if you say yes (or pass `--yes`), and an edit to a config file it cannot make safely, such as a `netlify.toml` that already configures the function, is printed for you to make by hand. It never deploys anything, and it reads no `.env` file or credential. `--no-mcp` leaves out the MCP endpoint, and [the CLI reference](/docs/cli#ondocs-init) lists every flag. ## Astro with an adapter On an Astro or Starlight site with an SSR adapter, the integration serves `POST /api/ask` and `/api/mcp` itself, with `injectRoute`, so there is no endpoint file to write or deploy. CI builds a site this way with the Node, Vercel and Cloudflare adapters and asks it questions. The routes answer from the index the build writes, which they read from the site's own static files, once per server instance (through the `ASSETS` binding on Cloudflare). Questions are embedded with the model the index records. Set `OPENAI_API_KEY` where the site builds and where it runs; `init` says so and writes nothing. The routes have the same defaults as the files `init` writes: 10 questions and 60 tool calls a minute per visitor, by the client address the adapter reports, a daily budget of 500 questions and 1.5 million model tokens per server instance, and the answer cache. Change them with `route`, or turn the routes off with `route: false` for a route of your own: ```js ondocs({ route: { model: 'openai:gpt-5.4-mini', // or an AI Gateway id, such as 'anthropic/claude-haiku-4.5' rateLimit: 10, budget: { requestsPerDay: 500, tokensPerDay: 1_500_000 }, mcp: '/api/mcp', // false serves no MCP endpoint }, }); ``` A route file the site already has at `src/pages/api/ask.ts` or `src/pages/api/mcp.ts` answers instead. With `base: '/docs'`, the routes are at `/docs/api/ask` and `/docs/api/mcp`, and the dialog posts there. In `astro dev`, the routes answer from the index the last `astro build` wrote. On a Vercel preview behind Deployment Protection, the route can only read the index with the project's Protection Bypass for Automation secret, which it uses when Vercel sets `VERCEL_AUTOMATION_BYPASS_SECRET`. ## GitHub Pages and other static hosts A host that serves only files, such as GitHub Pages, S3, Read the Docs or a plain web server, cannot run the endpoint. `init --host github-pages` writes `ondocs-worker/`, a Cloudflare Worker of its own, that answers for the site from another origin with Workers AI, so there is no API key to set (`--provider openai` answers with OpenAI instead): - It fetches `ask-index.json` from the live site, at the site's URL from your config (or `--site-url`), keeps it in memory, and checks it again every five minutes with a conditional request, so it follows the site's deploys without being redeployed. - It sends CORS headers for the site's origin, and only for it, so other sites' pages cannot spend your model budget through their visitors' browsers. - The MCP endpoint's results link to the pages on the site, not on the Worker. Deploy it with `cd ondocs-worker && npm install && npx wrangler deploy`, then set the dialog's `endpoint` (or the script tag's `data-endpoint`) to the URL Wrangler prints, plus `/api/ask`. The site keeps deploying as it does today. The same Worker is a template for `npm create cloudflare`; [Workers AI](/docs/workers-ai) has both ways in, the models and what they cost, and how large a site fits. The Worker uses `remoteIndex` from `ondocs/server`, which any endpoint deployed apart from its site can use: `createAskHandler({ index: remoteIndex('https://acme.github.io/docs/ask-index.json'), … })`. A check that fails keeps the index it has; only the first fetch's failure fails a request. The first request in each Worker instance loads the index: 17 ms for this site's docs (334 chunks) and 38 ms for 1,000 chunks, in a fresh Node.js process on an Apple M1 Max. The Workers Free plan gives a request 10 ms of CPU time, with what Cloudflare calls "some built-in flexibility" for a Worker that "infrequently runs over" it, so a small site fits; a large one needs the Workers Paid plan, which allows 30 seconds by default. ## Runtimes The handler uses Web APIs only, so it runs on Node.js, Bun, Deno, Cloudflare Workers, Vercel Functions and Netlify Functions. File-system helpers live in `ondocs/node` and are only needed at build time. ## Where the index lives There are three ways for the endpoint to read the index: - **Import it.** `import index from './ask-index.json'` bundles the index with the function. This is the simplest, and what a Next.js app does. - **Read it from the build output.** A static site's function reads `build/ask-index.json` (Docusaurus), `dist/ask-index.json` (Astro; `dist/client/ask-index.json` with an adapter) or `.vitepress/dist/ask-index.json` (VitePress) with `readFile`, and the host bundles the file with the function. - **Fetch it.** For large indexes on platforms that limit function size, or an endpoint deployed apart from its site, serve the file as a static asset and fetch it: `index: remoteIndex(url)`, which checks it again after a deploy, or a loader such as `index: () => fetch(url).then((r) => r.text())`. A loader runs once per server instance, on the first question, and a failed load is retried on the next request. A `remoteIndex` is checked every five minutes (`revalidateSeconds`), with the file's `ETag`, so an unchanged index is never downloaded again. ## Deploy to Vercel In a Next.js app on Vercel there is nothing to configure: the route handler is a Vercel Function, and the imported index is bundled with it. For a static site, such as a Docusaurus or Astro site, `init` writes `api/ask.ts`. This is it without the budget, the answer cache and the comments: ```ts // api/ask.ts import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { openai } from '@ai-sdk/openai'; import { createAskHandler, memoryRateLimit } from 'ondocs/server'; export const POST = createAskHandler({ index: () => readFile(join(process.cwd(), 'build/ask-index.json'), 'utf8'), model: openai('gpt-5.4-mini'), embeddingModel: openai.embedding('text-embedding-3-small'), siteName: 'Acme Docs', rateLimit: memoryRateLimit({ limit: 10, windowMs: 60_000 }), }); ``` Then include the index in the function's bundle with `vercel.json`: `{ "functions": { "api/ask.ts": { "includeFiles": "build/ask-index.json" } } }`. Use `dist/ask-index.json` for Astro, or `dist/client/ask-index.json` with an adapter. On Vercel, rate limits need no extra setting, because Vercel overwrites `X-Forwarded-For` with the client's IP. ## Deploy to Netlify On Netlify, `init` writes a function at `netlify/functions/ask.mts` that rate-limits on the client IP header Netlify sets. Shortened, it is: ```ts // netlify/functions/ask.mts import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { openai } from '@ai-sdk/openai'; import { createAskHandler, memoryRateLimit } from 'ondocs/server'; export default createAskHandler({ index: () => readFile(join(process.cwd(), 'build/ask-index.json'), 'utf8'), model: openai('gpt-5.4-mini'), embeddingModel: openai.embedding('text-embedding-3-small'), rateLimit: memoryRateLimit({ limit: 10, windowMs: 60_000, trustedHeader: 'x-nf-client-connection-ip', }), }); export const config = { path: '/api/ask' }; ``` Include the index in the function's bundle with `netlify.toml`: ```toml [functions.ask] included_files = ["build/ask-index.json"] ``` ## Deploy to Cloudflare On Cloudflare Pages, `init` writes a Pages Function at `functions/api/ask.ts`. It fetches the index from the site's own static assets, and reads the key from the function's environment. Shortened, it is: ```ts // functions/api/ask.ts import { createOpenAI } from '@ai-sdk/openai'; import { createAskHandler, memoryRateLimit } from 'ondocs/server'; const INDEX = '/ask-index.json'; let handler: ((request: Request) => Promise) | undefined; export const onRequest: PagesFunction<{ OPENAI_API_KEY: string; ASSETS: Fetcher }> = ({ request, env, }) => { const openai = createOpenAI({ apiKey: env.OPENAI_API_KEY }); handler ??= createAskHandler({ index: async () => { const response = await env.ASSETS.fetch(new URL(INDEX, request.url)); if (!response.ok) throw new Error(`${INDEX}: HTTP ${String(response.status)}`); return response.text(); }, model: openai('gpt-5.4-mini'), embeddingModel: openai.embedding('text-embedding-3-small'), rateLimit: memoryRateLimit({ limit: 10, windowMs: 60_000, trustedHeader: 'cf-connecting-ip' }), }); return handler(request); }; ``` For a static site on Cloudflare Workers, `init` writes `worker/ondocs.ts`, which answers `/api/ask` and `/api/mcp` and hands every other request to the static assets, and points the wrangler config's `main` at it. A Worker that already has an entry point can call its `ondocs(request, env)` from there. On Cloudflare, always rate-limit with `trustedHeader: 'cf-connecting-ip'`, as above: the default limiter reads `X-Forwarded-For`, which is not the header Cloudflare vouches for. ## Environment variables Set your provider's key, such as `OPENAI_API_KEY`, in two places: in the build, if the build embeds the index, and in the function's runtime environment, which embeds questions and calls the model. `init` prints where each goes on your host. The key never reaches the browser: the dialog only talks to your endpoint. A build without the key writes a keyword-only index, which the endpoint answers from without embedding questions. ## Cold starts Loading the index is a one-time cost per server instance: about 32 ms for 1,000 chunks and 339 ms for 10,000, measured on an Apple M1 Max. After that, a query takes 0.7 ms of CPU at 1,000 chunks and about 7 ms at 10,000, so nearly all of a request's time is the embedding call and the model's answer. ## Cost ondocs itself is free and MIT-licensed; you pay your model provider. Each answered question costs one embedding call for the question and one model call for the answer, with at most 8,000 characters of sources in (`maxContextChars`) and at most 800 tokens out (`generation.maxOutputTokens`). A follow-up that needs rewriting costs one more call, with the last three questions and answers in and at most 200 tokens out. A refused question costs the embedding call only: the model is never called. Indexing costs one embedding call per changed chunk, since unchanged chunks reuse their vectors. --- # Docusaurus Source: https://ondocs.vercel.app/docs/docusaurus The `ondocs/docusaurus` plugin adds ondocs to a Docusaurus 3 site with one line of config. After every `docusaurus build` it indexes the pages the site serves, and in the browser it adds the dialog with a floating "Ask AI" button. ## Install the plugin Install the package with the AI SDK, the OpenAI provider (the default embedding model) and the dialog's two dependencies: ```sh npm i ondocs ai @ai-sdk/openai @radix-ui/react-dialog cmdk ``` ## Add it to docusaurus.config.ts To add ondocs to Docusaurus, put the plugin in the `plugins` array of `docusaurus.config.ts`: ```ts // docusaurus.config.ts export default { // … plugins: ['ondocs/docusaurus'], }; ``` The dialog posts questions to `/api/ask` on your site. Set `endpoint` for another path or host, as in `['ondocs/docusaurus', { endpoint: 'https://ask.example.com/api/ask' }]`. The `ASK_ENDPOINT` environment variable, when it is set as the site builds or starts, overrides both, which is how [local development](/docs/local-development) points the dialog at `ondocs dev`. ## What the plugin indexes After `docusaurus build`, the plugin indexes the docs, blog posts and MDX pages into `build/ask-index.json`, at the exact URLs Docusaurus generated, so citations never guess at a URL. It reads each page's Markdown content without the navbar, breadcrumbs, table of contents, doc cards or pagination. Pages that only list other pages are left out: blog lists, tag and author pages, and generated category indexes. There is nothing to commit. The index is rebuilt with the site, and unchanged pages reuse their vectors from the previous build, cached in `node_modules/.cache/ondocs`, which Netlify and Vercel keep between builds. Leave sections out with `exclude`, for example `exclude: ['/changelog']`. ## The dialog and the shortcut In the browser the plugin adds a floating "Ask AI" button beside the back-to-top button, and opens the dialog with ⌘I or Ctrl+I. ⌘K stays with your search, and text fields keep ⌘I for italic. The dialog follows the site's light or dark mode, and citations route through Docusaurus without a page reload. DocSearch's own Ask AI panel also opens with ⌘I. Pick another key with `dialog: { shortcut: 'j' }`, turn the shortcut off with `shortcut: false`, or hide the button with `buttonLabel: false`. `dialog: { links: 'sources' }` keeps only the answer's links to its source pages. ## Embeddings in Docusaurus The plugin embeds with OpenAI's `text-embedding-3-small` at 512 dimensions when `OPENAI_API_KEY` is set at build time, or with the same model through AI Gateway when only `AI_GATEWAY_API_KEY` is. Without either key it builds a keyword-only index and prints a warning. If a key is set but the provider cannot load, the build fails with the reason instead of quietly going keyword-only. Choose the model with `embedding`, named the way the CLI names it, so the config imports no provider: `'openai:text-embedding-3-large'`, `'cohere/embed-v4.0'` through AI Gateway, `'workers-ai:@cf/baai/bge-small-en-v1.5'` for the [Workers AI Worker](/docs/workers-ai), `'mock'` for offline builds, or `'none'` for keyword-only. `dimensions` sets the vector size for models that support it, and the endpoint reads it from the index. ```ts plugins: [['ondocs/docusaurus', { embedding: 'openai:text-embedding-3-small', dimensions: 512 }]], ``` ## Deploy the endpoint for Docusaurus A Docusaurus site is static, so the endpoint runs as a function on your host and reads the index the build wrote. Write it with one command in the site's folder: ```sh npx ondocs init ``` On Vercel it writes `api/ask.ts` and `api/mcp.ts` and bundles `build/ask-index.json` with them in `vercel.json`; on Netlify and Cloudflare, their equivalents. On GitHub Pages, where Docusaurus sites often live and nothing runs but files, it writes a Cloudflare Worker that reads the index from the live site and answers with Workers AI, with no API key, and the dialog posts to it: set `endpoint` to its URL. See [Workers AI](/docs/workers-ai). The endpoints on Vercel, Netlify and Cloudflare embed questions with OpenAI's `text-embedding-3-small`, the plugin's default; each has a rate limit and a daily budget. See [Deploying](/docs/deployment) for what it writes on each host. ## Try the Docusaurus plugin locally Build the site once, run `npx ondocs dev` in its folder, which answers from `build/ask-index.json`, and start the site with `ASK_ENDPOINT=http://localhost:8787/api/ask npm start`. Without a key, the build makes a keyword-only index unless you set `embedding: 'mock'`, and `ondocs dev` answers with the mock model either way. `docusaurus start` reminds you when the dialog posts to a path it does not serve. ## Docusaurus plugin options | Option | Default | What it does | | -------------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------- | | `endpoint` | `ASK_ENDPOINT`, else `/api/ask` | Where the dialog posts questions; `ASK_ENDPOINT` overrides it. | | `embedding` | OpenAI when a key is set | Model spec: `openai:`, `workers-ai:@cf/`, `/`, `mock`, `none`. | | `dimensions` | the model's | Vector size, for models that support it. | | `embeddingModel` | none | An AI SDK embedding model object, instead of `embedding`. | | `embeddingProviderOptions` | none | Passed to the embedding model. | | `chunking` | `{ maxChars: 1200, overlap: 150 }` | Chunk size and overlap, in characters. | | `indexFile` | `ask-index.json` | Where the index is written in `build/` and served from. | | `exclude` | `[]` | Path prefixes to leave out, relative to `baseUrl`. | | `dialog` | | `title`, `placeholder`, `suggestions`, `shortcut`, `buttonLabel`, `labels`, `locales`. | | `mcp` | none | The [MCP endpoint](/docs/mcp)'s URL or path, for ``. | | `llmsTxt` | on | Write [llms.txt, llms-full.txt and .md copies](/docs/llms-txt); `false` turns them off. | ## Locales and versions Docusaurus builds each locale on its own, and each gets its own index under its locale path: `build/fr/ask-index.json`, served at `/fr/ask-index.json`. On a locale's pages, the dialog sends the locale with each question, and an endpoint with `indexes` answers from that locale's index: `npx ondocs init` reads the locales from `i18n` and writes that. Give the dialog its words in each language with `dialog.locales`, by Docusaurus locale, over the dialog's own options: ```ts plugins: [ [ 'ondocs/docusaurus', { dialog: { suggestions: ['How do I install it?'], locales: { fr: { title: 'Demander à Acme', suggestions: ['Comment l’installer ?'], labels: { launcher: 'Demander', placeholder: 'Posez une question…', newQuestion: 'Nouvelle question' }, }, }, }, }, ], ], ``` Every label is listed in [The ask dialog](/docs/ask-dialog#labels-and-languages). Versioned docs index every version, so an answer can cite an old one; leave versions out with `exclude`, as in `exclude: ['/docs/next', '/docs/1.0']`. ## When another plugin wraps Root When two plugins wrap `Root`, Docusaurus uses only the last one's wrapper, so the dialog can go missing. Render it from your own `Root` instead; it shows once even if the plugin's `Root` renders it too: ```tsx // src/theme/Root.tsx import Ondocs from '@theme/Ondocs'; import Root from '@theme-original/Root'; import type { ReactNode } from 'react'; export default function RootWithAsk({ children }: { children: ReactNode }) { return ( {children} ); } ``` --- # FAQ Source: https://ondocs.vercel.app/docs/faq ## What is ondocs? ondocs is a self-hosted "Ask AI" box for documentation sites: a build-time index, in-memory hybrid search, and streamed answers with citations from a model you choose. It is an open-source npm package, not a hosted service. See the [introduction](/docs/introduction). ## Is it free? Yes. ondocs is free and open source under the MIT license, with no paid tier and no hosted service. You pay only your model provider for the calls your endpoint makes, under your own API key. ## How much does it cost to run? An answered question costs one embedding call and one model call, with at most 8,000 characters of sources in and 800 tokens out by default, and a follow-up that needs rewriting one more short call. A question the docs can't answer costs the embedding call only: the model is never asked to answer it. A follow-up that needs rewriting costs the short rewrite call as well. Hosting is one function and one static file. See [cost](/docs/deployment#cost). ## Do I need a vector database? No. The index is a static JSON file built with your site, and the endpoint searches it in memory: 0.71 ms per query at 1,000 chunks. A vector database only pays off past about 50,000 chunks, or for content that changes between deploys. ## Do I need an API key to try it? No. Mock mode runs the whole pipeline offline with a deterministic embedder and a model that quotes your pages: `npx ondocs index ./docs -e mock`, then `npx ondocs dev`. In production you need a key for your model provider. See [Try it locally](/docs/local-development). ## Can I use Claude or other models? Yes. Answers come from any Vercel AI SDK language model: OpenAI's, Anthropic's Claude, Google's Gemini, xAI's Grok, Mistral's, or any model on AI Gateway. Embeddings come from any AI SDK embedding model, such as OpenAI's or Google's. Anthropic and xAI have none, so pair Claude or Grok with another provider's embeddings, or build a keyword-only index. See [Model providers](/docs/model-providers). ## Can I use my ChatGPT Plus, Claude Pro or SuperGrok subscription? No. A site that answers its visitors calls a model through an API, and chat subscriptions such as ChatGPT Plus, Claude Pro or SuperGrok don't include API access: the API is billed on its own, per use, with an API key from the provider's developer platform. The free option is a model you run yourself, for example with Ollama or LM Studio, which serve an OpenAI-compatible endpoint the AI SDK can call; it needs a machine your endpoint can reach. ## Where are my docs and questions sent? Only to your own model provider. Your endpoint sends the question to the embedding model, and the question with the retrieved excerpts to the language model, under your API key. ondocs runs no service and sends no telemetry. See [Privacy](/docs/security#privacy-what-leaves-your-servers). ## Where does the index live? In `ask-index.json`, a static file next to your site: in your repository when you build it with the CLI, or in `build/` or `dist/` when a plugin builds it. The endpoint imports it, reads it from the build output, or fetches it from a URL. See [Where the index lives](/docs/deployment#where-the-index-lives). ## How large can my site be? ondocs is comfortable on sites as large as 10,000 chunks, and workable to about 50,000. At 10,000 chunks the index is 15.7 MB, loads in 339 ms and answers a query in about 7 ms. Every section of a page makes at least one chunk, of up to 1,200 characters. See [Benchmarks](/docs/benchmarks). ## What happens when the docs don't cover a question? The relevance gate finds nothing good enough and the endpoint answers "I don't know. I couldn't find anything about that on this site." without calling the model. That refusal is fast, free and deterministic. See [Saying "I don't know"](/docs/retrieval#saying-i-dont-know). ## Does it support follow-up questions? Yes. The dialog sends the thread with each question, and the endpoint rewrites a follow-up such as "and on Netlify?" into a question that stands on its own before it searches, with one short model call. A question that already stands on its own is not rewritten. "New question" in the dialog starts over. See [Follow-up questions](/docs/ask-endpoint#follow-up-questions). ## Does it work for docs that aren't in English? Yes, through the embedding model: a multilingual model such as Cohere's `embed-multilingual-v3.0` matches questions and pages across languages. The keyword side uses English stopwords, so it is weaker in other languages. The plugins build one index per locale, the dialog sends the page's locale so the endpoint answers from that locale's index, and every string the dialog shows can be given in your language with `labels`. See [Labels and languages](/docs/ask-dialog#labels-and-languages). ## How is it different from Kapa, Inkeep or Algolia Ask AI? Those are hosted services: your docs are ingested into their platform, and answers come from their infrastructure under their pricing. ondocs is a library. The index is a file in your build, the endpoint is a function on your host, and the model is one you choose under your own key. You give up their dashboards and analytics: you get `ondocs eval`, readers' ratings and a log of unanswered questions to wire as you like instead, and no account, no vendor, and nothing to keep in sync. ## How is it different from Pagefind? Pagefind is static search: it builds an index at build time and returns matching pages, with no server at all. ondocs builds a similar static index, then adds an endpoint that answers in sentences with citations. It needs one function and a model key that Pagefind doesn't. They can run side by side: Pagefind on ⌘K, ondocs on ⌘I. ## Can I host the endpoint somewhere other than my site? Yes. `npx ondocs init --host github-pages` writes a Cloudflare Worker that reads the index from the live site and answers it across origins with Workers AI, with no API key and CORS for the site's origin only; point the dialog's `endpoint`, or the script tag's `data-endpoint`, at it. See [Deploying](/docs/deployment#github-pages-and-other-static-hosts). An endpoint on Vercel or Netlify works the same way: set `access-control-allow-origin` for your site's origin in the handler's `headers`. ## Does it work with versioned docs and several languages? Yes. Docusaurus, Starlight and VitePress sites get one index per locale, and the dialog on a locale's pages is answered from it. Versioned Docusaurus docs index every version; leave old ones out with `exclude: ['/docs/1.0']`. See [Docusaurus](/docs/docusaurus#locales-and-versions). ## How is the ondocs website built? The website is a Next.js app that uses ondocs on these docs. `ondocs index` builds the index from the Markdown pages before every build, `createAskHandler` serves `/api/ask`, and `` is the dialog. In production it answers with OpenAI; its preview deployments, and the example run locally without a key, use mock mode, where answers are quotes picked from the pages rather than written by a model. The repository's Docusaurus, Starlight and VitePress examples build the same pages with their plugins. ## Was ondocs called ask-my-site? Yes. It was published as `ask-my-site` until 0.6.0, and 0.7.0 is its first release as `ondocs`. The options, the endpoints and the index file are the same; the package, the command and the import paths are renamed. [Migrating from ask-my-site](/docs/migrating) lists every rename and the commands to move a site over. --- # Getting started Source: https://ondocs.vercel.app/docs/getting-started Every setup has the same two halves: an index built with your site, and an endpoint that answers from it. The integration you pick decides how much of that is done for you. ## Pick your setup - **Docusaurus.** One line in `docusaurus.config.ts`: the plugin builds the index and adds the dialog. See [Docusaurus](/docs/docusaurus). - **Astro or Starlight.** One plugin or integration in `astro.config.mjs`, which builds the index and adds the dialog. See [Astro and Starlight](/docs/astro). - **VitePress.** Wrap the config in `.vitepress/config.mts` with `withOndocs`, which builds the index and adds the dialog. See [VitePress](/docs/vitepress). - **Next.js or another React app.** Index with the CLI, mount `createAskHandler` as a route, and render ``. See [Next.js and React](/docs/nextjs). - **Any other site.** Index the built HTML with the CLI, write the endpoint with `npx ondocs init`, and add one script tag. See [Script tag](/docs/script-tag). ## Install Install the package with the Vercel AI SDK and a model provider. The two packages the dialog is built on, `@radix-ui/react-dialog` and `cmdk`, are optional peer dependencies: leave them out if you only deploy the endpoint. ```sh npm i ondocs ai @ai-sdk/openai @radix-ui/react-dialog cmdk ``` Moving a site from `ask-my-site`, its name until 0.6.0? See [Migrating from ask-my-site](/docs/migrating). ondocs needs Node.js 22.12 or later to build the index. The endpoint itself uses Web APIs only, so it also runs on Bun, Deno and Cloudflare Workers. ## Build the index Point the CLI at your content. It reads Markdown, MDX and HTML, splits pages at every heading, embeds the chunks and writes `ask-index.json`: ```sh npx ondocs index ./docs --base-url /docs -e openai:text-embedding-3-small --dimensions 512 ``` With the Docusaurus, Astro, Starlight or VitePress plugin you skip this step: the plugin indexes the built site after every build. ## Add the endpoint The endpoint is one function. It takes a Web `Request` and returns a streaming `Response`, and it must embed questions with the same model the index was built with: ```ts import { openai } from '@ai-sdk/openai'; import { createAskHandler } from 'ondocs/server'; import index from './ask-index.json'; export const POST = createAskHandler({ index, model: openai('gpt-5.4-mini'), embeddingModel: openai.embedding('text-embedding-3-small'), // at the index's 512 dimensions }); ``` `npx ondocs init` writes this for your host, with a rate limit and a daily budget: Vercel, Netlify, Cloudflare, or a Cloudflare Worker with Workers AI and no API key for a site on GitHub Pages. An Astro site with an SSR adapter needs nothing: the integration serves it. See [Deploying](/docs/deployment). ## Add the dialog In a React app, render the dialog once near the root and import its two stylesheets: ```tsx import { AskDialog } from 'ondocs/react'; import 'ondocs/react/styles.css'; import 'ondocs/embed/launcher.css'; ; ``` `launcher` adds a floating "Ask AI" button. The dialog also opens with ⌘K on macOS and Ctrl+K elsewhere. The plugins and the script tag add the same dialog for you, opened with ⌘I or Ctrl+I so that ⌘K stays with your site's search. ## Try it without an API key Mock mode runs the whole pipeline offline: a deterministic embedding model and a scripted model that answers by quoting the best-matching sentences, with citations. Build with `-e mock` and serve the endpoint locally with `npx ondocs dev`. [Try it locally](/docs/local-development) walks through it. --- # The index file Source: https://ondocs.vercel.app/docs/index-format The index is one JSON file, `ask-index.json`, with your documents, their chunks and one vector per chunk. It is built at build time, ships with your site, and is loaded into memory by the endpoint. There is no database behind it. ## What the file holds The file records its format (`ask-my-site/index@1`, under the package's name before it became ondocs, so indexes from either name are read by both), the embedding model and vector size it was built with, the chunking options, a content hash, the documents (`id`, `url`, `title`) and the chunks. Each chunk has its heading path, its anchor, its text, a hash of the text it was embedded from, and its vector. ## Index size A site of 1,000 chunks of about 800 characters at 512 dimensions makes an index of 1.6 MB, or 0.9 MB gzipped. The size grows linearly: 15.7 MB at 10,000 chunks and 78.7 MB at 50,000. The same index with vectors written as plain JSON numbers would be 7.5 MB at 1,000 chunks. A keyword-only index has no vectors and is smaller still. ## Why vectors are int8 Each embedding is quantized to 8-bit integers. The vector is scaled so its largest component is ±127, then every component is rounded. No scale factor is stored, because cosine similarity is scale-invariant and the scale cancels out. An int8 vector takes one byte per dimension, a quarter of float32. Stored as base64, a 512-dimension vector is 684 characters, against 6,500 to 7,000 characters for the same vector written as JSON numbers the way embedding APIs return them. Quantization costs almost nothing in accuracy. Similarities stay within 0.005 of the float32 values, and the top 10 results overlap with those of exact float32 search by 99.9% at 1,000 chunks and 99.1% at 10,000. ## Why not binary quantization Binary (1-bit) quantization would shrink vectors another 8×, but on the same benchmark vectors it keeps only 27% of the exact top 10, and 66% even after rescoring a 4× shortlist, which means shipping full vectors anyway. Product quantization needs trained codebooks. Neither pays off at the sizes a static site produces. ## Diffable by design The file is written deterministically: documents are sorted, no timestamps are stored, and each chunk sits on its own line. Editing one paragraph changes one or two lines in a pull request, so the index can be committed and reviewed next to the content it describes. ## Incremental rebuilds Every chunk records the first 16 hex characters of a SHA-256 of the text it was embedded from. On a rebuild, chunks whose hash is unchanged reuse their vectors, so only edited sections are sent to the embedding model. Vectors are reused only when the embedding model and its options are the same. ## Loading the index At startup the handler parses the file, decodes the vectors into one contiguous `Int8Array` and builds the BM25 inverted index. This happens once per server instance and takes about 32 ms for 1,000 chunks, using 3.2 MB of memory. --- # Indexing content Source: https://ondocs.vercel.app/docs/indexing Indexing turns your pages into chunks, embeds each chunk and writes everything to one JSON file. The CLI does it for any folder; the Docusaurus, Astro and Starlight plugins do it after every build. ## Supported content The CLI reads `.md`, `.mdx`, `.markdown`, `.html` and `.htm` files recursively, and skips dot-folders and `node_modules`. MDX is reduced to text without being evaluated, so code fences stay as they are but components that render content from props are invisible to the index. HTML is read from each page's `
`, then its `
`s, then ``, with navigation, scripts, forms and other chrome stripped and heading `id`s kept. For content that is not on disk, such as a CMS, pass `{ id, url, title, content }` records through the `documents` field of a [config file](/docs/cli#the-config-file), or call `buildIndex` yourself. ## Frontmatter Markdown files can set `title` and `url` (or `permalink`) in YAML frontmatter. A `url` must be a path or an http(s) URL: citations link to it, so a `javascript:` or `data:` URL fails the build. Without a title, the first `# Heading` is used, then the file name. ## Excluding pages There are four ways to keep pages out of the index: - Frontmatter: `draft: true`, `ask: false` or `noindex: true` leaves a page out. - The CLI: `--ignore 'changelog/**'` skips matching files, and is repeatable. Globs support `*`, `**`, `?`, `[...]` and `{a,b}`. - The plugins: `exclude: ['/changelog']` leaves out a path and everything under it. - HTML: a page with `` is skipped, and in Starlight, anything marked `data-pagefind-ignore`. ## How files become URLs A file path becomes a URL by dropping the extension, so `guides/setup.md` is cited as `/guides/setup`. `index.md`, `_index.md` (Hugo), `index.html` and `README.md` stand for their folder. Other HTML files keep `.html` (`docs/install.html` is `/docs/install.html`) unless you pass `--clean-urls`. `--base-url /docs` adds a prefix, or a full origin. Frontmatter `url` or `permalink` always wins. Docs frameworks don't all map files to URLs the same way, so the CLI looks for a framework's config in or above the content folder and follows its rules: | Framework | Detected from | Rules | | -------------------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------- | | Docusaurus | `docusaurus.config.*` | `slug` and `id` frontmatter, number prefixes dropped (`01-intro.md` is `intro`), `_` files are partials | | Starlight | `astro.config.*` with `@astrojs/starlight` | `slug` frontmatter, slugified path segments, `_` files skipped | | Next.js-style (Fumadocs, Nextra) | `next.config.*` or `source.config.ts` | `(group)` folders left out, `page.mdx` is its folder's page | `--framework` overrides the detection. Docusaurus serves docs under `/docs` by default, so pass `--base-url /docs` there. The Docusaurus rules were checked against Docusaurus's own `getSlug`: all 92 pages of docusaurus.io's docs get the URL Docusaurus serves. ## Chunking Chunks never cross a heading, so every chunk has exactly one heading path and one anchor, and a citation links to the section, not just the page. Within a section, paragraphs are packed up to 1,200 characters (`--chunk-size`). Oversized blocks are split by line, then sentence, then word, and consecutive chunks of a section share up to 150 characters (`--chunk-overlap`). Code blocks are never split in the middle of a line. Each chunk is embedded together with its page title and heading path, as in `Deploying › Netlify`, so a short section keeps its context. Anchors are the slugs GitHub, rehype-slug and Docusaurus generate, or your own `{#id}`, or `{/* #id */}` as Docusaurus writes it in MDX. ## Incremental rebuilds Every chunk records a hash of exactly the text it was embedded from. On a rebuild, unchanged chunks reuse their previous vectors, so only edited sections are sent to the embedding model. The plugins keep the previous vectors in `node_modules/.cache/ondocs` between builds. ## Checking the index in CI To fail CI when the index is stale, run the same command with `--check`. It exits with code 1 when the committed index no longer matches the content, and lists which chunks were added, changed or removed: ```sh npx ondocs index ./docs --base-url /docs --check ``` The check re-chunks the content and compares content hashes. It never calls an embedding model and needs no API key. The plugins rebuild the index with the site, so they need no check. --- # Introduction Source: https://ondocs.vercel.app/docs/introduction ondocs is a self-hosted "Ask AI" box for documentation sites. A visitor types a question and gets a short answer written from your own pages, with numbered citations that link to the exact section each claim came from. It is an open-source npm package under the MIT license, not a hosted service. It was published as `ask-my-site` until 0.6.0: [Migrating from ask-my-site](/docs/migrating) lists what was renamed. ## How it works At build time, `ondocs index` (or the Docusaurus, Astro, Starlight or VitePress plugin) splits your pages into chunks at every heading, embeds each chunk and writes one static JSON file, `ask-index.json`. Vectors are stored as int8, so a site of 1,000 chunks makes an index of about 1.6 MB. At request time, a function you deploy loads that file into memory once and searches it for each question: BM25 keyword search and cosine similarity over the vectors, merged with reciprocal rank fusion. A relevance gate then decides whether anything found is good enough to answer from. If it is, your model streams an answer that cites its sources as `[1]`, `[2]`. If it is not, the answer is "I don't know", and the model is never called. The same index is also an [MCP server](/docs/mcp) for agents, such as Claude Code, Cursor and ChatGPT: they search and read your docs with their own model, so that traffic costs you no model call. ## What you need - Content in Markdown, MDX or HTML: a docs folder, or the HTML your static site generator builds. - One serverless function to answer questions, on Vercel, Netlify, Cloudflare or any runtime that speaks Web `Request` and `Response`. - A key for a model provider you choose: OpenAI, Anthropic, or any other provider the Vercel AI SDK supports. Trying it needs no key at all, because mock mode runs offline. ## What you don't need There is no vector database, no ingestion pipeline to keep in sync, and no account with a search or chat vendor. The index is a file that ships with your site. The only data that leaves your infrastructure is what you send to your own model provider: the question and the excerpts retrieved to answer it. ## How it compares Hosted assistants such as Kapa, Inkeep, Algolia Ask AI and Biel.ai ingest your docs into their platform and answer from there. Search-only tools such as Pagefind and Orama's local index need no server, but they return links, not answers. ondocs sits between the two: a local index like Pagefind's, plus cited answers from a model you pick, with no cloud account. The [FAQ](/docs/faq#how-is-it-different-from-kapa-inkeep-or-algolia-ask-ai) has the longer comparison. ## When it fits ondocs fits documentation sites, product sites, blogs and changelogs: content of up to tens of thousands of chunks that changes when you deploy. It ships a plugin for Docusaurus, a plugin for Starlight, an integration for Astro, a plugin for VitePress, React components for Next.js and other React apps, and a script tag for everything else, such as Hugo, Jekyll, Eleventy or MkDocs. ## When it is the wrong tool It is the wrong tool for content that changes per user or per request, for corpora much larger than 50,000 chunks, and for open-ended chat: it answers questions from your pages, and a follow-up leans on the last three questions and their answers, nothing older. If you want search results without running any server code, use Pagefind. [Limits and trade-offs](/docs/limits) has the details. --- # Limits and trade-offs Source: https://ondocs.vercel.app/docs/limits ondocs trades scale and freshness for having no infrastructure. The trade has edges, and this page lists them. ## Corpus size Search is an exact scan over every vector, which is the right choice up to tens of thousands of chunks. At 10,000 chunks a query takes about 7 ms and the index is 15.7 MB. Both grow linearly: at 50,000 chunks the index is 78.7 MB, cold load takes 1.7 s and a query about 37 ms. That is comfortable to about 10,000 chunks and workable to about 50,000. Past that, an approximate index or a vector database is the better tool. If your platform limits function bundle size, serve the index as a static file and load it with `index: () => fetch(url).then((r) => r.text())` instead of importing it. ## Freshness The index is rebuilt when you deploy. Content that changes between deploys, or differs per user, is not a fit: use a vector database that you write to at runtime. ## Language Keyword search uses an English stopword list and light plural stripping. Other languages still work through the embedding model, which handles them, but keyword matching is weaker for them. Per-language stopwords and stemming are on the roadmap. ## Follow-ups and the top sources The model answers from the top few sources only, one question at a time. A follow-up is rewritten into a standalone question first, from the last three questions and their answers, so it can lean on them; nothing older is remembered. Whether a question needs the rewrite is decided by an English check, so on a site in another language set `followUps: { always: true }`. ## Content it cannot see MDX is reduced to text without being evaluated, so components that render content from props are invisible to the index. The HTML loader is a fast tag stripper that expects a static site generator's output, not arbitrary markup. Anything that only appears after JavaScript runs in the browser is not in the built HTML, so it is not indexed. ## Rate limits per instance `memoryRateLimit` counts per server instance, so on a serverless platform with many instances it limits each noisy client, not your total spend. A `budget` caps the total, but its memory store also counts per instance: give it `upstashBudgetStore` (and the limiter `upstashRateLimit`) for one count across instances, and set your provider's spend limit as the hard cap. ## Tuning per embedding model The relevance gate's thresholds are calibrated for OpenAI's `text-embedding-3-small`, and measured for Workers AI's `@cf/baai/bge-small-en-v1.5`. Another embedding model needs its own `minSimilarity`, found by checking the `best` scores `retrieve()` reports on real questions. That is the main tuning cost. ## Prompt injection Text on your pages reaches the model as data inside tags it cannot forge, but it is still text the model reads. A page that says "ignore your rules" is read like any other. Index only content you trust. ## When to use something else - Search results without any server code: Pagefind. - Answers over content that changes per user or per request: a vector database with a write path. - A hosted dashboard and analytics, and no endpoint of your own to run: a hosted assistant such as Kapa or Inkeep. ## What is planned - **Bigger corpora:** sharded indexes loaded per section, and binary quantization with int8 rescoring. - **More framework plugins,** built on [`ondocs/integration`](/docs/writing-an-integration) as the Docusaurus, Astro, Starlight and VitePress ones are. - **Language-aware keyword search:** stopwords and stemming per language. --- # llms.txt and Markdown pages Source: https://ondocs.vercel.app/docs/llms-txt Agents read docs as Markdown. With the same pages it indexes, ondocs writes three files that agents look for: - **`llms.txt`**, as [llmstxt.org](https://llmstxt.org) describes it: the site's name, its summary, and a section of links per part of the site, each to the page's Markdown copy. It also names the [MCP server](/docs/mcp) when there is one. - **`llms-full.txt`**: every page in one file, for a model to read in one request. - **A Markdown copy of each page**, at its URL plus `.md`: `/docs/intro.md` for `/docs/intro` or `/docs/intro/`, and `/index.md` for the site's root. That is where Mintlify, GitBook, Fumadocs and the Docusaurus and Starlight llms plugins put them. Each starts with a line pointing to `llms.txt`. The pages are the ones in the index, so `exclude` leaves a page out of both. Built from the HTML the site serves, a copy keeps the page's links, emphasis, code blocks with their language, numbered and nested lists, tables and quotes. The index itself is unchanged: it holds the text it always did. This site serves its own: [/llms.txt](https://ondocs.vercel.app/llms.txt), [/llms-full.txt](https://ondocs.vercel.app/llms-full.txt), and every page plus `.md`, as [/docs/mcp.md](https://ondocs.vercel.app/docs/mcp.md). ## In Docusaurus, Astro, Starlight and VitePress The plugins write all three after the build, into the build output next to `ask-index.json`, with nothing to set: `build/` for Docusaurus, `dist/` for Astro (`dist/client/` with an adapter) and `.vitepress/dist/` for VitePress. Each locale gets its own, as the index does. `llms.txt` takes its title and summary from the site's title and its tagline (Docusaurus) or description (Starlight and VitePress), and links absolutely when the site has a URL: `url` in Docusaurus, `site` in Astro, `sitemap.hostname` in VitePress. Each page with a copy also links to it from its ``, so an agent that fetches the page finds the Markdown without guessing the URL: ```html ``` The plugins add the link to the HTML they built, after the build, with the site's base path and the page's locale; it goes away with the copies. A page whose head already links a Markdown copy keeps its own, and when another plugin writes the copies, linking them is left to it. The link is in the HTML each page is served with, which is what an agent that fetches the page reads. Docusaurus and VitePress change pages in the browser without loading them, and there the head keeps the link of the page that was loaded. Turn them off with `llmsTxt: false`, or one at a time: ```ts plugins: [['ondocs/docusaurus', { llmsTxt: { full: false, markdown: false } }]], ``` `llmsTxt` takes `index`, `full` and `markdown` (each on by default), and `title` and `description` to name the site. ## Alongside other llms plugins A site that already uses an llms plugin keeps it. The plugins never replace a file the build already has, from `static/` or `public/` or another plugin, and say so. They also leave out what a known plugin writes, by its name and options: | Plugin | Leaves to it | | --------------------------------------------------------------- | ------------------------------------------------------------------------------ | | `docusaurus-plugin-llms` | `llms.txt`, `llms-full.txt`, and the `.md` copies with `generateMarkdownFiles` | | `@signalwire/docusaurus-plugin-llms-txt` (and the unscoped one) | `llms.txt`, the `.md` copies unless turned off, and `llms-full.txt` when on | | `docusaurus-plugin-copy-page-button` | the `.md` copies, with `generateMarkdownRoutes` | | `starlight-llms-txt` | `llms.txt` and `llms-full.txt` | | `starlight-page-actions`, `starlight-llm-actions` | the `.md` copies | | `vitepress-plugin-llms` | `llms.txt`, `llms-full.txt` and the `.md` copies | ## With the CLI `--llms-txt ` writes the three files into `dir`, the folder your site serves at its root, such as `public/` in Next.js. A page's copy goes at its URL there: `/docs/intro` is `public/docs/intro.md`. Unlike the plugins, the CLI replaces what is there, since you named the folder. ```sh npx ondocs index content/docs --base-url /docs --llms-txt public \ --llms-title "Acme Docs" --llms-description "Docs for Acme." \ --site-url https://docs.acme.dev --mcp-url https://docs.acme.dev/api/mcp ``` `--no-llms-index`, `--no-llms-full` and `--no-llms-markdown` leave one out, and a config module can set the same under `llmsTxt`. From Markdown sources, a copy keeps the Markdown as written, links and images included, without MDX imports and components. This site runs it with `--no-llms-index`, because it curates its own `llms.txt` in its sidebar's order. The CLI does not change your pages, so link each one to its copy from the page itself. In Next.js, that is `alternates.types` in the page's metadata: ```ts // app/docs/[slug]/page.tsx export async function generateMetadata(props: PageProps<'/docs/[slug]'>): Promise { const { slug } = await props.params; return { alternates: { types: { 'text/markdown': `/docs/${slug}.md` } } }; } ``` ## Serving them Static hosts serve `.md` files as they are; check that yours sends them as `text/markdown` or `text/plain` rather than as a download. Besides the `` in each page, a host can send the same as a `Link` header, `Link: ; rel="alternate"; type="text/markdown"`, which the plugins do not set. --- # Try it locally Source: https://ondocs.vercel.app/docs/local-development You can try ondocs on your own content in about two minutes, without an API key. Mock mode replaces both models with offline stand-ins, and `ondocs dev` serves the endpoint on your machine. ## Run the endpoint locally To run the endpoint locally, index a folder of Markdown, MDX or HTML with the mock embedder, then start `ondocs dev`: ```sh npm i ondocs npx ondocs index ./docs -e mock # writes ask-index.json npx ondocs dev # serves POST http://localhost:8787/api/ask ``` `ondocs dev` answers from `ask-index.json`, or from `build/ask-index.json`, `dist/ask-index.json` or `.vitepress/dist/ask-index.json`, where the Docusaurus, Astro and VitePress plugins write theirs. It listens on 127.0.0.1 port 8787 (`--port`), answers pages on this machine only (`--allow-origin` adds another origin), and picks up a rebuilt index on the next question. ## Point the dialog at it The plugins post to the `ASK_ENDPOINT` environment variable whenever it is set, over any `endpoint` option, so you don't edit any config: ```sh ASK_ENDPOINT=http://localhost:8787/api/ask npm start ``` With React, render ``. With the script tag, set `data-endpoint="http://localhost:8787/api/ask"`. Then press ⌘K (⌘I with the plugins and the script tag) and ask. ## What mock mode does `mockEmbeddingModel()` hashes words and word pairs into 512-dimension vectors, so similarity tracks shared vocabulary. `mockLanguageModel()` answers extractively: it picks the sentences from the retrieved sources that best match the question and cites each one, and it never writes text of its own. Everything else is the production code path: chunking, the int8 index, hybrid retrieval, the relevance gate, streaming and citations. Both models come from `ondocs/mock`. They are for demos, tests and local development. Retrieval over a mock index is lexical, so a question has to share words with the page that answers it. ## Use a real model locally With `OPENAI_API_KEY` set, `ondocs dev` answers with OpenAI's `gpt-5.4-mini` (`--model` picks another) instead of the mock model. With only `AI_GATEWAY_API_KEY` set, it answers through AI Gateway. Questions are always embedded with the model the index was built with: an index embedded with OpenAI needs one of the two keys, and an index it cannot match is refused with a message saying what to change. The CLI and `ondocs dev` read `.env` and `.env.local` in the working directory, without overriding variables that are already set. ## The demo website The ondocs website is a Next.js app that indexes these pages with `ondocs index` at build time and answers through `createAskHandler`. In production it answers with OpenAI, under a rate limit. Its preview deployments, and the example when you run it without a key, use mock mode: `mockLanguageModel()` answers with quotes from these docs, picked by the words they share with the question, where a real model writes full sentences from the same sources. The Docusaurus, Starlight and VitePress examples in the repository answer the same way through `ondocs dev`. --- # MCP server Source: https://ondocs.vercel.app/docs/mcp The index that the Ask box answers from can also be served to agents, such as Claude Code, Cursor, VS Code, ChatGPT and Claude, as an MCP server. `createMcpHandler` from `ondocs/server` serves it with three read-only tools: `search`, `fetch` and `list_pages`. The agent brings its own model: the endpoint never calls a language model, and with no embedding model, it calls no model at all. This site serves its own docs this way, at `https://ondocs.vercel.app/api/mcp`. To try it in Claude Code: ```sh claude mcp add --transport http ondocs https://ondocs.vercel.app/api/mcp ``` Or add it to [Cursor](cursor://anysphere.cursor-deeplink/mcp/install?name=ondocs&config=eyJ1cmwiOiJodHRwczovL29uZG9jcy52ZXJjZWwuYXBwL2FwaS9tY3AifQ%3D%3D), [VS Code](vscode:mcp/install?%7B%22name%22%3A%22ondocs%22%2C%22type%22%3A%22http%22%2C%22url%22%3A%22https%3A%2F%2Fondocs.vercel.app%2Fapi%2Fmcp%22%7D) or [Claude](https://claude.ai/customize/connectors?modal=add-custom-connector&connectorName=ondocs&connectorUrl=https%3A%2F%2Fondocs.vercel.app%2Fapi%2Fmcp), or in ChatGPT, add a custom MCP server with that URL at chatgpt.com/plugins. ## Mount the endpoint It is a Web-standard `(Request) => Promise`, like the ask endpoint, and it mounts the same way. In a Next.js app, `app/api/mcp/route.ts`: ```ts // app/api/mcp/route.ts import { createMcpHandler } from 'ondocs/server'; import index from '../../../ask-index.json'; const handler = createMcpHandler({ index, siteName: 'the Acme docs' }); export const POST = handler; export const GET = handler; export const DELETE = handler; export const OPTIONS = handler; ``` Pass the same imported `index` to `createAskHandler` and `createMcpHandler`, and it is loaded into memory once for both. MCP clients send JSON-RPC with POST. The handler answers GET and DELETE with a 405, as a server without sessions does, and OPTIONS for browser clients. For a static site, `npx ondocs init` writes it next to the ask endpoint: `api/mcp.ts` on Vercel, `netlify/functions/mcp.mts` on Netlify, `functions/api/mcp.ts` on Cloudflare Pages, or `/api/mcp` in the Worker it writes for a Cloudflare Worker or a GitHub Pages site, each rate-limited on the host's client IP header. On an Astro site with an SSR adapter, the integration serves `/api/mcp` itself. See [Deploying](/docs/deployment#write-the-endpoint-with-init). ## The tools | Tool | Input | Returns | | ------------ | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `search` | `query`: keywords or a short question | Up to 8 sections, best first, each with an `id`, the page `title`, the section's `heading` path, its absolute `url` with the anchor, and `text`, a snippet of the part that matches the query. | | `fetch` | `id`: an id from `search`, or a page URL | The page as Markdown, or with an `#anchor`, that section. A page over 60,000 characters is cut, and the sections to fetch for the rest are listed. | | `list_pages` | `prefix` (optional): a path, as `/docs/guides` | Every page's `id`, `title` and `url`, or those under the prefix, up to 500. | Results come back as structured content, and as the same JSON in a text block for clients that read only text. That is the shape ChatGPT's connectors and deep research expect from `search` and `fetch`. `fetch` also takes a URL as an agent might write it: absolute, with a trailing slash, or ending in `.md`. A page or tool that does not exist is an error the agent reads, with what to try instead. ## Keyword-only or hybrid search Without an `embeddingModel`, `search` matches on keywords (BM25), and agent traffic costs you no model call of any kind. Agents write good search queries, so this works well. On docusaurus.io's own docs (92 pages, 1,070 chunks), searched as an agent searches ("autogenerated sidebar from folder", "configureWebpack"), the right page is in the top three for 22 of 22 queries, and first for 19. Asked as a person asks ("How do I add a tip or warning callout box?"), it is in the top three for 20 of 22. Those counts are checked in CI on every change. With `embeddingModel`, each search also embeds its query, one embedding call at your provider's price, and merges both rankings as the ask endpoint does. That helps queries that share no words with the page. It must be the model the index was built with. If the provider fails, that search falls back to keywords. ```ts const handler = createMcpHandler({ index, embeddingModel: openai.embedding('text-embedding-3-small'), }); ``` ## Limits - **Rate limit.** 60 tool calls a minute per client IP by default, per server instance, keyed as the ask endpoint's are (see [which client IP header to trust](/docs/security#which-client-ip-header-to-trust)). Discovery and listing the tools are not counted. Hosted clients, ChatGPT and claude.ai, call from their own servers, so their users share a few IPs; raise the limit if many people use your docs from them. - **Budget.** `budget: { requestsPerDay }` caps tool calls a day for the whole endpoint, apart from the ask endpoint's budget. There is no token budget, as nothing generates tokens. - **Size.** Bodies over 64 KiB get a 413 while they are read, and queries over 500 characters an error. ## Protocol versions The endpoint speaks MCP 2026-07-28, where every request names its version and there is no session, and, for clients that still start with `initialize`, 2025-11-25, 2025-06-18, 2025-03-26 and 2024-11-05, answered without a session. Claude Code's newer runtime asks for 2026-07-28 first, with `server/discover`. Cursor and VS Code start with `initialize`. Both ways are tested with the official MCP TypeScript client, and against the official conformance suite's scenarios that apply to a tools-only server. Browsers on other sites get no CORS headers, so they cannot read the answers. Requests are answered whatever their `Origin`, because hosted clients send one from their servers. `allowedOrigins: ['https://app.example.com']` turns on MCP's origin check: requests from any other origin get a 403, and the listed ones get CORS headers. ## Show it on your site Give the plugin the endpoint's URL with `mcp`, and a page can show how to add it to Cursor, VS Code, Claude, Claude Code and ChatGPT: - **Docusaurus:** `['ondocs/docusaurus', { mcp: '/api/mcp' }]`, then in an MDX page, `import OndocsMcp from '@theme/OndocsMcp'` and ``. - **Astro and Starlight:** `ondocs({ mcp: '/api/mcp' })`, with `site` set, then in an MDX page, `import McpInstall from 'ondocs/astro/McpInstall.astro'` and ``. - **VitePress:** `withOndocs(config, { mcp: '/api/mcp' })`, with `sitemap.hostname` set, points `llms.txt` at it; for the links on a page, use `mcpInstallLinks` below. - **React:** `` from `ondocs/react`. - **Anything else:** `mcpInstallLinks({ url, name })` from `ondocs` returns the Cursor and VS Code install links, the claude.ai link, the Claude Code command and the JSON for other clients. A path is resolved against the site's URL. The name defaults to the site title, as `acme-docs`. ## Try it locally `npx ondocs dev` serves the index at `http://localhost:8787/api/mcp` as well as the ask endpoint, keyword-only, with the same origin and host checks: ```sh npx ondocs dev claude mcp add --transport http docs http://localhost:8787/api/mcp ``` ## Handler options | Option | Default | Notes | | -------------------------- | ------------------ | --------------------------------------------------------------------------------------------------------------------------------- | | `index` | required | As for `createAskHandler`. The same imported object as the ask endpoint's is loaded once. | | `embeddingModel` | none | Embeds each search query. Without it, search is keyword-only and no model is called. | | `embeddingProviderOptions` | none | As for `createAskHandler`. | | `siteName` | `"this site"` | Names the docs in the tools' descriptions, which the agent reads. | | `siteUrl` | the request's | The origin to make URLs absolute with, when the endpoint runs on another host. `false` keeps the index's URLs. | | `name`, `instructions` | `ondocs` | The server's name, and what it tells the agent about using the tools. | | `retrieval` | keyword gate `0.3` | Retrieval settings for `search`; it keeps sections with at least 30% of the query's keyword weight, looser than the ask endpoint. | | `rateLimit` | 60 a minute per IP | Any limiter, or `false`. `rateLimitFailure` works as for `createAskHandler`. | | `budget` | none | `{ requestsPerDay, store }`: tool calls a day. | | `allowedOrigins` | none | A list of origins, or `'*'`, to check `Origin` and send CORS headers. | | `maxQueryLength` | `500` | Longest search query, in characters. | | `maxBodyBytes` | 64 KiB | Counted while reading. | | `onToolCall` | none | `{ tool, arguments, isError, results, retrieval, durationMs }` after each call, to count agent traffic. | | `onError` | `console.error` | Handled errors: embedding fallbacks, a failing limiter, a misconfigured index. | --- # Migrating from ask-my-site Source: https://ondocs.vercel.app/docs/migrating `ask-my-site` is now `ondocs`. Version 0.7.0 is the first release under the new name; `ask-my-site` stays at 0.6.0 and gets no new releases. The options, the endpoints and their paths, `ask-index.json` and its format are the same, and the rename changes nothing in what the dialog and the endpoint send each other. The old names of the Docusaurus theme components, the script tag's global and the types still work, with a notice where there is one to give, until you rename them. ## What changed | What | Before | Now | | --------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------------ | | npm package | `ask-my-site` | `ondocs` | | Command | `npx ask-my-site index`, `init`, `dev`, `eval` | `npx ondocs index`, `init`, `dev`, `eval` | | Imports | `ask-my-site/server`, `/react`, `/docusaurus`, `/starlight`, … | `ondocs/server`, `/react`, `/docusaurus`, `/starlight`, … | | Stylesheets | `ask-my-site/react/styles.css`, `ask-my-site/embed/launcher.css` | `ondocs/react/styles.css`, `ondocs/embed/launcher.css` | | Script tag | `https://cdn.jsdelivr.net/npm/ask-my-site@0.6/dist/embed.global.js` | `https://cdn.jsdelivr.net/npm/ondocs@0.8/dist/embed.global.js` | | Script tag's global | `window.AskMySite.mount()` | `window.Ondocs.mount()` (the old name still works) | | Docusaurus theme components | `@theme/AskMySite`, `@theme/AskMySiteMcp` | `@theme/Ondocs`, `@theme/OndocsMcp` (the old names still work) | | Docusaurus plugin's name | `ask-my-site` | `ondocs`, as in `useAllPluginInstancesData('ondocs')` | | Astro integration's name | `ask-my-site` | `ondocs`, which Astro's log lines start with | | Types | `AskMySiteOptions`, `AskMySiteDialogOptions`, `AskMySiteTheme`, … | `OndocsOptions`, `OndocsDialogOptions`, `OndocsTheme`, … (old ones work) | | The button's class | `.ask-my-site-launcher` | `.ondocs-launcher` | | The script tag's container | `
` | `
` | | Build cache | `node_modules/.cache/ask-my-site` | `node_modules/.cache/ondocs` | | Log lines and errors | `[ask-my-site]`, `ask-my-site:` | `[ondocs]`, `ondocs:` | | MCP server's name in `initialize` | `ask-my-site` | `ondocs` (the `name` option still sets your own) | | Plugins under their own names | none | `docusaurus-plugin-ondocs` and `starlight-ondocs`, new | | Repository | `github.com/dgesteves/ask-my-site` | `github.com/dgesteves/ondocs` (old links redirect) | ## What stays the same - `ask-index.json`, and the format it records, `ask-my-site/index@1`: an index built by either package is read by both. - `createAskHandler`, `createMcpHandler`, `AskDialog`, `useAsk`, `mountAskDialog` and every other export, with the same options. - `/api/ask`, `/api/mcp` and `ASK_ENDPOINT`. - The dialog's `ask-*` classes and `--ask-*` CSS variables, including `--ask-launcher-*`, and the script tag's `data-*` attributes. - The keys `budget` and `answerCache` write to a shared store, which start `ask-my-site:budget:`, `ask-my-site:answer:` and `ask-my-site:mcp-budget:`, so an Upstash store keeps the day's counts and its answers across the upgrade. ## Move a site over Swap the package: ```sh npm uninstall ask-my-site npm install ondocs ``` With pnpm, Yarn or Bun, use `pnpm remove` and `pnpm add`, `yarn remove` and `yarn add`, or `bun remove` and `bun add`. If the site installed the plugin's peers for the dialog (`ai`, `@ai-sdk/openai`, `@radix-ui/react-dialog`, `cmdk`), they stay as they are. Then rename the imports, the commands in scripts and CI, the script tag's URL, the theme components, the types and the button's class in one go. Run this from the repository's root, after the install above, so `package.json` already names `ondocs`: ```sh git ls-files -z -- '*.js' '*.jsx' '*.mjs' '*.cjs' '*.ts' '*.tsx' '*.mts' '*.md' '*.mdx' \ '*.astro' '*.html' '*.css' '*.yml' '*.yaml' 'package.json' \ | xargs -0 perl -pi -e 's{(["\x27`])ask-my-site(?=[/"\x27`])}{$1ondocs}g; s{\bask-my-site (?=index|init|dev|eval)}{ondocs }g; s{(cdn\.jsdelivr\.net/npm|unpkg\.com)/ask-my-site(?:\@[\d.]+)?}{$1/ondocs\@0.7}g; s{ask-my-site-launcher}{ondocs-launcher}g; s{\.ask-my-site(?![\w-])}{.ondocs}g; s{\bAskMySite}{Ondocs}g; s{\baskMySite\b}{ondocs}g' ``` It leaves links to `github.com/dgesteves/ask-my-site` and the demo site alone, since they still work. Check the result with `git diff` before you commit it. A script tag pinned to an exact version, such as `ask-my-site@0.6.0`, comes out as `ondocs@0.7`: pin it again, such as `ondocs@0.7.0`. ### Docusaurus The plugin is `ondocs/docusaurus`, or `docusaurus-plugin-ondocs` under its own package: ```ts // docusaurus.config.ts export default { plugins: ['ondocs/docusaurus'] }; ``` - **A swizzled `Root`** that renders `` from `@theme/AskMySite` keeps working, and says once, in the build's output and in the browser's console with `docusaurus start`, to import `Ondocs` from `@theme/Ondocs` instead. The command above does that. - **A swizzled copy of the dialog,** `src/theme/AskMySite.tsx` (or `.js`, or a folder), is no longer rendered, since the plugin's `Root` renders `@theme/Ondocs`; the plugin warns when it finds one. Rename it, and any `@theme-original/AskMySite` import in it, which the command above changes to `@theme-original/Ondocs`: ```sh git mv src/theme/AskMySite.tsx src/theme/Ondocs.tsx ``` - **An MDX page** that imports `@theme/AskMySiteMcp` keeps working; the command above changes it to `OndocsMcp` from `@theme/OndocsMcp`. ### Astro and Starlight Import the plugin or the integration from `ondocs/starlight` or `ondocs/astro`, or `starlight-ondocs` under its own package. The default export's name is yours to choose; these docs call it `ondocs`: ```js // astro.config.mjs import starlight from '@astrojs/starlight'; import ondocs from 'ondocs/starlight'; import { defineConfig } from 'astro/config'; export default defineConfig({ integrations: [starlight({ title: 'Acme Docs', plugins: [ondocs()] })], }); ``` The MCP block for a page is `ondocs/astro/McpInstall.astro`. ### Script tag Point the tag at the new package: ```html ``` The old URL keeps serving `ask-my-site` 0.6, so nothing breaks until you change it. A page that mounts the dialog by hand with `window.AskMySite.mount()` keeps working on the new script, which says once in the browser console to use `window.Ondocs.mount()`. Rename `.ask-my-site-launcher` to `.ondocs-launcher` in your CSS, and `.ask-my-site` to `.ondocs` if you style the script tag's container. If you host the script yourself, copy both `embed.global.js` and `embed-dialog.global.js` from the new package. ### Endpoints Import `createAskHandler` and `createMcpHandler` from `ondocs/server`; their options are the same. An MCP client lists the server under the key in its own config, so nothing changes for people who already added it, but `initialize` now names the server `ondocs`: pass `name` to `createMcpHandler` to keep the old one. ## Clean up The first build after the upgrade reuses the vectors in `node_modules/.cache/ask-my-site`, so it embeds only what changed, and from then on uses `node_modules/.cache/ondocs`. After that build, the old folder can be deleted: ```sh rm -rf node_modules/.cache/ask-my-site ``` --- # Model providers Source: https://ondocs.vercel.app/docs/model-providers ondocs uses the Vercel AI SDK for every model call, so it works with OpenAI, Anthropic, Google and xAI, and with any other provider the AI SDK supports. You bring your own API key: requests go from your function straight to your provider, and ondocs never sees or proxies them. ## Two models, two jobs ondocs uses two models. The embedding model turns chunks into vectors at build time and each question into a vector at request time; it must be the same model in both places. The language model writes the answer from the retrieved sources; you can change it at any time without rebuilding the index. The index records which embedding model built it, and the handler checks it: a handler that embeds questions with another model fails with an error that names both, instead of returning poor answers. ## OpenAI OpenAI is the default. Install `@ai-sdk/openai`, set `OPENAI_API_KEY` at build time and in your deployment, and pass both models to the handler: ```ts import { openai } from '@ai-sdk/openai'; import { createAskHandler } from 'ondocs/server'; import index from './ask-index.json'; export const POST = createAskHandler({ index, model: openai('gpt-5.4-mini'), embeddingModel: openai.embedding('text-embedding-3-small'), embeddingProviderOptions: { openai: { dimensions: 512 } }, }); ``` At 512 dimensions each vector is 512 bytes, stored as 684 characters of base64: a third of what the model's default 1,536 dimensions take. The CLI builds the matching index with `-e openai:text-embedding-3-small --dimensions 512`. ## Anthropic and Claude To answer with Claude, install `@ai-sdk/anthropic`, set `ANTHROPIC_API_KEY`, and pass an Anthropic model as `model`. Anthropic does not offer an embedding model, so keep embeddings on another provider, such as OpenAI: ```ts import { anthropic } from '@ai-sdk/anthropic'; import { openai } from '@ai-sdk/openai'; import { createAskHandler } from 'ondocs/server'; import index from './ask-index.json'; export const POST = createAskHandler({ index, model: anthropic('claude-haiku-4-5'), embeddingModel: openai.embedding('text-embedding-3-small'), embeddingProviderOptions: { openai: { dimensions: 512 } }, }); ``` Or build a keyword-only index with `-e none` and leave `embeddingModel` out, so the only key you need is Anthropic's. ## Google Gemini Google offers both a chat model and an embedding model, so one `GOOGLE_GENERATIVE_AI_API_KEY` covers answers and vectors. Install `@ai-sdk/google`, and build the index from a [config file](/docs/cli#the-config-file), since the CLI's `-e` specs name OpenAI and AI Gateway models: ```js // ondocs.config.mjs import { google } from '@ai-sdk/google'; /** @type {import('ondocs/node').AskConfig} */ export default { embeddingModel: google.embedding('gemini-embedding-001'), embeddingProviderOptions: { google: { outputDimensionality: 768 } }, }; ``` Then give the handler the same embedding model and options, with a Gemini model for answers: ```ts import { google } from '@ai-sdk/google'; import { createAskHandler } from 'ondocs/server'; import index from './ask-index.json'; export const POST = createAskHandler({ index, model: google('gemini-3.5-flash'), embeddingModel: google.embedding('gemini-embedding-001'), embeddingProviderOptions: { google: { outputDimensionality: 768 } }, }); ``` `outputDimensionality` shrinks Gemini's vectors, as `dimensions` does OpenAI's; leave it out to keep the model's full size. The relevance gate's similarity threshold is calibrated for OpenAI, so [check it](#tuning-for-another-embedding-model) against Gemini's scores. ## xAI and Grok xAI's Grok writes answers, but xAI has no embedding model in the AI SDK. Install `@ai-sdk/xai`, set `XAI_API_KEY`, and pair Grok with another provider's embeddings, such as OpenAI's: ```ts import { openai } from '@ai-sdk/openai'; import { xai } from '@ai-sdk/xai'; import { createAskHandler } from 'ondocs/server'; import index from './ask-index.json'; export const POST = createAskHandler({ index, model: xai('grok-4.20-non-reasoning'), embeddingModel: openai.embedding('text-embedding-3-small'), embeddingProviderOptions: { openai: { dimensions: 512 } }, }); ``` Or build a keyword-only index with `-e none` and leave `embeddingModel` out, so the only key you need is xAI's. Through AI Gateway the model is a string, under the name AI Gateway lists xAI's models with: `model: 'spacexai/grok-4.20-non-reasoning'`. ## Workers AI Cloudflare's Workers AI runs in a Cloudflare Worker through an `AI` binding, with no API key, and the `workers-ai-provider` package makes its models AI SDK models. The [Workers AI guide](/docs/workers-ai) has a Worker that answers any static site with it, from the template or from `npx ondocs init --host github-pages`: ```ts import { createWorkersAI } from 'workers-ai-provider'; const workersai = createWorkersAI({ binding: env.AI }); createAskHandler({ index, model: workersai('@cf/meta/llama-4-scout-17b-16e-instruct'), embeddingModel: workersai.textEmbedding('@cf/baai/bge-small-en-v1.5'), }); ``` Build the index with the same embedding model, outside Cloudflare, over its REST API: `-e workers-ai:@cf/baai/bge-small-en-v1.5` for the CLI, or `embedding: 'workers-ai:@cf/baai/bge-small-en-v1.5'` in the plugins, with `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN` (a token with Workers AI Read and Edit permissions) set as the index builds. ## AI Gateway With Vercel's AI Gateway, one `AI_GATEWAY_API_KEY` reaches every provider, and models are plain strings such as `model: 'openai/gpt-5.4-mini'` and `embeddingModel: 'openai/text-embedding-3-small'`. The CLI and the plugins take gateway ids as embedding specs, as in `-e cohere/embed-v4.0`, and use AI Gateway on their own when only `AI_GATEWAY_API_KEY` is set. ## Any other AI SDK provider `model` takes any AI SDK language model, so Mistral, Groq, Amazon Bedrock, a local model through an OpenAI-compatible endpoint (Ollama or LM Studio, with `@ai-sdk/openai-compatible`), or any other provider package works the same way. Answers are streamed with `streamText`, and `generation` passes settings such as `temperature`, `maxOutputTokens` or `providerOptions` through to it. ## Configure the embedding model The CLI and the plugins name the embedding model with a spec string, so a config file needs no provider import: | Spec | Model | Needs | | ----------------------- | ---------------------------------------------------- | -------------------- | | `openai:` | An OpenAI embedding model, through `@ai-sdk/openai` | `OPENAI_API_KEY` | | `/` | Any model on AI Gateway, such as `cohere/embed-v4.0` | `AI_GATEWAY_API_KEY` | | `mock` or `mock:` | The offline mock embedder | nothing | | `none` | No vectors: a keyword-only index | nothing | `--dimensions 512` (or `dimensions: 512` in a plugin, its default) shrinks vectors for models that support it. For OpenAI's `text-embedding-3` models the handler reads the size from the index; for another provider's size option, pass the same value in `embeddingProviderOptions`. For a provider package the specs don't cover, export the model from a [config file](/docs/cli#the-config-file): ```js // ondocs.config.mjs import { cohere } from '@ai-sdk/cohere'; /** @type {import('ondocs/node').AskConfig} */ export default { embeddingModel: cohere.embedding('embed-multilingual-v3.0'), }; ``` The handler then uses `cohere.embedding('embed-multilingual-v3.0')` as its `embeddingModel`. Changing the embedding model re-embeds every chunk on the next build. ## Keyword-only, without embeddings An index built with `-e none` has no vectors, and retrieval is BM25 alone. It costs nothing to build and answers questions that use the docs' own words well, but it misses paraphrases. Its relevance gate relies on keyword coverage alone, so lower `retrieval.minKeywordCoverage` if it refuses reasonable questions. ## Tuning for another embedding model The relevance gate's similarity threshold, 0.25, is calibrated for OpenAI's `text-embedding-3-small`, and an index embedded with Workers AI's `@cf/baai/bge-small-en-v1.5` gets the 0.65 measured for it. Other embedding models produce other similarity ranges, so check the `best` scores that `retrieve()` reports on real questions and pass `retrieval: { minSimilarity }` to the handler. [How retrieval works](/docs/retrieval#tuning) explains the gate. --- # Next.js and React Source: https://ondocs.vercel.app/docs/nextjs In a Next.js app, or any React app with a server, ondocs is three pieces: an index built by the CLI, a route handler made with `createAskHandler`, and the `` component. The ondocs website is built exactly this way. ## Install for Next.js ```sh npm i ondocs ai @ai-sdk/openai @radix-ui/react-dialog cmdk ``` `@radix-ui/react-dialog` and `cmdk` work with React 18.3 and 19. ## Index your content Build the index from the folder that holds your pages, and commit `ask-index.json`. Running it before every build keeps it current, and only changed chunks are embedded again: ```json { "scripts": { "index": "ondocs index content --base-url /docs -e openai:text-embedding-3-small --dimensions 512", "prebuild": "npm run index" } } ``` `--base-url /docs` makes `content/setup.md` cite `/docs/setup`. The CLI detects Next.js-style content (Fumadocs, Nextra) and leaves `(group)` folders out of URLs. ## Mount the route handler To add the endpoint to Next.js, export the handler as `POST` from a route handler. It is a plain `(request: Request) => Promise` function: ```ts // app/api/ask/route.ts import { openai } from '@ai-sdk/openai'; import { createAskHandler, memoryRateLimit } from 'ondocs/server'; import index from '../../../ask-index.json'; export const POST = createAskHandler({ index, model: openai('gpt-5.4-mini'), embeddingModel: openai.embedding('text-embedding-3-small'), siteName: 'Acme Docs', rateLimit: memoryRateLimit({ limit: 10, windowMs: 60_000 }), }); ``` Importing the JSON bundles the index with the function, and it is loaded into memory once per server instance. Set `OPENAI_API_KEY` in your deployment's environment. ## Render the dialog Render the dialog once, near the root, from a client component. `onNavigate` sends citations through the Next.js router instead of a full page load: ```tsx // app/ask.tsx 'use client'; import { AskDialog } from 'ondocs/react'; import 'ondocs/react/styles.css'; import 'ondocs/embed/launcher.css'; import { useRouter } from 'next/navigation'; export function Ask() { const router = useRouter(); return ( { event.preventDefault(); router.push(url); }} /> ); } ``` Then render `` in `app/layout.tsx`. `launcher` shows a floating "Ask AI" button; the dialog also opens with ⌘K or Ctrl+K. ## Open it from your own button Leave `launcher` out and pass `trigger` to open the dialog from an element of your own, such as a search box in your header: `Ask the docs} />`. Change the shortcut with `shortcut="j"`, or turn it off with `shortcut={false}`. ## Other React frameworks and runtimes `createAskHandler` returns a Web-standard handler, so it mounts anywhere that speaks `Request` and `Response`: `app.post('/api/ask', (c) => handler(c.req.raw))` in Hono, `Bun.serve({ fetch: handler })`, or `export default { fetch: handler }` in a Cloudflare Worker. React Router, Remix, SvelteKit and Astro endpoints also receive a `Request`, which you pass to the handler as it is. The dialog only needs React and the endpoint's URL. ## Build your own interface `useAsk()` from `ondocs/react` is the hook behind the dialog. It returns `ask`, `stop` and `reset`, plus the streaming `answer`, its `sources` and a `status`, so you can render answers inline on a page instead of in a dialog. `AskAnswer` renders an answer with clickable citations. See [The ask dialog](/docs/ask-dialog#the-useask-hook). ## Keep the index fresh in CI Add the check to CI so a pull request that changes content without rebuilding the index fails: ```sh npx ondocs index content --base-url /docs --check # exit 1 if stale ``` The check never calls a model and needs no API key. --- # Measure and improve answers Source: https://ondocs.vercel.app/docs/quality Three tools tell you how well your docs answer: `ondocs eval`, which scores retrieval against questions whose answers you know, in CI; readers' thumbs up and down in the dialog; and a log of the questions the docs did not answer, the content gaps to write next. None of them sends anything anywhere unless you wire it to. ## Score retrieval with eval Write down questions your readers ask, with the pages that answer them, and a few your docs should not answer: ```yaml # ask-eval.yaml thresholds: hit@3: 0.8 refusalRecall: 1 questions: - question: How do I deploy to GitHub Pages? expect: /docs/deployment/github-pages - question: How do I add a tip or warning callout? expect: [/docs/markdown-features/admonitions, /docs/markdown-features] - question: Why is each vector stored as int8? expect: /docs/index-format#vectors - question: What will the weather be in Lisbon tomorrow? unanswerable: true ``` Then run it against the index your build wrote: ```sh npx ondocs eval ask-eval.yaml --index build/ask-index.json ``` For each question it prints where the first expected page came among the pages found, or that the gate refused it, with the best keyword coverage and similarity it saw. Then come the scores: - **hit@1** and **hit@3**: the share of answerable questions whose expected page came first, or in the top three. An `expect` with a `#anchor` must match that section. - **Refusal precision**: of the questions the gate refused, the share that should have been refused. Under 1, the gate turns away questions your docs answer. - **Refusal recall**: of the questions marked `unanswerable`, the share it refused. Under 1, off-topic questions reach the model. `eval` exits 1 when a score is under its threshold, so it fails CI, and `--json` prints the report for other tools. Thresholds in the file can be overridden with `--min-hit-at-1`, `--min-hit-at-3`, `--min-refusal-precision` and `--min-refusal-recall`, and `retrieval` in the file takes the handler's retrieval settings, so the eval scores what the endpoint runs. It runs what the endpoint runs before the model, embedding each question as the index was, so it calls no language model. With an embedded index it makes one embedding call per question, with the key the index's model needs; `--keyword-only` makes none. It measures retrieval and the relevance gate, not the wording of the answers. This repository runs two eval files in CI: one of this site's own questions against the website's index, and one of the 22 questions and 6 off-topic ones the 2026-10-10 audit asked of docusaurus.io's docs, where keyword search scores hit@1 14 of 22, hit@3 18 of 22, and refuses all 6 off-topic questions and 2 it should answer. Copy `test/eval/docusaurus.io.yaml` from the repository as a start. When you change `minSimilarity` or `minKeywordCoverage`, the eval shows what moved. ## Feedback from readers Give the handler an `onFeedback`, and the dialog asks "Was this helpful?" under each answer, with thumbs up and down, and then offers a comment: ```ts export const POST = createAskHandler({ index, model, onFeedback: ({ rating, comment, id, question }) => { console.log(JSON.stringify({ feedback: rating, comment, id, question })); }, }); ``` The dialog posts the rating to the same endpoint, with the question, the answer and its sources as the reader saw them, and a comment as a second call with the same `id`. That `id` is the answer's, the one `onFinish` gets, so you can join a rating to everything else about the answer. Without `onFeedback` the dialog shows no thumbs, and a posted rating gets a 400. A rating counts against the rate limit like a question, and costs no model call. ## Questions your docs do not answer `onFinish` runs after every answer. Two of its fields mark the questions worth reading: - **`refused`**: nothing in the docs cleared the relevance gate, so the reader got "I don't know" and the model was not called. - **`lowConfidence`**: the model answered but cited none of its sources, which usually means it said the sources do not cover the question. Together they are the content gaps: the questions to write pages for, or the words your pages should use. Log them wherever suits you. To a file, on a server you run: ```ts import { appendFile } from 'node:fs/promises'; onFinish: async ({ question, refused, lowConfidence, followUp, retrieval }) => { if (!refused && !lowConfidence) return; const line = { at: new Date().toISOString(), question, asked: followUp?.question, refused, best: retrieval.best }; await appendFile('unanswered.jsonl', `${JSON.stringify(line)}\n`); }, ``` To a KV store, in a Cloudflare Worker with a KV namespace bound as `GAPS`: ```ts onFinish: async ({ id, question, refused, lowConfidence }) => { if (refused || lowConfidence) { await env.GAPS.put(`${new Date().toISOString().slice(0, 10)}:${id}`, question, { expirationTtl: 90 * 86_400 }); } }, ``` Or to a webhook, such as a Slack channel's: ```ts onFinish: async ({ question, refused, lowConfidence }) => { if (refused || lowConfidence) { await fetch(process.env.GAPS_WEBHOOK_URL!, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text: `Unanswered: ${question}` }), }); } }, ``` `onFinish` runs after the answer has streamed, so a slow log never delays the reader, and if it throws the error goes to `onError`. Then read the log now and then: `jq -r .question unanswered.jsonl | sort | uniq -c | sort -rn` lists the questions asked most. The ones that should be answerable, but are not, go in your eval file next. ## What leaves your site Nothing, unless you send it. The questions and ratings reach your endpoint, and go no further than the code you write in `onFinish` and `onFeedback`; ondocs sends no telemetry and keeps no store. A question is what a reader typed, so it can hold personal details: log only what you need, with no IP address, and keep it only as long as you use it. See [Rate limits and security](/docs/security#privacy-what-leaves-your-servers). --- # How retrieval works Source: https://ondocs.vercel.app/docs/retrieval Hybrid search combines keyword search and vector search over the same chunks, then decides whether anything it found is relevant enough to answer from. It all runs in memory, in the same function that streams the answer. ## Two retrievers BM25 ranks chunks by the question's keywords, weighting rare terms higher. It is strong on exact names such as function names, flags and error messages. camelCase identifiers are indexed whole and split, so `createAskHandler` also matches "create ask handler". Cosine similarity ranks chunks by the meaning of the question, using the embedding model the index was built with. It is strong on paraphrases, where the question and the page use different words. The scan is exact: every int8 vector is compared, with no approximate index to build at cold start. ## Reciprocal rank fusion The two rankings are merged with reciprocal rank fusion (RRF). Each chunk scores the sum of 1 / (60 + rank) over the lists it appears in. Fusion uses ranks only, so BM25 scores and similarities never need to be put on one scale, and a chunk that both retrievers rank well beats one that only one of them ranks first. ## Saying "I don't know" Before fusion, every candidate must clear a relevance gate: a cosine similarity of at least 0.25, or keyword coverage of at least 0.5. Keyword coverage is the share of the question's IDF weight that a chunk contains, so it is between 0 and 1 and comparable across questions of any length. When no chunk clears the gate, the handler answers "I don't know" immediately and never calls the language model. That makes refusals fast, free and deterministic. The default message is "I don't know. I couldn't find anything about that on this site.", with your `siteName`, and `noAnswerMessage` replaces it. The model's instructions add a second layer: answer only from the numbered sources, cite each claim, and say so when the sources are not enough. ## From chunks to sources The top 6 chunks are numbered as sources, and chunks from the same section share one number, so an answer never cites two numbers that open the same place. Sources are added in rank order until 8,000 characters (`maxContextChars`), and sent to the model with the question. The stream sends the sources before the first word of the answer, so citations are clickable as soon as they appear. ## Keyword fallback If the embedding provider fails while embedding a question, that request falls back to keyword retrieval instead of failing, and the error goes to `onError`. An index built without embeddings (`-e none`) always retrieves by keywords alone. ## Tuning The defaults are `{ topK: 6, candidates: 40, rrfK: 60, minSimilarity: 0.25, minKeywordCoverage: 0.5 }`, passed to the handler as `retrieval`. The similarity threshold is calibrated for OpenAI's `text-embedding-3-small`. An index embedded with Workers AI's `@cf/baai/bge-small-en-v1.5` gets 0.65 instead, measured on docusaurus.io's docs (see [Workers AI](/docs/workers-ai#keywords-or-meaning-too)). Other embedding models produce other ranges, so check the `best` scores that `retrieve()` returns on real questions and set `minSimilarity` to match. The keyword side is conservative: IDF comes from your pages, so a question word that appears on none of them carries the most weight. A question that adds one such word, like a product or company name, can fall below 0.5 on keywords alone even when the rest of it matches a page exactly. With embeddings, the similarity side catches such questions; for a keyword-only index, lower `minKeywordCoverage`. ## Rerank, or retrieve your own way Two hooks in `retrieval` change how the handler finds its sources, and the relevance gate still decides last. `rerank` puts the candidates in a new order, best first, before the top `topK` are kept. It gets the question and up to `candidates` hits (default 40), each with its chunk and its scores. With the AI SDK's `rerank()` and a reranking model: ```ts import { cohere } from '@ai-sdk/cohere'; import { rerank } from 'ai'; export const POST = createAskHandler({ index, model, embeddingModel, retrieval: { rerank: async (question, hits, { signal }) => { const { ranking } = await rerank({ model: cohere.reranking('rerank-v3.5'), query: question, documents: hits.map((hit) => hit.chunk.text), topN: 6, abortSignal: signal, }); return ranking.flatMap(({ originalIndex }) => hits[originalIndex] ?? []); }, }, }); ``` That is one call to the reranking model per question, after the cache: a question answered from `answerCache` reranks nothing. A reranker can also be plain code, such as boosting a section: `rerank: (question, hits) => [...hits].sort((a, b) => boost(b) - boost(a))`, with `boost` reading `hit.chunk.url`. `retrieve` finds the candidates instead of the built-in retrieval. It gets the question's text and vector, and `search`, the built-in retrieval, to call with options of its own: `retrieve: (query, search) => search({ topK: 20 }).filter((hit) => hit.chunk.url.startsWith('/docs/v2/'))` answers from one version of the docs. It returns hits on the index's chunks, which `context.index` holds, and runs before `rerank` when both are set. Whatever the hooks return, each hit's chunk is measured again against the question, and a chunk that clears neither the similarity nor the keyword threshold is dropped, as are chunks not in the index: a hook can reorder and filter the sources, but not make an off-topic page count. With nothing left, the answer is "I don't know", with no model call. A hook that throws is reported to `onError`, and the question is answered without it. `gateHits` from `ondocs` applies the same gate to hits from anywhere, for a handler of your own. ## Retrieval in mock mode The mock embedder's similarity comes from shared words, so a mock index uses `minSimilarity: 0.2` (`MOCK_MIN_SIMILARITY`) and counts similarity only from chunks that share a word with the question. Without that rule, a short question could match a short chunk through a hash collision. The `similarityNeedsKeyword` option turns the rule on or off; it is off for real embeddings, which match meaning without shared words. --- # Script tag Source: https://ondocs.vercel.app/docs/script-tag For a site that does not build with React, such as Hugo, Jekyll, Eleventy, MkDocs or plain HTML, one script tag adds the dialog and its "Ask AI" button. ## Add the script tag Add this tag to your base template, before `` or in ``: ```html ``` The script mounts itself once the page has loaded. It opens the dialog from a floating "Ask AI" button and with ⌘I or Ctrl+I, so ⌘K stays with your site's search. Pin an exact version, such as `ondocs@0.7.0`, in production. ## Script size The embed script, `embed.global.js`, is 2.7 KB gzipped: the button, the shortcut and their styles. The dialog, with React and its styles, is a second file next to it, `embed-dialog.global.js` (94 KB gzipped), which the script loads the first time the dialog is wanted: a pointer over or focus on the button, the shortcut, or `open()`. A shortcut pressed while it loads still opens the dialog once it arrives. The page needs nothing else, and `defer` loads the script without blocking the page. If you host the script yourself, copy both files to the same folder, or point at the dialog's file with `data-dialog-src`. ## Index a static site The script only adds the dialog; the answers come from an endpoint that reads your index. Build the index with the CLI, either from your Markdown or from the HTML your generator built, which has the URLs it actually serves: ```sh hugo && npx ondocs index public -e openai:text-embedding-3-small ``` The output folder is `public` after `hugo`, `_site` after Jekyll or Eleventy, and `site` after `mkdocs build`. HTML is read from each page's `
`, and citations link to the headings' own `id`s. `index.html` stands for its folder, and other HTML files keep `.html` in their URLs unless you pass `--clean-urls`, for hosts that serve `/guide.html` at `/guide`. ## Deploy the endpoint for a static site Write the endpoint with `npx ondocs init` in the site's folder: a Vercel, Netlify or Cloudflare function that reads the index next to the site's files, or, for GitHub Pages, S3 or any host that only serves files, a Cloudflare Worker of its own that reads the index from the live site, answers it across origins and needs no API key: see [Workers AI](/docs/workers-ai). Then set `data-endpoint` to the Worker's URL plus `/api/ask`. On Vercel, Netlify and Cloudflare, the functions import `ondocs`, `ai` and `@ai-sdk/openai`, and init prints the command that installs them, with `npm init -y` first for a site that has no `package.json`. See [Deploying](/docs/deployment). To try it first, run `npx ondocs dev` beside the index and set `data-endpoint="http://localhost:8787/api/ask"`. ## Script tag attributes | Attribute | Default | What it does | | ------------------------- | ------------------ | ------------------------------------------------------------------------------------- | | `data-endpoint` | `/api/ask` | Where the dialog posts questions. | | `data-title` | "Ask this site" | The dialog's accessible name. | | `data-placeholder` | "Ask a question…" | The input's placeholder. | | `data-suggestions` | none | A JSON array, as in `data-suggestions='["How do I install it?"]'`. | | `data-shortcut` | `i` | The key used with ⌘ or Ctrl; `"false"` turns it off. | | `data-button-label` | "Ask AI" | The floating button's label; `"false"` hides the button. | | `data-theme` | `auto` | `auto` follows `data-theme` on ``, then the system; or `light` or `dark`. | | `data-links` | `all` | `sources` keeps only the answer's links to its source pages. | | `data-labels` | English | The dialog's words, as a JSON object, as in `data-labels='{"launcher":"Demander"}'`. | | `data-label-` | English | One label, as in `data-label-new-question="Nouvelle question"`; over `data-labels`. | | `data-locale` | none | The page's locale, sent with each question, for an endpoint with an index per locale. | | `data-turnstile-site-key` | none | A Turnstile site key: solve a [bot check](/docs/bot-challenge) before each question. | | `data-manual` | off | Don't mount; wait for `window.Ondocs.mount()`. | | `data-dialog-src` | next to the script | Where `embed-dialog.global.js` is, for a copy hosted elsewhere. | ## Open it from your own search box With `data-manual`, the script waits for you to call `window.Ondocs.mount(options)`, which takes the same options as the attributes and returns `{ open, close, unmount }`. Use it to open the dialog from your own button: ```html ``` ## With a bundler In an app with a bundler, `mountAskDialog(options)` from `ondocs/embed` does the same with your own copy of React. Install `react`, `react-dom`, `@radix-ui/react-dialog` and `cmdk`, and import `ondocs/react/styles.css` and `ondocs/embed/launcher.css` with it. --- # Rate limits and security Source: https://ondocs.vercel.app/docs/security A public ask endpoint spends your model budget on every question, so it needs a rate limit, and it reads untrusted input on both sides: the question and your own pages. This page covers what the handler does about each. ## Add a rate limit The handler rate-limits by default: 10 questions a minute per client IP, read from `X-Forwarded-For`, which suits Vercel. To set your own limit, or to read the IP header your platform sets, pass `rateLimit` to `createAskHandler`. `memoryRateLimit` is a token bucket per client IP, kept in the memory of one server instance: ```ts import { openai } from '@ai-sdk/openai'; import { createAskHandler, memoryRateLimit } from 'ondocs/server'; import index from './ask-index.json'; export const POST = createAskHandler({ index, model: openai('gpt-5.4-mini'), embeddingModel: openai.embedding('text-embedding-3-small'), rateLimit: memoryRateLimit({ limit: 10, windowMs: 60_000 }), }); ``` A rejected request gets a 429 with `Retry-After` and `RateLimit-*` headers before any model is called, and the dialog shows "Too many questions. Try again shortly." On serverless platforms each instance counts on its own, so treat the memory limiter as a guard against one noisy client. For a limit shared by every instance and region, wrap an `@upstash/ratelimit` instance with `upstashRateLimit(new Ratelimit({ … }))`; ondocs does not depend on Upstash. Any `(request) => { success }` function works too. ## Which client IP header to trust A rate limit is only as strong as its key. Both limiters key each request by the client IP read from one header, and only a header your platform sets on every request can be trusted: a client can send any other. By default it is the last entry of `X-Forwarded-For`. An IPv6 address is keyed by its /64, which one subscriber is usually given whole. | Where the handler runs | Configure | | ----------------------------- | ---------------------------------------------------------------------------------------------- | | Vercel | Nothing: Vercel overwrites `X-Forwarded-For` with the client IP. | | Netlify | `trustedHeader: 'x-nf-client-connection-ip'` | | Cloudflare | `trustedHeader: 'cf-connecting-ip'` | | Fly.io | `trustedHeader: 'fly-client-ip'` | | Behind your own reverse proxy | Nothing if it appends to `X-Forwarded-For`; otherwise the header it sets, such as `x-real-ip`. | Requests without the header share one `"anonymous"` bucket. On a platform that does not set `X-Forwarded-For`, the default limiter is therefore one limit of 10 a minute for all visitors together, which errs on the side of your bill, and the handler reports the first such request to `onError` with the setting to change. Pass `rateLimit: false` only when something in front of the endpoint already limits it. `key: (request) => string` replaces the lookup, for example to limit per signed-in user. ## When the rate limiter fails If the limiter throws, for example because Redis is unreachable, the handler fails closed: it reports the error and answers 503 without calling the model. Set `rateLimitFailure: 'open'` to keep answering during an outage and rely on your provider's spend limits instead. ## Cap the daily spend A rate limit is per client, so many clients, or one rotating its IPs, can still run up the bill. `budget` caps the whole endpoint per UTC day, in questions and in model tokens: ```ts export const POST = createAskHandler({ index, model: openai('gpt-5.4-mini'), embeddingModel: openai.embedding('text-embedding-3-small'), rateLimit: memoryRateLimit({ limit: 10, windowMs: 60_000 }), budget: { requestsPerDay: 1_000, tokensPerDay: 3_000_000 }, }); ``` `requestsPerDay` counts the questions that go on to retrieval and the model, and `tokensPerDay` the model's input and output tokens, from each answer's `usage`. Before the model is called, the answer's worst case (its prompt plus `maxOutputTokens`) is reserved, and the answer goes ahead only if that fits; once it ends, the reservation is corrected to what it really used. So concurrent answers cannot overshoot the cap. Once the day's budget is spent, questions get a 429 with code `budget_exceeded` until midnight UTC, and the dialog says the assistant has reached its daily limit. The counts are kept in memory by default, so on a serverless platform each instance has its own budget. For one budget shared by every instance, pass `store: upstashBudgetStore(Redis.fromEnv())` with `@upstash/redis`. Either way, also set a monthly spend limit with your model provider: it is the only hard cap. ## Check for bots A fleet of addresses gets past a per-client rate limit, and can use up the day's budget before your readers ask anything. `challenge: turnstile({ secretKey })` has each question carry a Cloudflare Turnstile token, which the dialog solves, invisibly for most visitors, and the endpoint checks before it spends anything. See [Bot check with Turnstile](/docs/bot-challenge). ## Cache repeated questions `answerCache: true` answers a question asked again from memory, without embedding it or calling the model, so the questions everyone asks cost nothing after the first time. Questions match when they are the same apart from case, spacing and trailing punctuation, and the cache starts fresh when the index is rebuilt. `{ store: upstashAnswerCache(Redis.fromEnv()) }` shares it between instances. ## Cross-origin requests The endpoint only accepts `application/json` bodies and answers anything else with a 415. Browsers send a cross-origin JSON POST only after a CORS preflight, which fails unless you allow the origin, so another site cannot make its visitors' browsers spend your model budget. To call the endpoint from another origin on purpose, set `access-control-allow-origin` and `access-control-allow-headers: content-type` in the handler's `headers` option, and in Next.js also `export const OPTIONS = POST`. ## Input limits Questions are limited to 500 characters (`maxQuestionLength`), and request bodies to 64 KiB (`maxBodyBytes`), counted while the body is read so a chunked upload cannot exhaust memory. The body is validated with zod, and malformed requests get a 400 with a JSON error. ## Prompt injection Sources and the question reach the model verbatim, inside tags that end in a random suffix drawn per request, such as ``. No text on your site or in a question can close the sources block or pose as the question, however it spells a tag, and the instructions tell the model to treat both as data. That stops injected text from forging structure, not from being read: index only content you trust. ## How answers are rendered The answer is rendered from a small Markdown subset into React nodes, never as HTML. Links are allow-listed to http(s), mailto and relative URLs, and `[n]` becomes a link only if the server actually sent source `n`. If your pages could carry injected text, set the dialog's `links: 'sources'` (`data-links="sources"` on the script tag): links in an answer then stay links only when they point at one of its source pages, so a model talked into it cannot show visitors another site's login page. Model errors are masked in the stream, so provider details never reach the browser, and reported to `onError`. ## Privacy: what leaves your servers ondocs has no service of its own and sends no telemetry, so no third party sees your docs or your visitors' questions except the model provider you choose. The browser talks only to your endpoint. Your endpoint sends the question to your embedding provider, and the question with the retrieved excerpts to your language model provider, under your own API keys and their terms. Nothing else is sent anywhere. `onFinish` gives you each question and answer, and `onFeedback` each rating, if you want to log them; nothing is stored by default, and the dialog offers ratings only when `onFeedback` is set. See [Measure and improve answers](/docs/quality). --- # VitePress Source: https://ondocs.vercel.app/docs/vitepress The `ondocs/vitepress` plugin adds ondocs to a VitePress site by wrapping its config. After every `vitepress build` it indexes the pages the site serves, writes `llms.txt` and the pages' Markdown copies, and adds the dialog to every page with a floating "Ask AI" button. It works with VitePress 1 and the VitePress 2 alphas, with the default theme or a theme of your own, and needs no theme file. ## Install for VitePress The dialog is a React component, so install React and its two dependencies along with the AI SDK. They load with the dialog, the first time a visitor opens it, and never on the rest of your Vue pages: ```sh npm i ondocs ai @ai-sdk/openai react react-dom @radix-ui/react-dialog cmdk ``` ## Add it to .vitepress/config.mts To add ondocs to VitePress, wrap the config in `withOndocs`: ```ts // .vitepress/config.mts import { defineConfig } from 'vitepress'; import { withOndocs } from 'ondocs/vitepress'; export default withOndocs( defineConfig({ title: 'Acme Docs', // ...the rest of your config, as it is }), ); ``` Options go in a second argument: `withOndocs(defineConfig({ ... }), { endpoint: 'https://ask.acme.dev/api/ask' })`. Your config's own `buildEnd`, `transformHtml` and Vite plugins keep working: `withOndocs` adds its own after them. ## What VitePress indexes After `vitepress build`, the index is written to `.vitepress/dist/ask-index.json`, or `docs/.vitepress/dist/ask-index.json` when the site is in `docs/`. It is at the URLs VitePress serves: under `base`, with or without `.html` as `cleanUrls` has it, at the paths `rewrites` give the pages, and with a page for each path of a dynamic route. Files in `srcExclude` are not pages, so they are not indexed, and neither are pages with `search: false` in their frontmatter, which VitePress's local search leaves out too. The plugin reads each page's Markdown as the default theme renders it, the `.vp-doc` element, without the navigation, sidebar, outline, edit link or pagination, and without the heading anchors and code blocks' language labels. A home page with only a hero has no Markdown, so it is skipped. A theme of your own that does not use `.vp-doc` names its content with `content: '.my-content'` (a tag, `#id`, `.class` or `[attribute]`, or a comma-separated list), and `ignore: '.toc'` leaves parts out. A page's title is its `

`, else its title without the site's, and its description is its frontmatter `description`. Unchanged pages reuse their vectors from the previous build, cached in `node_modules/.cache/ondocs`. ## The dialog in VitePress The plugin adds a floating "Ask AI" button and opens the dialog with ⌘I or Ctrl+I, so ⌘K stays with VitePress's search. The button takes the default theme's colors, and the button and the dialog follow VitePress's light and dark modes, as its appearance switch sets them. A page loads only the button, about 2 KB of JavaScript, and the dialog loads the first time it is wanted. The plugin adds the button through VitePress's own client entry, so there is no theme file to write. With `mpa: true`, VitePress ships no client entry, and pages get no button: add the [script tag](/docs/script-tag) to `head` instead. Pick another key with `dialog: { shortcut: 'j' }`, turn the shortcut off with `shortcut: false`, or hide the button with `buttonLabel: false`. `dialog: { links: 'sources' }` keeps only the answer's links to its source pages. ## Locales in VitePress Each locale in VitePress's `locales` gets an index of its own, with the pages under its key: `.vitepress/dist/ask-index.json` for the root locale and `.vitepress/dist/fr/ask-index.json` for `fr`, served at `/fr/ask-index.json`. The llms files are written per locale the same way, titled after the locale's `title` and `description`. On a locale's pages, the dialog takes the locale from the page's path and sends it with each question, and the endpoint answers from that locale's index; the endpoints `npx ondocs init` writes read the locales from the config. VitePress moves between pages without loading them, and when a visitor switches language, the dialog switches with them. It is named after the locale's title, and `dialog.locales`, by the locale's key, gives it its words in each language: `withOndocs(config, { dialog: { locales: { fr: { title: 'Demander à Acme', labels: { launcher: 'Demander' } } } } })`. Every label is listed in [The ask dialog](/docs/ask-dialog#labels-and-languages). ## Embeddings in VitePress Embeddings work as in the other plugins. OpenAI's `text-embedding-3-small` at 512 dimensions is the default when `OPENAI_API_KEY` is set at build time, or the same model through AI Gateway with `AI_GATEWAY_API_KEY`; without either key, the index is keyword-only and the build prints a warning. Choose the model with `embedding` and `dimensions`, for example `{ embedding: 'openai:text-embedding-3-small', dimensions: 512 }`, `embedding: 'workers-ai:@cf/baai/bge-small-en-v1.5'` for the [Workers AI Worker](/docs/workers-ai), or `embedding: 'mock'` to build without a key. ## Deploy the endpoint for VitePress A VitePress site is static, so the endpoint is a function on its host. Write it with `npx ondocs init`, which reads `.vitepress/dist/ask-index.json` on Vercel, Netlify or Cloudflare, or writes a Cloudflare Worker for GitHub Pages; see [Deploying](/docs/deployment). When the endpoint is not at `/api/ask` on the site, point the dialog at it with `endpoint`. To serve the same index to agents, `init` writes the [MCP endpoint](/docs/mcp) too, and the `mcp` option puts its URL in `llms.txt`. `llms.txt` links pages absolutely when the plugin knows the site's URL: `sitemap.hostname`, or the `siteUrl` option. When vitepress-plugin-llms is in your Vite plugins, the plugin leaves the llms files to it. To try it locally, build the site once, run `npx ondocs dev`, which answers from `.vitepress/dist/ask-index.json` or `docs/.vitepress/dist/ask-index.json`, and start `vitepress dev` with `ASK_ENDPOINT=http://localhost:8787/api/ask`. ## VitePress options | Option | Default | What it does | | -------------------------------------------- | ---------------------------------- | ----------------------------------------------------------------------------------------------- | | `endpoint` | `ASK_ENDPOINT`, else `/api/ask` | Where the dialog posts questions; `ASK_ENDPOINT` overrides it. | | `embedding`, `dimensions` | OpenAI when a key is set | The embedding model, named as the CLI names it. | | `embeddingModel`, `embeddingProviderOptions` | none | An AI SDK model object and its options, instead of `embedding`. | | `chunking` | `{ maxChars: 1200, overlap: 150 }` | Chunk size and overlap, in characters. | | `content`, `ignore` | `.vp-doc` | What to read from each page, and what to skip besides heading anchors and code labels. | | `indexFile` | `ask-index.json` | Where the index is written in `.vitepress/dist/` and served from. | | `exclude` | `[]` | Path prefixes to leave out, relative to `base` and the locale. | | `dialog` | | `title`, `placeholder`, `suggestions`, `shortcut`, `buttonLabel`, `theme`, `labels`, `locales`. | | `siteUrl` | `sitemap.hostname` | The site's URL, for absolute links in `llms.txt` and an `mcp` path. | | `mcp` | none | The [MCP endpoint](/docs/mcp)'s URL or path, which `llms.txt` points agents at. | | `llmsTxt` | on | Write [llms.txt, llms-full.txt and .md copies](/docs/llms-txt); `false` turns them off. | --- # Workers AI, without an API key Source: https://ondocs.vercel.app/docs/workers-ai A docs site on GitHub Pages, S3, Read the Docs or any other host that only serves files can have its Ask box and its MCP endpoint answered by a Cloudflare Worker with Workers AI, Cloudflare's own models. There is no model key to get or set, and a small site's questions fit in Workers AI's free daily allowance of 10,000 neurons. ## Deploy the Worker There are two ways to get the same Worker. In the site's folder, `init` reads the site's URL from its config: ```sh npx ondocs init --host github-pages # writes ondocs-worker/ cd ondocs-worker && npm install && npx wrangler deploy ``` Or start from the template, set `SITE_URL` in its `wrangler.jsonc` to the site's URL with its base path, and deploy: ```sh npm create cloudflare@latest my-docs-ask -- --template=dgesteves/ondocs/templates/cloudflare-worker cd my-docs-ask && npx wrangler deploy ``` Wrangler prints the Worker's URL. Point the dialog at it, plus `/api/ask`: `endpoint` in the Docusaurus or Starlight plugin, or `data-endpoint` on the script tag. You need a Cloudflare account, which is free, and `npx wrangler login` once. The site keeps deploying as it does now, and must serve its index at `SITE_URL/ask-index.json`, which the plugins write with every build. The Worker fetches the index from the live site and checks it again every five minutes with a conditional request, so it follows the site's deploys without being deployed again. It sends CORS headers for the site's origin and no other, says "I don't know" without calling the model when nothing in the docs is relevant, and serves the same index to agents at `/api/mcp`, keyword-only, calling no model. ## Keywords, or meaning too An index built without an embedding model, the default when the build has no model key, is searched by keywords, and needs no secret anywhere. To search by meaning as well, embed the index with Workers AI as the site builds: ```js // The Docusaurus or Starlight plugin ondocs({ endpoint: 'https://my-docs-ask..workers.dev/api/ask', embedding: 'workers-ai:@cf/baai/bge-small-en-v1.5', }); ``` ```sh # Any other site npx ondocs index public -o public/ask-index.json -e workers-ai:@cf/baai/bge-small-en-v1.5 ``` The build calls Cloudflare's REST API with `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN`, a token with Workers AI Read and Edit permissions, set as repository secrets for a GitHub Actions build. It sends each chunk's text, 100 at a time, and gets back BGE-small's 384-dimension vectors, at $0.020 per million tokens (1,841 neurons per million). The Worker embeds each question with the same model through its AI binding. Both send only the text, so both get the model's default pooling, as they must to compare. On docusaurus.io's docs (1,070 chunks) and the 22 questions and 6 off-topic ones the 2026-10-10 audit asked, with BGE-small run locally (the same open weights, with Workers AI's default mean pooling): | Search | Right page first | Right page in the top 3 | Off-topic questions answered | | ---------------------- | ---------------- | ----------------------- | ---------------------------- | | Keywords only | 14 of 22 | 18 of 22 | 0 of 6 | | Keywords and BGE-small | 16 of 22 | 19 of 22 | 0 of 6 | BGE-small scored even the off-topic questions 0.46 to 0.63 against their closest chunk, so the default gate of 0.25, set for OpenAI's models, would let every question through. An index embedded with it gets 0.65 instead: the lowest value at which no off-topic question in that set gets through. Another embedding model needs its own `retrieval.minSimilarity`; [How retrieval works](/docs/retrieval) explains the gate. ## Choose the model that answers `CHAT_MODEL` in `wrangler.jsonc` is the Workers AI model that writes the answers. Workers AI costs $0.011 per 1,000 neurons, after the first 10,000 each day. A question of 2,400 input tokens (the instructions and up to 8,000 characters of sources) and 500 output tokens costs, from the neurons per million tokens on Cloudflare's pricing page in October 2026: | Model | Neurons a question | Questions a day, free | Per 1,000 questions after | | ---------------------------------------------- | ------------------ | --------------------- | ------------------------- | | `@cf/meta/llama-4-scout-17b-16e-instruct` | 97.5 | about 100 | $1.07 | | `@cf/mistralai/mistral-small-3.1-24b-instruct` | 101.8 | about 98 | $1.12 | | `@cf/meta/llama-3.3-70b-instruct-fp8-fast` | 166.4 | about 60 | $1.83 | | `@cf/meta/llama-3.2-3b-instruct` | 26.3 | about 380 | $0.29 | | `@cf/ibm-granite/granite-4.0-h-micro` | 8.8 | about 1,100 | $0.10 | Llama 4 Scout is the default. These figures are prices, not quality: the answers of these models have not been compared here. A question's embedding costs about 0.04 neurons. On the Workers Free plan, nothing is charged past the free allowance; on Workers Paid it is billed at the rate above. The Worker caps itself at 100 questions and 300,000 model tokens a day per instance, about the free allowance with Llama 4 Scout: raise the `budget` in `src/index.ts` on Workers Paid. ## How large a site fits A Worker instance has 128 MB of memory, and loads the index on its first request. Measured with Node.js 24 on an Apple M1 Max (V8, the engine Workers run), for indexes of docusaurus.io's text: | Chunks | Index file, keywords / 384 dimensions | Memory once loaded | Heap growth while loading, before collection | | ------ | ------------------------------------- | ------------------ | -------------------------------------------- | | 1,000 | 0.8 MB / 1.3 MB | 1.8 MB / 2.2 MB | 13 MB / 17 MB | | 5,000 | 3.9 MB / 6.4 MB | 6.7 MB / 8.6 MB | 48 MB / 66 MB | | 10,000 | 7.7 MB / 12.7 MB | 14 MB / 23 MB | 110 MB / 137 MB | Up to about 5,000 chunks, a site fits with room to spare. Around 10,000 chunks, loading comes close to the limit; keep such an index keyword-only, or split it, as the plugins already do per locale. Avoid OpenAI's full 1,536 dimensions in a Worker: in the audit, 9,610 chunks at that size grew the process by 214 MB. CPU time is the other limit. The Workers Free plan gives a request 10 ms of CPU time, and, in Cloudflare's words, "each isolate has some built-in flexibility to allow for cases where your Worker infrequently runs over the configured limit". Loading the index in a fresh process took 17 ms for this site's docs (334 chunks), 38 ms for 1,000 chunks and about 160 ms for 5,000, once per instance. A search after that takes 0.7 ms at 1,000 chunks and about 7 ms at 10,000 ([Benchmarks](/docs/benchmarks)), and the model calls are waits, not CPU time. A small site fits the Free plan. For a large one, use Workers Paid, which allows 30 seconds of CPU time by default. The Free plan also caps a Worker at 100,000 requests a day. ## Test it without an account The template's `npm test` runs the Worker in workerd, the Workers runtime, on your machine, with stand-ins for the site and the AI binding, so it needs no Cloudflare account and uses none of your allowance. `npx wrangler dev` runs it against the real Workers AI, which needs `wrangler login` and counts against your allowance. ## With OpenAI instead `npx ondocs init --host github-pages --provider openai` writes the same Worker with OpenAI: set its key with `npx wrangler secret put OPENAI_API_KEY`, and it embeds questions with the OpenAI model the index records. --- # Writing an integration Source: https://ondocs.vercel.app/docs/writing-an-integration The Docusaurus plugin, the Astro integration, and the Starlight and VitePress plugins are built from one import, `ondocs/integration`, and use nothing else of ondocs's to build. A plugin for another framework imports the same helpers: it reads the pages the framework built, writes the index and the llms files next to them, and adds the launcher to every page. It runs in Node.js, as the site builds. ## What an integration does An integration has two jobs, at two moments of the framework's build: 1. **While the framework loads its config**, check the options, so a mistake fails before anything is built, and add the launcher and its stylesheets to every page, pointed at the endpoint. 2. **After the framework has written the site**, read each page it built, at the URL it is served from, and write `ask-index.json`, `llms.txt`, `llms-full.txt` and the pages' `.md` copies into the build output, linking each page to its copy. Indexing the built pages, rather than the Markdown sources, means the index has what readers see: the URLs the site serves, with its base path, its rewrites and its trailing slashes, and the pages' text after components and includes have rendered. ## A minimal integration Here is the whole integration for an imaginary framework whose plugins get a `clientModule` that every page imports and a `buildEnd` hook with the output folder and the routes it built: ```ts import { buildEmbedding, checkIndexOptions, consoleLogger, dialogEndpoint, excluder, indexCache, LAUNCHER_STYLESHEETS, launcherScript, llmsOutputs, loadBuiltPage, readBuiltHtml, writeLlmsFiles, writeSiteIndex, type IndexOptions, type LlmsPage, type LlmsTxtOption, type SourceDocument, } from 'ondocs/integration'; export type OndocsOptions = IndexOptions & { endpoint?: string; exclude?: string[]; llmsTxt?: LlmsTxtOption; }; export default function ondocs(options: OndocsOptions = {}) { checkIndexOptions(options); const log = consoleLogger(); return { name: 'ondocs', clientModule: [ ...LAUNCHER_STYLESHEETS.map((stylesheet) => `import '${stylesheet}';`), launcherScript({ endpoint: dialogEndpoint(options.endpoint), title: 'Ask Acme Docs' }), ].join('\n'), async buildEnd({ root, outDir, routes }: { root: string; outDir: string; routes: string[] }) { const excluded = excluder(options.exclude ?? []); const outputs = llmsOutputs(options.llmsTxt); const documents: SourceDocument[] = []; const pages: LlmsPage[] = []; for (const route of routes) { if (excluded(route)) continue; const html = await readBuiltHtml(outDir, route); if (html === undefined) continue; const built = loadBuiltPage(html, { url: route, root: 'main', markdown: true }); if (!built) continue; documents.push(built.document); if (built.page) pages.push(built.page); } const embedding = await buildEmbedding(options, log); await writeSiteIndex({ documents, embeddingModel: embedding.model, embeddingProviderOptions: embedding.providerOptions, options, file: `${outDir}/ask-index.json`, cache: indexCache(root, 'acme.json'), name: 'ask-index.json', log, }); await writeLlmsFiles({ pages, site: { title: 'Acme Docs', url: 'https://docs.acme.dev' }, outputs, dir: outDir, label: 'dist', log, // Link each page to its .md copy, in the HTML the framework wrote as guide/index.html. linkPages: 'directory', }); }, }; } ``` The plugin's options are `IndexOptions` (`embedding`, `dimensions`, `embeddingModel`, `embeddingProviderOptions` and `chunking`, as every plugin takes them) and whatever else it needs. The sections below go through each step. ## Read the pages the framework built `readBuiltHtml(outDir, path)` reads the HTML the build wrote for a path, as `guide/index.html` or `guide.html`, whichever exists; pass `'file'` as the third argument when the framework writes `guide.html` first. `loadBuiltPage(html, options)` turns it into the document to index and, with `markdown: true`, the page for the llms files. Give it: - `url`: the URL the site serves the page at, with the base path, as citations will link to it. - `root`: the part of the page that is its content. The default, `main`, reads `
`, then the page's `
`s, then ``. A selector such as `.vp-doc` or `[data-pagefind-body]` reads only what it matches, and a page where it matches nothing is skipped. - `ignore`: what to leave out of `root`, such as `.header-anchor, .toc`. Navigation, buttons, forms and footers are left out anyway. - `titleSuffix`: what the framework adds to each ``, such as `' | Acme Docs'`, taken off a title read from it. A title from the page's `<h1>` has none. It returns `null` for a page with no content, or with `<meta name="robots" content="noindex">`, and such a page is not indexed. Skip the framework's 404 page yourself. `excluder(options.exclude)` gives the plugins' `exclude` option: path prefixes, each with the paths under it. ## Write the index `buildEmbedding(options, log)` gives the embedding model the options name, as the other plugins choose it: `embeddingModel` or `embedding` when set, else OpenAI's `text-embedding-3-small` at 512 dimensions when `OPENAI_API_KEY` is set at build time, the same model through AI Gateway when only `AI_GATEWAY_API_KEY` is, and else a keyword-only index, with a warning. A key that is set but cannot be used fails the build. `checkIndexOptions(options)` throws on options that contradict each other; call it where the framework first hands you the options. `writeSiteIndex` builds the index and writes it to `file`. `cache` is where it keeps a copy for the next build, so unchanged pages reuse their vectors instead of being embedded again: `indexCache(root, name)` puts it in `node_modules/.cache/ondocs`, which Vercel and Netlify keep between builds. Give each index its own cache file, one per locale or plugin instance. The log line says how many pages and chunks it indexed, and how many it embedded and reused. A site with locales gets an index per locale: group the pages by their locale's path prefix, and write each group to `<outDir>/<locale>/ask-index.json`, as the Astro integration does. The dialog sends the page's locale with each question, and the endpoint answers from that locale's index. ## Write llms.txt and the Markdown pages `llmsOutputs(options.llmsTxt)` reads the plugins' `llmsTxt` option: all three files by default, none for `false`, and `{ full: false }` to turn one off. `writeLlmsFiles` writes them into `dir`, the build output for `site.base`, from the pages `loadBuiltPage` returned. It never replaces a file the build already has, from the site's static files or another plugin, and says so. Pass `owners` to leave a file to another plugin that writes it, by name: the Docusaurus plugin leaves `llms.txt` to docusaurus-plugin-llms when the site has it. With `linkPages`, it also links each page to its copy, with `<link rel="alternate" type="text/markdown">` in the `<head>` of the page's HTML in `dir`. Set it to how the framework writes pages, as `readBuiltHtml` reads them: `'directory'` for `guide/index.html`, `'file'` for `guide.html`. A URL ending in `/` or `.html` names its file itself. The page's HTML is replaced in one step, so another plugin reading it at the same time reads all of it. ## Add the launcher Every page needs the launcher, the "Ask AI" button and the ⌘I shortcut, which loads the dialog the first time it is wanted. How you add it depends on how the framework builds its pages: - **A framework that bundles each page's scripts**, with Vite, Rollup or webpack: `launcherScript` returns the source of a module that mounts the launcher with `mountAskDialog` from `ondocs/embed`, about 1.5 KB gzipped, with the dialog in a chunk of its own. Serve it as a virtual module every page imports, with `LAUNCHER_STYLESHEETS`, the dialog's and the launcher's stylesheets. Give it `locales` and `base`, and each page under a locale's path prefix gets that locale's labels and sends its locale; with `spa: true`, for a site that moves between pages without loading them, it mounts again when a navigation changes `<html lang>`. With Vite, add `quietUseClient()` to its plugins, which blanks the `"use client"` directives that Radix and cmdk start with, so the build does not warn about each one. The Astro integration and the VitePress plugin do this; the site installs React, `@radix-ui/react-dialog` and `cmdk` for the dialog. - **A React framework**: render `AskDialog` from `ondocs/react` with `launcher`, as in the [Next.js guide](/docs/nextjs). The Docusaurus plugin's theme does the same. - **Anything else**: the [script tag](/docs/script-tag), which brings the dialog with its own copy of React. `dialogEndpoint(options.endpoint)` is where the dialog posts: the `ASK_ENDPOINT` environment variable when it is set, so `ASK_ENDPOINT=http://localhost:8787/api/ask` points any site at [`ondocs dev`](/docs/local-development), else the option, else `/api/ask`. While the framework's dev server runs, `hintDevEndpoint(endpoint, 'acme dev', log)` says once how to get answers from `ondocs dev`, when the dialog posts to a path the dev server does not serve. `warnMissingDialogPeers(log)` warns, naming what to install, when the site lacks a package the dialog renders with, before the bundler fails on it. ## Point agents at the MCP endpoint The plugins take an `mcp` option, the site's [MCP endpoint](/docs/mcp), as a URL or a path on the site. `resolveMcp(options.mcp, siteUrl, siteTitle, where)` makes it absolute and names it, and its URL goes into `llms.txt` as `site.mcp`, so agents that read the file find it. `mcpInstallLinks` from `ondocs` gives the "Add to Cursor / VS Code / Claude" links for a page that shows them. The endpoint itself is a function next to the site, as for any other static site: [`ondocs init`](/docs/deployment) writes it for the host, and it reads the index the build wrote. ## What ondocs/integration exports | Export | What it does | | -------------------------------------------- | ------------------------------------------------------------------------------------- | | `readBuiltHtml`, `loadBuiltPage` | Read a built page, and turn it into a document to index and a page for the llms files | | `excluder` | The `exclude` option: whether a path is under one of its prefixes | | `checkIndexOptions`, `buildEmbedding` | Check the index options, and build the embedding model they name | | `writeSiteIndex`, `indexCache` | Write the index, reusing the vectors cached from the previous build | | `llmsOutputs`, `writeLlmsFiles` | Read the `llmsTxt` option, and write `llms.txt`, `llms-full.txt` and the `.md` pages | | `launcherScript`, `LAUNCHER_STYLESHEETS` | The module and the stylesheets that add the launcher to every page | | `quietUseClient` | A Vite plugin that keeps the dialog's dependencies from warning about `"use client"` | | `dialogEndpoint`, `hintDevEndpoint` | Where the dialog posts, and how to get answers in development | | `warnMissingDialogPeers` | Name the dialog's missing packages | | `resolveMcp` | The `mcp` option as an absolute URL and a name | | `consoleLogger` | A logger with the `[ondocs]` prefix, for a framework that gives plugins none | | `IndexOptions`, `LlmsTxtOption`, `McpOption` | The options every plugin takes, as types | It also exports the types its functions take and return, such as `SourceDocument`, `LlmsPage` and `OndocsDialogOptions`, so an integration needs no other import.