Skip to main content
Server-sent events, resumable. Two streams: Both need sessions:read.
Each frame is the SSE event: name plus a JSON data::
internal_type travels alongside type so that when you ask us about something, you and our logs are naming the same thing.

If you stop reading

A stream you hold open but do not read backs up. Past about a megabyte of unread frames the transient ones are dropped — deltas and progress, the decoration this page tells you to treat as decoration — and the persisted ones keep coming, so nothing a cursor resumes from is lost. Past eight megabytes the connection is closed. Reconnect with your last seq and you get everything back. That is what the cursor is for, and it is why closing is safe.

Reconnecting

The stream sends its own retry: hint and you should honour it. It is deliberately longer than the time it takes us to notice a connection has gone: a slot is not freed the instant your client walks away, and reconnecting inside that window at your ceiling would be refused for a stream you had already closed. That refusal, if you meet it, is rate_limit_exceeded with Retry-After. It is not a request to close anything.

Resuming

An id: line is written only for a persisted event. Reconnect with Last-Event-ID: <seq> (or ?last_event_id=<seq>) and you get everything persisted after that point, then live frames. Sending no cursor at all is different from sending 0. No cursor is live frames only — which is what “subscribe first, then send” needs. 0 is everything, because zero is a real cursor: it is the one your code starts with, and a first connection that asks for everything after nothing should get everything.
Transient events — text deltas, command output, progress — stream live and carry seq: null. They do not advance the cursor and are not replayed. So a reconnecting caller misses deltas but not facts: agent.text is the token-by-token stream and is transient, while agent.message is its settled form and is persisted. Build on the persisted events; treat the transient ones as decoration. A cursor always names a row that exists, which is what makes resuming safe rather than approximate.

When there is more to replay than one connection will carry

A replay is walked to the end, however many pages that takes. Past 50,000 events in a single reconnect the stream stops replaying and says so, in a frame of its own, before live frames begin:
Reconnect with that from_seq and you get the next stretch. You will not see this frame in ordinary use — it takes an integration that has been away for a very long time — and it exists so that being away that long is something you are told about rather than a gap in your records you would have no way to notice.

What is inside payload

The vocabulary below tells you which events exist. This tells you what to read out of one — which is the part that decides whether your handler works. Two pairs are worth reading before you write any of it, because they are the ones people guess wrong:
agent.tool_call carries toolName/args; tool.required carries name/arguments. The two are neighbours and spell the same idea differently, because one is the sandbox’s vocabulary and the other is yours. Reaching for name on the first is the single commonest mistake against this API, and it fails quietly: the field is absent, your renderer draws an empty object, and nothing anywhere says why. A real payload for every event type is in GET /v1/openapi.json, under components.schemas.EventStream.properties.payload.examples, keyed by event name. That document needs no token, and a guard in this repository fails the build if any published event is missing from it. The rest, briefly:

Items share this vocabulary, under another name

GET /v1/sessions/{id}/items is the durable record of the same things. Each item carries detail, and detail.event is the event name from the table above — that field is the join between the two models. It is also the only way to tell the two halves of tool_call apart:
There is no tool_result item type. A call and its result are both tool_call, so code written from the type enum alone renders every tool call twice. Branch on detail.event.

The vocabulary

An allowlist. Gobare’s internal event vocabulary has fifty-odd members and grows with the Console’s needs; publishing all of it would make every rendering detail a promise we could not withdraw. turn.ended is deliberately neutral. The run finished; whether it succeeded is a property of the turn resource, which the same event names. Calling it turn.completed would be a guess made at the wrong moment.

Operational notes

  • A comment frame (: ping) every 15 seconds keeps the connection open. Ignore it.
  • retry: 2000 is sent on open; honour it rather than reconnecting instantly.
  • Concurrent streams are capped per token and per organization. See limits.md.
  • The organization-wide stream re-checks ownership per event, so a session created after you connected still appears on it.

Polling instead

Streaming is not required. GET /v1/sessions/{id}/turns and GET /v1/sessions/{id}/items are the same facts, and a poll every few seconds is a perfectly reasonable integration — it just costs more requests, and requests are rate limited.

mcp.unavailable

An MCP server you declared would not connect and was skipped. Carries server and reason. Not an error: the turn continues without that server’s tools, which is what required: false asks for. See tools.md if you would rather it failed.

Next