Agents
Embed your documentation assistant inside your product
A chat panel in your React app that streams answers from your Blume docs, knows which page covers the current screen, and links every source back to the docs.
By Hayden Bleasel12 min read

To put your docs assistant inside your product, turn on Blume's assistant, deploy your docs to a server host, list your product's origin in ai.assistant.cors, and call the generated POST /api/ask route from your app. The route answers with a plain text stream grounded in your docs, with citations as links to your pages, so a small React component can show answers inside your product as they arrive.
By the end of this guide, the fictional Acme app at app.acme.example has an "Ask the docs" panel that streams answers from docs.acme.example, tells the assistant which docs page covers the current screen, links every source back to the docs, stops on request, and turns each error the route can return into a message the reader understands.
If a link to your docs, or the assistant panel on the docs site itself, is enough, you don't need any of this. And Blume's route only knows your docs: it can't see who the user is or what's in their account, and it answers anyone who calls it. If answers have to depend on the user, or sit behind your login, run your own assistant backend instead.
Decide which side runs the assistant
Two settings connect the assistant to something outside the docs site, and they point in opposite directions:
cors | endpoint | |
|---|---|---|
| What it does | Lets pages on other origins call Blume's generated route | Points Blume's docs panel at a backend you run |
| Who answers | Blume's route, on the docs host | Your backend |
| Docs build | Server output, on a host adapter | Can stay static |
| Retrieval and citations | Blume grounds answers in your pages | Your backend's job |
| CORS, rate limits, bot checks | Blume's route handles them | Your backend's job |
Setting both is a config error, since cors only configures the generated route. This guide uses cors. If you already run an assistant backend, your app calls it directly and endpoint lets the docs panel share it. Both take the same request body and return the same text stream, so the component below works against either once you change its URL.
Deploy the assistant route
Add the assistant and a host adapter to the docs site's config. The origin in cors is your product's, not the docs site's:
import { defineConfig } from "blume";
import { vercel } from "blume/deploy";
export default defineConfig({
// ...the rest of your config
ai: {
assistant: {
enabled: true,
cors: ["https://app.acme.example"],
},
},
deployment: vercel({ site: "https://docs.acme.example" }),
});The assistant's backend is a server route, and naming a host adapter switches the build to server output. Without one, blume build stops with BLUME_SERVER_FEATURE_REQUIRED. The node(), netlify(), and cloudflare() adapters work the same way; see Server rendering.
The model and its key work as in Add an AI assistant that answers from your documentation, which covers choosing a provider, keeping the key on the server, and testing answers. With no provider set, answers come from openai/gpt-5.5 through the Vercel AI Gateway, which reads AI_GATEWAY_API_KEY (or, on Vercel, the deployment's OIDC token). Until the key is set, the route answers 503.
Once deployed, the route is at https://docs.acme.example/api/ask. If your docs deploy under a deployment.base like /docs, the route moves under it too: https://acme.example/docs/api/ask.
Allow your product's origin
A browser only lets a page read a response from another origin when the response names that origin. Blume reduces each cors entry to its origin (scheme, host, and port), and the route compares the caller's Origin header against the list exactly:
https://app.acme.exampleandhttps://acme.exampleare different origins. List each one your panel runs on.- There are no patterns. Per-branch preview URLs each need their own entry, so list a stable staging origin, or
"*"to let any page call the route. - The list is written into the generated route when the site builds, so a change takes effect on the next deploy.
To develop against a local docs server, add Vite's default origin, http://localhost:5173, to the list and run npx blume dev, which serves the route at http://localhost:4321/api/ask.
Once it's deployed, replay the preflight a browser sends before the real request:
curl -i -X OPTIONS https://docs.acme.example/api/ask \
-H "Origin: https://app.acme.example" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: content-type"A listed origin gets a 204 with these headers among the rest:
access-control-allow-origin: https://app.acme.example
access-control-allow-headers: content-type
access-control-allow-methods: POST
access-control-max-age: 86400
vary: originSend the same command with an origin that isn't listed, like https://example.com. It still gets a 204, but without access-control-allow-origin, so the browser stops there and never sends the question.
CORS is not authentication
CORS decides which browser pages may read the route's answers. It doesn't decide who may ask. Leave out the Origin header and the route streams an answer to anyone:
curl -N https://docs.acme.example/api/ask \
-H "content-type: application/json" \
-d '{"messages":[{"role":"user","content":"How do I send an SMS?"}]}'So treat the route as public, whatever the list says. These are what limit it:
- Validation. The route reads at most 64 KB, and accepts 1 to 40 messages, up to 24,000 characters of JSON, in the
userandassistantroles only, so a caller can't swap in their own system prompt. - Rate limiting. On by default, at 30 questions per IP address every 10 minutes. The default count lives in memory, per instance on serverless hosts;
upstash()gives every instance one shared count. People using your product from one office often share an address, so raiserequestsif they meet it. See Rate-limit a public documentation AI assistant. - A bot check. With
captchaset (see Add a bot check), the route answers 403 to any question without a valid token in the body'scaptchafield. Your panel would then have to run the same Turnstile or hCaptcha widget and send its token, and Turnstile only runs on the hostnames listed for the widget, so add your product's. The example below leaves the bot check out.
Questions reach your model provider as the reader typed them. The route doesn't know who's asking, so don't add account details or tokens to the messages your app sends.
Build the chat component
In your React app, add react-markdown to render the answers' Markdown:
npm install react-markdown@10Put the request in its own module. It sends the conversation, reads the answer as it streams, and turns every failure into an AskError with a message for the reader:
// Where the docs are deployed, with the deployment base if there is one
// (like https://acme.example/docs). The route and every citation live under it.
export const DOCS_URL = "https://docs.acme.example";
export interface AskMessage {
content: string;
role: "assistant" | "user";
}
/** The route turned the question away, or the answer never arrived. */
export class AskError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
}
const GENERIC = "Something went wrong answering that. Try again.";
const NOTICES: Record<number, string> = {
0: "Couldn't reach the docs. Check your connection and try again.",
400: "That question couldn't be sent. Shorten it and try again.",
403: "The docs assistant turned that request away.",
413: "That question is too long. Shorten it and try again.",
429: "You've asked a lot of questions. Try again in a few minutes.",
503: "The docs assistant isn't available right now.",
};
// The route answers 400 past 40 messages or 24,000 characters of JSON, so
// send the latest turns that fit, starting with a question.
const fit = (messages: AskMessage[]): AskMessage[] => {
let kept = messages.slice(-40);
while (
kept.length > 1 &&
(kept[0].role === "assistant" || JSON.stringify(kept).length > 24_000)
) {
kept = kept.slice(1);
}
return kept;
};
/** Point a citation like /templates at the docs, not at your app. */
export const docsHref = (url: string): string =>
url.startsWith("/") && !url.startsWith("//") ? `${DOCS_URL}${url}` : url;
interface AskOptions {
messages: AskMessage[];
/** Called with the answer so far, each time more of it arrives. */
onText: (answer: string) => void;
/** The docs route that covers the reader's screen, like "/templates". */
page?: string;
signal: AbortSignal;
}
export const askDocs = async ({
messages,
onText,
page,
signal,
}: AskOptions): Promise<string> => {
let response: Response;
try {
response = await fetch(`${DOCS_URL}/api/ask`, {
body: JSON.stringify({
messages: fit(messages),
page: page ? { path: page } : undefined,
}),
// Without a JSON content type, the docs site's cross-site check
// answers 403 before the route runs.
headers: { "content-type": "application/json" },
method: "POST",
signal,
});
} catch (error) {
// A Stop click lands here too, and the caller handles it.
if (signal.aborted) {
throw error;
}
// Offline, DNS, or a CORS rejection: the browser gives no status.
throw new AskError(0, NOTICES[0]);
}
if (!(response.ok && response.body)) {
throw new AskError(response.status, NOTICES[response.status] ?? GENERIC);
}
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
let answer = "";
for (;;) {
const { done, value } = await reader.read();
if (done) {
break;
}
answer += value;
onText(answer);
}
// A provider failure after the 200 (a bad key, an unknown model) ends the
// stream with no text, so an empty answer is an error.
if (!answer) {
throw new AskError(200, GENERIC);
}
return answer;
};Three details in it matter:
- The content type. Astro rejects a cross-origin
POSTwith no content type, or a form-like one such astext/plain, with a 403 before Blume's route runs. That response has no CORS headers, so the browser reports it as a network error. - The stream is plain UTF-8 text, not server-sent events.
TextDecoderStreamdecodes it chunk by chunk and keeps a character split across two chunks intact. - An empty answer is an error. The route sends its 200 as soon as the model call starts, so a provider failure after that ends the stream with no text. A failure partway through ends it early, which looks like a short answer.
Then the panel itself:
import { type FormEvent, useEffect, useRef, useState } from "react";
import Markdown, { defaultUrlTransform } from "react-markdown";
import { AskError, type AskMessage, askDocs, docsHref } from "./ask-docs";
interface DocsAssistantProps {
/** The docs route that covers the screen this panel sits on. */
page?: string;
}
export function DocsAssistant({ page }: DocsAssistantProps) {
const [messages, setMessages] = useState<AskMessage[]>([]);
const [question, setQuestion] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const controllerRef = useRef<AbortController | null>(null);
// Stop the answer if the panel closes mid-stream.
useEffect(() => () => controllerRef.current?.abort(), []);
const send = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const text = question.trim();
if (!text || busy) {
return;
}
const before = messages;
const history: AskMessage[] = [...before, { content: text, role: "user" }];
const controller = new AbortController();
controllerRef.current = controller;
setMessages([...history, { content: "", role: "assistant" }]);
setQuestion("");
setError(null);
setBusy(true);
try {
await askDocs({
messages: history,
onText: (answer) =>
setMessages([...history, { content: answer, role: "assistant" }]),
page,
signal: controller.signal,
});
} catch (caught) {
if (controller.signal.aborted) {
// Stopped: keep what arrived, and drop the bubble if nothing did.
setMessages((current) => current.filter((message) => message.content));
} else {
// Failed: put the question back so the reader can send it again.
setMessages(before);
setQuestion(text);
setError(
caught instanceof AskError ? caught.message : "Something went wrong."
);
}
} finally {
controllerRef.current = null;
setBusy(false);
}
};
return (
<section aria-label="Ask the docs">
<ol aria-busy={busy}>
{messages.map((message, index) => (
<li data-role={message.role} key={index}>
{message.role === "user" ? (
<p>{message.content}</p>
) : (
<Markdown
components={{
a: ({ node, ...props }) => (
<a {...props} rel="noreferrer" target="_blank" />
),
}}
urlTransform={(url) => defaultUrlTransform(docsHref(url))}
>
{message.content || "Thinking…"}
</Markdown>
)}
</li>
))}
</ol>
{error && <p role="alert">{error}</p>}
<form onSubmit={send}>
<input
aria-label="Question"
maxLength={4000}
onChange={(event) => setQuestion(event.target.value)}
placeholder="Ask about the docs"
value={question}
/>
{busy ? (
<button onClick={() => controllerRef.current?.abort()} type="button">
Stop
</button>
) : (
<button type="submit">Ask</button>
)}
</form>
</section>
);
}It's unstyled, so it takes on your app's styles. react-markdown doesn't render raw HTML by default, so an answer can't inject markup into your app.
Send page context and keep source links
Mount the panel on each screen with the docs page that covers it:
import { DocsAssistant } from "./DocsAssistant";
export function TemplatesScreen() {
return (
<main>
<h1>Templates</h1>
{/* ...the screen itself */}
<DocsAssistant page="/templates" />
</main>
);
}The component sends page as page.path. The route puts that page into the model's context first, marked as the page the reader is viewing, and keeps retrieval to its language and, on a versioned site, its docs version. Use the route as your docs sidebar links it, without deployment.base. A path that matches no page is ignored, and answers come from search alone.
The assistant cites pages as Markdown links to their routes, like [Templates](/templates). On the docs site those resolve to the right page, but in your app a root-relative link resolves against your app's origin and lands on the wrong page. docsHref joins root-relative links onto DOCS_URL before react-markdown's defaultUrlTransform runs, which still drops unsafe URLs such as javascript: links. Citations leave the base out, so if your docs have one, include it in DOCS_URL.
Handle errors, limits, and cancellation
These are the responses a question can get, and what the panel shows for each:
| Status | Cause | The panel shows |
|---|---|---|
| None | Offline, a DNS failure, an origin not in cors, or Astro's cross-site 403 | Couldn't reach the docs |
| 400 | The body failed validation | Couldn't be sent |
| 403 | The bot check failed | Turned away |
| 413 | The body is over 64 KB | Too long |
| 429 | The reader is over the rate limit | Try again in a few minutes |
| 503 | The provider's key, or the bot check's secret, isn't set | Not available |
| 500 | The route failed before streaming | Something went wrong |
| 200, empty | The provider failed after the response started | Something went wrong |
Every response from the route names a listed origin, so your app can always read its status. The 429's Retry-After header isn't exposed to other origins, though, so show a fixed wait. Each error also has a short plain-text body that explains it: read it with curl when you debug, and keep your own wording in the panel.
Stop aborts the request, and so does closing the panel mid-answer. The component sees the aborted signal, keeps whatever text had arrived, and shows no error. On the server, the route passes the request's abort signal to the model call, so generation stops once the host reports the closed connection, and it isn't logged as a provider error.
Test it before you ship
With the docs deployed and your app running on a listed origin, check four things. Each request counts against the rate limit, so space them out.
- Preflight. In your browser's Network panel, the first question sends an
OPTIONSrequest that gets a 204 naming your origin, then thePOST. - Streaming. The answer appears in pieces, not all at once, and its links open pages on the docs site.
- Errors. An invalid body gets a 400 that still names your origin, so the browser lets your app read the status.
- Cancellation. Ask for something long and select Stop. The
POSTshows as canceled, the partial answer stays, and your host's logs show noAssistant provider errorline for it.
For the third check:
curl -i https://docs.acme.example/api/ask \
-H "Origin: https://app.acme.example" \
-H "content-type: application/json" \
-d '{"messages":[]}'Troubleshooting
The browser reports a CORS error
The origin isn't listed exactly, or the change isn't deployed yet. Copy the Origin header from the failed request in the Network panel and replay the preflight with it. If access-control-allow-origin is missing, fix the list and redeploy. http://localhost:5173 and http://127.0.0.1:5173 are different origins.
A 403 says "Cross-site POST form submissions are forbidden"
That's Astro's cross-site check, not Blume's route. The request had no content type, or a form-like one. Send content-type: application/json. curl -d sends a form content type unless you set that header.
The config fails with "ai.assistant.cors only applies to the generated route"
You set both cors and endpoint. Remove endpoint to use Blume's route, or remove cors and handle CORS in your own backend.
The build fails with BLUME_SERVER_FEATURE_REQUIRED
The build is static, and the assistant needs a server. Set deployment to a host adapter from blume/deploy, or drop output: "static" from the one you have.
Every question says the assistant isn't available
The route answered 503. Send it a valid question with curl and read the body: it names the variable to set, such as AI_GATEWAY_API_KEY. Set it in your host's environment and redeploy.
Answers come back empty
A 200 with no text means the provider failed after the response started, usually over a rejected key or a model name it doesn't know. The route logs the cause as Assistant provider error: in your host's function logs.
Source links open pages in your app
The citation rewrite isn't running. Check that every <Markdown> that renders an answer passes urlTransform, and that DOCS_URL includes your docs' base, if they have one.
The request never leaves the browser
If your app sets a Content Security Policy, its connect-src has to include the docs origin, or the browser blocks the request before the preflight. The console names the directive that blocked it.
Next step
Protect the route
The route stays public whatever the CORS list says. Give it a rate limit every server instance shares before product traffic reaches it.
Rate-limit the assistantA step here not working for you? Report a broken step.