Skip to main content

SSE Events

Streaming endpoints return text/event-stream. This page is the complete list of the public event vocabulary. Anything not on this table is an internal event passed through verbatim — it is not a contract and may change.

Wire format

Each event is two lines plus a blank line:
Three conventions:
  • data is single-line JSON
  • Every payload carries a monotonically increasing event_id starting at 1, usable for dedup and gap detection
  • The type field mirrors the event: line, so parsing only data is sufficient
The stream terminator is:
[DONE] is not JSON. Calling JSON.parse(line.slice(6)) on it throws SyntaxError: Unexpected token D. This is the single most common first-integration failure — check for this line before parsing.

Full event table

Internal event names map to public names through a fixed table. The public name is the contract; the internal name is not — internal renames do not affect you, while public-name changes go through a version process.

Run lifecycle

run.in_progress fires once per turn. Within one run the model may call tools, read results, and reason again — each round is a turn, so receiving multiple run.in_progress events is normal. Do not treat them as duplicate requests.

Output content

For text and reasoning, the content field is renamed from content to delta during mapping. This is deliberate (alignment with the OpenAI Responses API), but it means every consumer must read delta.The safe form is event.delta ?? event.content — accept both names so pass-through events and version skew never silence your output.
Assembling the answer means concatenating every output.text.delta delta in arrival order. Do not reorder by event_id; events already arrive in order and reordering only adds a failure mode.

Tool calls

Tool calls split into several events; the first four come from the internal tool_call event dispatched on its status field:
Unlisted tool_call.<something> names are possible: any status outside the mapping table produces tool_call.${status} verbatim. Your UI needs a fallback renderer for the tool_call.* prefix rather than a hard-coded four-case switch whose default branch does nothing.
tool_call.denied and tool_call.failed are different things:
  • failed = the tool ran and errored (timeout, invalid argument, missing target)
  • denied = the tool never ran; a permission gate stopped it
Collapsing both into “tool error” gives users a Retry button that can never succeed.

Approvals and delegation

approval.required is a blocking event: the stream waits for your response. Ignore it and the run hangs until timeout.

Usage and quota

usage.trial_exhausted needs its own branch. It is not an error — it means “this user’s trial is used up and a purchase is next.”Routing it into a generic error branch shows the user something like “operation refused,” which carries no information and reads as an account fault. IM channels once did exactly this, and every user whose trial ended believed the system was broken. Handle this event by surfacing the purchase entry point.

Consumption example

Boundaries and failure modes

Unrecognised event types are passed through, not dropped. That is deliberate: new internal events do not vanish on your side just because the mapping table lags. The cost is that you must tolerate unknown names — so your default branch should ignore, not throw.

Verify your integration

Minimal check:
You should see:
  1. event: run.created first
  2. Several event: output.text.delta
  3. event: run.completed
  4. data: [DONE] last
If the first event is run.failed, read the code in the payload and look it up in Error Codes.

Endpoint Reference

Which endpoints stream

Error Codes

Reading the code inside a run.failed payload
Verified 2026-08-11. Source: services/core/src/lib/sse-event-mapper.ts (EVENT_MAP 16 entries + TOOL_CALL_STATUS_MAP 4 entries = 20 public event names; that file is the single source of truth for the vocabulary).