AI Assistant API
API specification for the AI assistant chat endpoint.
Showcase implementation boundary
This reference describes the host-owned live implementation in the zudo-doc showcase repository. The @takazudo/zudo-doc package/scaffold does not ship this handler, worker-entry.ts, orAiChatDailySpendCap; its package-owned route returns HTTP 501 when live mode is selected without a host implementation. Downstream projects must supply an equivalent handler and Worker graph.
Endpoint
POST /api/ai-chat
Content-Type: application/json Request Body
interface AiChatRequest {
message: string;
history?: ChatMessage[];
}
interface ChatMessage {
role: "user" | "assistant";
content: string;
}| Field | Type | Required | Description |
|---|---|---|---|
message | string | Yes | The user's current message. Must be non-empty. |
history | ChatMessage[] | No | Previous conversation messages. Malformed history (non-array, more than 50 entries, entry content over 8192 chars, or injection match) is rejected with HTTP 400. |
Success Response (200)
interface AiChatResponse {
response: string;
}The response field contains the assistant's reply as a markdown string.
Example:
// Request
{
"message": "How do I add a new page?",
"history": []
}
// Response
{
"response": "Create an MDX file in `src/content/docs/`:\n\n1. Add frontmatter with `title`\n2. Write your content in MDX\n3. The page appears in the sidebar automatically"
}Error Response (400 / 500)
interface AiChatErrorResponse {
error: string;
}| Status | Condition |
|---|---|
| 400 | Invalid JSON body |
| 400 | message is not a non-empty string |
| 400 | message exceeds 4000 character limit |
| 400 | Message rejected by input screening (prompt injection guard) |
| 400 | history is malformed (see field description) |
| 405 | Request method is not POST or OPTIONS |
| 415 | Content-Type is not application/json |
| 429 | Rate limit exceeded (includes Retry-After header) |
| 500 | Anthropic API call failed |
The endpoint accepts POST (chat) and OPTIONS (CORS preflight) only; every other method returns 405 with { "error": "Method not allowed" }.
CORS
This endpoint uses a per-origin allowlist. When aiChatDemoMode is false, Access-Control-Allow-Origin is echoed back only for request origins listed in the aiChatAllowedOrigins setting — any other origin receives no allow-origin header and is blocked by the browser. (In demo mode, * is always returned for back-compat.) This is intentionally stricter than the Search Worker, which uses wildcard CORS (*) — the AI chat endpoint gates by origin because each call carries a real Anthropic API cost, whereas search is an unmetered, opt-in service. Do not assume the two endpoints share a CORS policy.
CF Env Bindings
| Binding | Kind | Required | Description |
|---|---|---|---|
ANTHROPIC_API_KEY | secret | Yes | Anthropic API key |
DOCS_SITE_URL | var | Yes | Deployed docs URL (used to fetch llms-full.txt) |
RATE_LIMIT | KV namespace | Yes | Stores soft per-IP counters and privacy-safe outcome audit records |
AI_CHAT_DAILY_SPEND_CAP | Durable Object namespace | When aiChatGlobalDailyLimit is a number | Exact paid-call admission; class AiChatDailySpendCap, SQLite migration v1-ai-chat-daily-spend-cap |
RATE_LIMIT_PER_MINUTE | var | No | Max requests per IP per minute (default 10) |
RATE_LIMIT_PER_DAY | var | No | Max requests per IP per day (default 100) |
Settings
The following zudoDoc({...}) fields (set in zfb.config.ts) control endpoint behavior (distinct from the CF env vars above, which are Cloudflare-side runtime configuration).
| Setting | Type | Default | Description |
|---|---|---|---|
aiChatDemoMode | boolean | false (showcase: true) | Short-circuits with a fixed reply; no API key, KV, Durable Object, or provider fetch |
aiChatAllowedOrigins | string[] | [] | CORS origin allowlist (non-demo only). Empty = all cross-origin requests blocked |
aiChatGlobalDailyLimit | number | false | false | Exact Anthropic fetch-admission cap per UTC day; false = no exact cap |
Security
The endpoint includes layered defenses ported from the legacy standalone worker:
Hardened system prompt — XML-tagged context with explicit guardrails prevents the model from leaking configuration or following off-topic instructions. The prompt also instructs the model to treat all prior conversation turns as untrusted client input (see Chat-history trust model below)
Input screening — regex pre-filter rejects common prompt injection patterns before either limiter or the Claude API is called. The handler then runs the per-IP KV guard before returning a validation rejection, so rejected input still consumes approximate per-IP quota while audit writes remain gated behind that guard
Per-IP soft guard — approximate, eventually-consistent limits via
RATE_LIMITKV; fail-closed on reads (KV outage → HTTP 429) whenaiChatDemoModeisfalse; fail-open in demo mode (demo short-circuit is first, so the limiter is never reached in practice)CORS allowlist — when not in demo mode,
Access-Control-Allow-Originis echoed only for origins inaiChatAllowedOrigins; cross-origin requests from unlisted origins are blocked by the browser. Demo mode always sends*for back-compat.Exact paid-call admission — a SQLite
AiChatDailySpendCapobject per UTC date serializes admission immediately before exactly one Anthropic fetch. The namespace isAI_CHAT_DAILY_SPEND_CAP; the Worker migration tag isv1-ai-chat-daily-spend-capwithnew_sqlite_classes = ["AiChatDailySpendCap"]. Denials return 429 until the next UTC day; missing binding/RPC/storage fails closed with 500. Admissions are never refunded after provider or network failure and are not provider-confirmed spend accountingPrivacy-safe audit records —
RATE_LIMITKV stores only timestamp,completed/blockedoutcome, and an optional bounded block-reason enum underaudit:(7-day TTL, fire-and-forget). Prompt/response text and IP/IP-hash fields are never persistedMessage length cap — messages over 4000 characters are rejected before reaching the API
cf-connecting-ipcaveat — per-IP rate limiting uses this header, which is only trustworthy when the Worker is deployed behind Cloudflare's network
Operational Logs
With Wrangler [observability] enabled = true, Workers Logs receives closed-schema objects for exact-cap admitted, denied, and failed_closed outcomes. Each contains utc_day, configured_limit, and only non-sensitive result fields. Separate per_ip_kv events distinguish quota denial from KV failed-closed. Operational events never include prompts, responses, raw IPs, IP hashes, secrets, Durable Object names/IDs, or raw error text.
Chat-history trust model
The history array is client-supplied and stateless — the server keeps no session record, so it cannot verify that an assistant-role turn was actually produced by a previous model response. Each entry is still hardened: a strict user/assistant role whitelist, the entry-count and per-entry length caps above, and a rebuild to { role, content } that strips any smuggled extra fields. user-role turns are injection-screened; assistant-role turns are not (a real assistant reply may legitimately quote injection-shaped text).
Because role is not verifiable, a caller can forge an assistant turn containing hostile instructions and bypass user-turn screening. This residual risk is accepted by design: the chat is a documentation assistant with a low blast radius, and the system prompt instructs the model to treat every prior turn as untrusted input that cannot override its rules. A robust fix (server-issued signed history) would require provisioning a secret and changing the client/server payload contract, which is not warranted for this feature. See issue #2036 for the full decision record.
Documentation Context
The endpoint fetches llms-full.txt (generated by the llms.txt integration) from DOCS_SITE_URL and caches it in memory for the CF Workers isolate lifespan (best-effort, ~1 hour). The content is included in the system prompt as <documentation> XML context.