Streaming a run
Two transports carry byte-identical event bodies, so a client can switch between them without changing its parser:
GET /runs/{run_id}/stream Server-Sent Events; ?after_seq=<n> resumesWSS /runs/{run_id}/stream WebSocket; the first frame authenticatesServer-sent events exist because they traverse proxies that break WebSockets, and because a one-way stream is all most clients need. Reach for the WebSocket when you need the socket for something else too.
Server-sent events
Section titled “Server-sent events”curl -N "https://your-deployment.example.com/runs/0190a2f4-…/stream?after_seq=0" \ -H "X-Api-Key: $COBRE_KEY"Every frame is a JSON object with the same envelope:
{"protocol": 2, "seq": 41, "run_id": "0190a2f4-…", "ts": "…", "type": "text.delta", "segment_id": "…", "data": {"text": "…"}}WebSocket
Section titled “WebSocket”Connect to the same path over wss://, then send the handshake as the first frame, within
five seconds:
{"protocol": 2, "auth": {"stream_token": "…"}}Mint the token with POST /runs/{run_id}/stream-token. It authenticates that socket, for that
run, and nothing else — see Authentication.
Close codes:
| Code | |
|---|---|
1000 | Normal close after run.end. |
4401 | Authentication failed, or the first frame did not arrive within five seconds. |
4404 | Run not found or out of scope — deliberately indistinguishable. |
4409 | Protocol version unsupported. |
Six guarantees, and what ignoring each one costs
Section titled “Six guarantees, and what ignoring each one costs”1. protocol is in the handshake and in every frame. Refuse a version you do not
understand instead of mis-parsing it.
2. seq is contiguous per run. A gap means frames were missed. Reconnect with after_seq
set to the last contiguous value, not the highest one you saw — this is the guarantee most
often implemented backwards, and the cost is a silent hole in the middle of the output.
3. segment.start precedes any content of that segment. A client can always attribute a
content frame to something it has already been told about; there is never a text.delta for a
segment you have not seen announced.
4. Usage and cost ride on segment.end and run.end. There is no separate usage event —
do not wait for one.
5. run.end is terminal for the attempt, and nothing follows it; you may close on it. It
is not emitted when a failure has a retry scheduled, because more frames are coming.
6. text.delta is the only channel for output text. Accumulate from it and nothing else.
The prior generation of this protocol also emitted a full-text event per model call; clients
that read both rendered every message twice, and clients that read one lost either the
streaming or the authoritative value.
Resuming
Section titled “Resuming”Both transports take after_seq. Keep the last contiguous sequence number you processed,
and reconnect with it:
curl -N "https://your-deployment.example.com/runs/0190a2f4-…/stream?after_seq=41" \ -H "X-Api-Key: $COBRE_KEY"The recorded journal is also readable without a socket at all —
GET /runs/{run_id}/events, same frames, same after_seq. A client that has fallen far
behind can catch up over HTTP and then open a stream.
Frames
Section titled “Frames”The frame types are a closed set — adding one is a protocol change and bumps the version.
Adding a field to a type’s data is additive, which is why the payloads are mappings: read
an unknown key rather than refusing the frame.
The full table, with the data keys of each type and the error-frame vocabulary, is the
event catalogue.
text.delta and thinking.delta are coalesced before delivery, so you see fewer, larger
frames than the executor produced. This is why you accumulate rather than index.
Where to next
Section titled “Where to next”- Event catalogue — every frame type and its payload keys.
- SDKs — both clients implement the resumption rules above for you.