Skip to content

Event protocol

The agent and the UI both speak the same event language. Different channels, same payloads.

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 data attribute, 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.

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 bubble

The UI dispatches events directly when buttons are clicked or forms submitted. These don’t go through the model:

// Customer page submitting eligibility
fetch("/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.

lib/parser.ts provides:

  • AgentStreamParser, streaming parser. Call feed(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.

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.

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.

Full type definition in lib/types.ts (type AgentEvent). Brief summary:

GroupEvents
Project & contactrecord_project_facts, record_customer_contact, generate_customer_link, installer_handoff_complete
Customer journeyrecord_eligibility, record_provisional_quote, record_email_preference, record_personal_facts, record_financial_facts, record_vulnerability_indicators
Disclosures and consentspresent_disclosure, acknowledge_disclosure, capture_consent
Applicationsubmit_application, accept_counter_offer, refuse_counter_offer, select_offer
Lifecyclewithdraw, case_outcome, case_complete

See Event taxonomy for full payload shapes.

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).

See Empty-turn protection.

The Anthropic API offers a tool-use feature. The demo doesn’t use it. Why:

  1. 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).
  2. Simpler parser. A streaming tag parser is ~150 lines. A robust tool-use handler with retries, partial responses, and recovery is more.
  3. 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.

Most events in the protocol are idempotent in the case-state sense:

  • present_disclosure deduplicates on id: a duplicate event for an already-presented disclosure is dropped (the first presentedAt wins).
  • acknowledge_disclosure only sets acknowledgedAt if the field is currently undefined. A second ack on the same disclosure is a no-op.
  • capture_consent replaces any prior consent of the same consentType, so a duplicate is harmless and the latest grant/refuse wins.
  • record_*_facts events shallow-merge into their target object. Re-applying the same payload produces the same case state.
  • case_outcome is set-once; a second case_outcome event on a case that already has one is a no-op.

Events that are not idempotent in the same sense:

  • submit_application always sets status = "submitting", but the route handler running the waterfall is what matters. The route checks for caseState.waterfall already being set before re-running.
  • accept_counter_offer is only meaningful when awaitingCounterDecision is true; on a second call it is a no-op because the flag has been cleared.
  • refuse_counter_offer clears 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.

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.

When you need a new event type:

  1. Add to the AgentEvent discriminated union in lib/types.ts.
  2. Add a case to the applyEvents switch in lib/server-store.ts.
  3. If it should trigger cross-cutting follow-ons, add a rule to lib/reconcile.ts.
  4. If the model needs to emit it, document it in lib/system-prompts.ts under “Available events”.
  5. 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.