Reference

The ask endpoint

createAskHandler options, the request and response, errors and the streaming protocol.

createAskHandler(options) from ondocs/server returns the endpoint: a function that takes a Web Request and returns a Promise<Response>. 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 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

OptionDefaultNotes
indexrequiredParsed 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.
indexesnoneThe index of each other locale, by the locale the dialog sends: { fr: () => readFile('build/fr/ask-index.json', 'utf8') }.
modelrequiredAny AI SDK language model, or an AI Gateway model id.
embeddingModelnoneMust match the index, which is checked. Without it, retrieval is keyword-only.
embeddingProviderOptionsnoneMust match the build, such as { openai: { dimensions: 512 } }.
siteName"this site"Used in the instructions and the "I don't know" message.
instructionsgrounded defaultsA string, or (defaults) => string to extend them.
retrievaltuned{ topK, candidates, rrfK, minSimilarity, minKeywordCoverage, similarityNeedsKeyword }, and the rerank and retrieve hooks.
maxContextChars8000Characters of source text sent to the model.
maxQuestionLength500Longer questions get a 400.
maxBodyBytes64 KiBCounted while reading the body.
noAnswerMessage"I don't know. I couldn't…"Streamed when nothing is relevant.
rateLimit10 a minute per IPmemoryRateLimit(); any (request) => { success, … } replaces it, and false turns it off.
rateLimitFailure"closed"When rateLimit throws: "closed" answers 503, "open" answers anyway.
challengenoneturnstile({ secretKey }): a bot check 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…
budgetnone{ requestsPerDay, tokensPerDay, store }: a daily cap, then a 429 with code budget_exceeded.
answerCacheofftrue, or { store, ttlSeconds }: repeated questions are answered from the cache.
followUpson{ model, always, maxTurns, instructions } for rewriting follow-ups; false answers every question on its own.
headersnoneAdded to every response, such as CORS headers.
onFeedbacknoneCalled with a reader's rating of an answer; with it, the dialog offers thumbs up and down and a comment.
onFinishnoneCalled after each answer with { id, question, answer, sources, refused, lowConfidence, retrieval, usage, followUp }.
onErrorconsole.errorHandled 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, 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'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:

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.