Skip to content

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> resumes
WSS /runs/{run_id}/stream WebSocket; the first frame authenticates

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

Terminal window
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": "…"}}

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
1000Normal close after run.end.
4401Authentication failed, or the first frame did not arrive within five seconds.
4404Run not found or out of scope — deliberately indistinguishable.
4409Protocol 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.

Both transports take after_seq. Keep the last contiguous sequence number you processed, and reconnect with it:

Terminal window
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.

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.

  • Event catalogue — every frame type and its payload keys.
  • SDKs — both clients implement the resumption rules above for you.