Chat protocol
The wire protocol between the browser and the Worker is defined once, as zod schemas, in packages/shared/src/chat.ts and packages/shared/src/feedback.ts. Both sides import the same definitions.
ChatRequest
The body of POST /api/chat.
const ChatRequest = z.object({
claim: Claim.optional(),
claimId: z.string().optional(),
messages: z.array(ChatMessage).min(1).max(60),
capability: z.string().optional(),
promptOverrides: z.record(z.string(), z.string()).optional(),
locale: Locale.optional(), // 'en-GB' | 'en-US'
brand: Brand.optional(), // 'claimpilot' | 'clonepilot'
screenActions: z.boolean().optional(),
}).refine((r) => Boolean(r.claim ?? r.claimId), { message: 'Provide either claim or claimId' });
const ChatMessage = z.object({
role: z.enum(['user', 'assistant']),
content: z.string().max(20_000),
});
claimorclaimId, at least one.claimis how the demo and every host work: the page sends the document it is showing.claimIdloads from the store; the Prompt Lab uses it.messagesis the visible conversation, text only. Tool round-trips never appear here; they live inside one request.capabilitymakes the last user message a quick action: the system prompt carries that capability's playbook.promptOverridesare the browser's active Prompt Lab versions, plus any per-run edits. The server drops unknown ids and over-long values.screenActionssays the caller can carry outclient_actionevents. Without it, the model is never offeredhighlight_panel.
Events
ChatEvent is a discriminated union on type. The Worker writes one per SSE data: line, in the order they happen.
type | Fields | Meaning |
|---|---|---|
text_delta | text | A chunk of the answer, to append |
activity | tool, label | A tool started. label is the present-tense chip: Reading the policy… |
tool_result | tool, summary | A tool finished. summary is its result, truncated to 400 characters, for the lookups trace |
client_action | action | Something for the browser to do on the screen |
done | usage?, model?, turnId?, prompts? | The turn is complete |
error | message | The turn failed; no done follows |
done
{
type: 'done',
usage?: { inputTokens: number; outputTokens: number; cacheReadTokens: number }, // summed over the turn's rounds
model?: string,
turnId?: string, // identifies this answer for feedback
prompts?: { fingerprint: string; overridden: string[] }, // 'default', or a 12-hex hash of the overrides that applied
}
The fields are optional in the schema so an older Worker remains readable; the current Worker always sends all four. An answer without a turnId cannot be rated.
Ordering
A turn with one tool round looks like this:
activity → tool_result → (client_action…) → [next round] → text_delta… → done
Activity for every call in a round comes before the next round's text. The browser inserts each activity chip before the streaming answer, so the answer reads as the conclusion.
ClientAction
const ClientAction = z.discriminatedUnion('kind', [
z.object({ kind: z.literal('highlight_panel'), panel: z.string() }),
z.object({ kind: z.literal('navigate'), path: z.string() }),
]);
highlight_panelscrolls to, pulses and outlines[data-panel="<panel>"]in the page.panelis one ofMATTER_PANELS:home,schedule,documents,finance,involvements,proceedings,strategy,finance_resolution,mi,system_checks,key_facts,schedule_table,proceedings_table,notes.navigateis defined for later use. The demo passes its router'snavigate; an embedded host passes nothing, so it is ignored there. No tool emits it today.
FeedbackRequest
The body of POST /api/feedback.
| Field | Type | Notes |
|---|---|---|
turnId | string, 8 to 64 | From the done event; minted by the browser for voice answers |
rating | up | down | null | null withdraws |
reasons | FeedbackReason[] | Default []. Kept only for down |
note | string ≤ 2,000 | Kept only for down |
claimId | string ≤ 100 | |
capability | string ≤ 64 | |
channel | text | voice | required |
surface | app | embed | required |
locale, brand | as in ChatRequest | |
model | string ≤ 100 | |
promptFingerprint | string ≤ 32 | |
promptOverridden | string[] ≤ 50 | The prompt ids behind the fingerprint |
question | string ≤ 20,000 | Omitted for embed votes without feedback-content |
answer | string ≤ 40,000 | Likewise |
tools | { tool, summary }[] ≤ 40 | Likewise |
FeedbackReason is one of incorrect, missed, off_target, unclear, unsafe, other. See Answer feedback.
The embed's DOM event
<claimpilot-assistant> dispatches claimpilot-feedback (bubbling, composed) with the full FeedbackRequest as event.detail, including the question and answer whatever is stored.