Event protocol
The agent and the UI both speak the same event language. Different channels, same payloads.
The tag
Section titled “The tag”The agent embeds events in its prose using a self-closing tag:
<agent-event type="EVENT_TYPE" data='JSON_PAYLOAD' />Three rules:
- Self-closing. Always
<agent-event ... />, never<agent-event>...</agent-event>. - Single-quoted JSON in the
dataattribute, so JSON’s double quotes don’t need escaping. - End-of-message. Events appear after the prose, on their own line(s).
Multiple events allowed in one turn. Each is parsed and applied in order.
How a turn flows
Section titled “How a turn flows”model output: "Got it. Sending Sarah a link now. <agent-event type="record_customer_contact" data='{"mobile":"07700 900 123"}' /> <agent-event type="generate_customer_link" data='{}' />"
parser: display = "Got it. Sending Sarah a link now." events = [ { type: "record_customer_contact", data: { mobile: "07700 900 123" } }, { type: "generate_customer_link", data: {} } ]
server: applyEvents(sessionId, events) // mutates case state reconcileSession(sessionId) // closes any gate-rule gaps appendCustomerMessage(sessionId, display) // transcript bubbleDirect events
Section titled “Direct events”The UI dispatches events directly when buttons are clicked or forms submitted. These don’t go through the model:
// Customer page submitting eligibilityfetch("/api/chat/customer", { method: "POST", body: JSON.stringify({ sessionId, seed: liveSeed, directEvents: [{ type: "record_eligibility", data: { isOver18: true, isUkResident: true, isHomeowner: true, isEmployed: true }, }], }),});The chat route applies direct events before calling the model. So by the time the model sees the conversation, the state is already correct, and the model just narrates what just happened.
This is the protocol-aligned pattern: regulated moments use deterministic UI events, conversational moments use the model.
Parser semantics
Section titled “Parser semantics”lib/parser.ts provides:
AgentStreamParser, streaming parser. Callfeed(chunk)as text arrives, get back{ displayText, events }for what’s been fully parsed in that chunk. Buffers partial tags across chunks.stripEventTags(text), convenience function for non-streaming uses. Returns{ display, events }for a complete model output.
JSON extraction uses brace-balancing rather than regex, so apostrophes in values (names like “John’s”) don’t break parsing. There’s a real bug in the codebase history where a regex-based parser truncated JSON at the first apostrophe; the brace-walker is the fix.
The parser tolerates three JSON dialects in fallback order: literal JSON, then JSON with \" escape sequences flattened, then JSON with smart quotes (' ' " ") folded back to ASCII. If all three fail the event is dropped silently. This is deliberate: a malformed event tag should never crash a turn. The agent will retry on the next turn, and reconciliation backstops anything load-bearing.
Streaming vs non-streaming today
Section titled “Streaming vs non-streaming today”The parser is streaming-capable but the chat route currently calls generateText, which returns a complete string. stripEventTags(text) is the convenience function used; it feeds the full string through AgentStreamParser once and flushes. Switching to a streaming chat route in future is a one-call swap (streamText plus per-chunk parser.feed), with no protocol change.
Streaming-friendly
Section titled “Streaming-friendly”The parser handles tags split across stream chunks. Example:
chunk 1: "Hi Sarah. Got it. <agent-event type=\"reco"chunk 2: "rd_customer_contact\" data='{\"mobile\":\"07700 900 123"chunk 3: "\"}' />"The parser holds back the partial tag in chunk 1, sees the rest in chunks 2 and 3, and only emits the parsed event when the closing /> lands. The visible text is yielded as it arrives so the customer sees it streaming.
Event taxonomy
Section titled “Event taxonomy”Full type definition in lib/types.ts (type AgentEvent). Brief summary:
| Group | Events |
|---|---|
| Project & contact | record_project_facts, record_customer_contact, generate_customer_link, installer_handoff_complete |
| Customer journey | record_eligibility, record_provisional_quote, record_email_preference, record_personal_facts, record_financial_facts, record_vulnerability_indicators |
| Disclosures and consents | present_disclosure, acknowledge_disclosure, capture_consent |
| Application | submit_application, accept_counter_offer, refuse_counter_offer, select_offer |
| Lifecycle | withdraw, case_outcome, case_complete |
See Event taxonomy for full payload shapes.
The “events with no prose” case
Section titled “The “events with no prose” case”A turn can legitimately consist of nothing but events:
"<agent-event type=\"acknowledge_disclosure\" data='{\"id\":\"service_status\"}' />"The events apply. But the display text is empty. The customer route detects this (trimmedDisplay.length === 0), applies the events, and does not persist a transcript turn. The model history builder also filters any prior empty assistant message out before calling Anthropic, then collapses any consecutive same-role turns the filter creates. Both checks together mean the API never sees an empty content block (which it rejects) and never sees two consecutive same-role messages (which it also rejects).
Why structured events not function calls
Section titled “Why structured events not function calls”The Anthropic API offers a tool-use feature. The demo doesn’t use it. Why:
- Streaming UX. Tool calls do stream as deltas, but the customer-facing surface needs the prose and the events interleaved on the same turn. Inline tags let the prose stream as text while events are extracted from the same buffer; tool-use forces a separate tool block per call and adds round-trip handling for tool results that aren’t needed here (the events are fire-and-forget state mutations, not function calls expecting a return value).
- Simpler parser. A streaming tag parser is ~150 lines. A robust tool-use handler with retries, partial responses, and recovery is more.
- Matches the protocol. The agentic credit broking protocol describes structured events as the model’s surface, not function calls.
Both approaches would work. Inline events fit better with the streaming, prose-first agent UX.
Idempotency
Section titled “Idempotency”Most events in the protocol are idempotent in the case-state sense:
present_disclosurededuplicates onid: a duplicate event for an already-presented disclosure is dropped (the firstpresentedAtwins).acknowledge_disclosureonly setsacknowledgedAtif the field is currently undefined. A second ack on the same disclosure is a no-op.capture_consentreplaces any prior consent of the sameconsentType, so a duplicate is harmless and the latest grant/refuse wins.record_*_factsevents shallow-merge into their target object. Re-applying the same payload produces the same case state.case_outcomeis set-once; a secondcase_outcomeevent on a case that already has one is a no-op.
Events that are not idempotent in the same sense:
submit_applicationalways setsstatus = "submitting", but the route handler running the waterfall is what matters. The route checks forcaseState.waterfallalready being set before re-running.accept_counter_offeris only meaningful whenawaitingCounterDecisionis true; on a second call it is a no-op because the flag has been cleared.refuse_counter_offerclears the flag; a second call is a no-op for the same reason.
In practice, the model occasionally re-emits an event from a previous turn after a retry. The deduplication above means this is invisible to the customer. A production deployment that streams events to a durable audit store should still attach a per-event UUID at emit time and dedupe on it at the store boundary, because the audit log cares about exact event counts even when the case-state effect is the same.
Versioning
Section titled “Versioning”The event taxonomy is unversioned today. Adding a new event type is non-breaking. Removing one would break older replays; in practice, old types stay in the union as // legacy markers (see request_decision, select_offer for the parallel-offers flow). When a payload shape needs to change incompatibly, prefer adding a sibling event type with a new name and a migration path rather than changing an existing payload.
Adding new events
Section titled “Adding new events”When you need a new event type:
- Add to the
AgentEventdiscriminated union inlib/types.ts. - Add a
caseto theapplyEventsswitch inlib/server-store.ts. - If it should trigger cross-cutting follow-ons, add a rule to
lib/reconcile.ts. - If the model needs to emit it, document it in
lib/system-prompts.tsunder “Available events”. - If the UI emits it directly, dispatch it from the relevant button or form handler.
The discipline: events are how state mutates. Add events when you need a new state mutation; otherwise use existing ones.