HTTP API
Every endpoint is under /api on the Worker. Responses are JSON unless stated. Request schemas are zod schemas in packages/shared/src/; a body that fails them is a 400.
| Method | Path | Purpose | EMBED_KEY |
|---|---|---|---|
GET | /api/health | Store, models and gateway in use | no |
GET | /api/claims | Paged claim summaries | no |
GET | /api/claims/scenarios | Counts by scenario, and the catalogue | no |
GET | /api/claims/random | A random claim id | no |
GET | /api/claims/:id | One full claim | no |
POST | /api/chat | One assistant turn, streamed as SSE | yes |
GET | /api/voice | WebSocket relay to Gemini Live | yes |
GET | /api/prompts | The prompt and capability catalogues | no |
POST | /api/feedback | Record, replace or withdraw a vote | yes |
GET | /api/feedback | The feedback report | yes |
Conventions
Cross-origin access. Same-origin callers need nothing. A cross-origin caller must be in ALLOWED_ORIGINS; otherwise every endpoint answers 403 before doing any work. Preflights from allowed origins get 204. See Access control.
The key. When EMBED_KEY is set, the gated endpoints need the header x-claimpilot-key: <key>, or ?key=<key> on the voice socket. A missing or wrong key is 401 with {"error": "Not authorised for this assistant."}.
Errors.
| Status | When | Body |
|---|---|---|
400 | A body or query that fails its schema | {"error": "…", "issues": [ … ]} for chat and feedback; {"error": "<zod message>"} elsewhere |
401 | Wrong or missing key | {"error": "Not authorised for this assistant."} |
403 | Origin not allowed | {"error": "Origin <o> may not embed this assistant. Add it to ALLOWED_ORIGINS."} |
404 | Unknown claim, unknown /api path | {"error": "Claim not found"} · {"error": "Not found"} |
500 | Misconfiguration or an unhandled error | {"error": "<message>"} |
Any path outside /api is served from the static assets, falling back to index.html.
GET /api/health
{
"ok": true,
"database": "d1",
"ai": { "configured": true, "model": "gemini-3.5-flash-lite", "liveModel": "gemini-3.8-live", "thinkingLevel": "default", "gateway": "claimpilot-chat" }
}
database is d1, neon or none. gateway is the AI Gateway id, or null when model calls go straight to Google. See Observability.
GET /api/claims
Paged claim summaries, ordered by id.
| Query | Type | Meaning |
|---|---|---|
lob | string | Line of business |
currency | GBP | USD | The claim's currency |
scenario | string | Scenario id |
status | string | Claim status |
q | string | Case-insensitive search on id, title and claimant name |
limit | integer | 1 to 500, default 50 |
offset | integer | Default 0 |
{
"items": [
{
"id": "CP-AUT-260381", "lineOfBusiness": "auto", "scenario": "auto_policy_lapsed_at_loss",
"title": "Priya Natarajan v Halden Logistics Ltd", "status": "under_review", "currency": "GBP",
"claimantName": "Priya Natarajan", "adjusterName": "Amira Hassan", "dateOfLoss": "2026-07-22",
"totalReserve": 27800, "totalPaid": 380, "riskFlagCount": 2
}
],
"total": 1
}
total counts every match, not just the page. With DATABASE_PROVIDER=none the list is always empty.
GET /api/claims/scenarios
{
"loaded": [{ "lineOfBusiness": "auto", "scenario": "auto_policy_lapsed_at_loss", "count": 12 }],
"catalogue": [{ "id": "auto_policy_lapsed_at_loss", "lineOfBusiness": "auto", "title": "…", "brief": "…", "showcases": ["…"], "gotchas": ["…"], "hint": "…" }]
}
loaded is what the store holds; catalogue is every scenario brief.
GET /api/claims/random
| Query | Meaning |
|---|---|
lob | Within one line of business |
currency | Within one currency |
capability | A claim whose demo notes showcase this capability id |
{ "id": "CP-PRO-260214" }
id is null when nothing matches.
GET /api/claims/:id
The full Claim document, parsed (every default applied), or 404.
POST /api/chat
One assistant turn. The response is text/event-stream: one JSON ChatEvent per data: line.
{
"claim": { "id": "CP-AUT-260381", "…": "…" },
"messages": [{ "role": "user", "content": "Was the policy in force at the date of loss?" }],
"capability": "policy_validation",
"locale": "en-GB",
"brand": "clonepilot",
"screenActions": true,
"promptOverrides": { "system.base": "…" }
}
| Field | Required | Meaning |
|---|---|---|
claim | one of claim or claimId | The document itself, as the page shows it |
claimId | one of claim or claimId | A claim to load from the store |
messages | yes | 1 to 60 text turns, user or assistant, each at most 20,000 characters |
capability | no | Run this capability's playbook as a quick action |
promptOverrides | no | Prompt id → text for this turn. Unknown ids and values over 20,000 characters are dropped |
locale | no | en-GB (default) or en-US |
brand | no | claimpilot (default) or clonepilot |
screenActions | no | The caller can perform client_action events. Default false |
data: {"type":"activity","tool":"check_policy_in_force","label":"Checking the policy period and coverages…"}
data: {"type":"tool_result","tool":"check_policy_in_force","summary":"{\"dateOfLoss\":\"2026-07-22\",…}"}
data: {"type":"text_delta","text":"Not safely. The loss on 22/07/2026 falls inside "}
data: {"type":"text_delta","text":"the policy period, but the policy is recorded as lapsed…"}
data: {"type":"done","model":"gemini-3.5-flash-lite","usage":{"inputTokens":5210,"outputTokens":412,"cacheReadTokens":3968},"turnId":"4f6b2c1e-…","prompts":{"fingerprint":"default","overridden":[]}}
Problems after the stream has started arrive as an error event, not an HTTP status. A malformed body is a 400 before any streaming. See The chat pipeline.
curl -N http://localhost:8787/api/chat \
-H 'content-type: application/json' \
-d '{"claimId":"CP-AUT-260381","messages":[{"role":"user","content":"Summarise this claim."}]}'
GET /api/voice
A WebSocket upgrade. A plain GET is 426; no model key is 503.
| Query | Meaning |
|---|---|
locale | en-GB or en-US |
claimId | Optional, older path: load the claim from the store; 404 before the upgrade if it does not exist |
key | The embed key, when one is set |
The first client frame may be {"clientConfig": {"promptOverrides", "claim", "screenActions", "brand"}}. After that, client frames are audio and text for Gemini Live; server frames are Live's own, plus {"chat": ChatEvent} for activity and screen actions and {"error": {"message"}}. See Voice.
GET /api/prompts
{
"prompts": [{ "id": "system.base", "label": "System — identity & working style", "description": "…", "defaultText": "…" }],
"capabilities": [{ "id": "claim_summary", "label": "Summarise claim", "description": "…", "group": "understand", "pinned": true, "quickActionMessage": "…" }],
"groups": [{ "id": "understand", "label": "Understand the claim", "blurb": "Get up to speed fast." }]
}
Read-only: the code defaults. Versions live in the browser.
POST /api/feedback
Record, replace or withdraw the vote on one answer. Body: FeedbackRequest.
{
"turnId": "4f6b2c1e-8a9d-4c3b-9e2f-1d7a5b0c6e93",
"rating": "down",
"reasons": ["missed"],
"note": "It ignored the lapse notice in the diary.",
"claimId": "CP-AUT-260381",
"capability": "policy_validation",
"channel": "text",
"surface": "app",
"locale": "en-GB",
"model": "gemini-3.5-flash-lite",
"promptFingerprint": "default",
"question": "Was the policy in force at the date of loss?",
"answer": "…",
"tools": [{ "tool": "check_policy_in_force", "summary": "…" }]
}
204on success.rating: nullwithdraws the vote.- The same
turnIdagain replaces the earlier vote. reasonsandnoteare kept only for a thumbs down.- An invalid body is
400with{"error": "Invalid feedback", "issues": [ … ]}.
GET /api/feedback
| Query | Meaning |
|---|---|
rating | up or down: filters items only |
capability | A capability id: filters items only |
limit | 1 to 200, default 50 |
{
"totals": { "up": 41, "down": 9 },
"byCapability": [{ "capability": "policy_validation", "up": 12, "down": 3 }],
"byPrompt": [{ "capability": "policy_validation", "promptFingerprint": "default", "promptOverridden": [], "up": 10, "down": 3 }],
"items": [{ "turnId": "…", "rating": "down", "reasons": ["missed"], "…": "…", "createdAt": "2026-09-15T10:11:12Z", "updatedAt": "2026-09-15T10:11:12Z" }]
}
The totals and tallies cover every vote; the filters apply to items. With DATABASE_PROVIDER=none the report is empty and votes go to the Worker log.