Streaming sessions
Follow a session live over SSE or WebSocket, resume from any seq, and write idempotently so a retry never duplicates.
Both channels deliver the same frames. Every frame carries v: 1 and a type t ∈ event | presence | lease | control. On connect the server first replays stored events with seq > after (up to 1000), then sends one lease frame, then switches to live. Duplicate event frames are suppressed by seq, so a message you already have never arrives twice.
| SSE | WebSocket | |
|---|---|---|
| Path | GET /v1/sessions/:id/stream?after=<seq> | GET /v1/sessions/:id/ws?after=<seq> (upgrade) |
| Direction | Down only | Down + up (controls, events, presence) |
| Auth | Authorization header | Authorization header — a key in the query string is rejected |
| Device | x-codai-device header, required | Header, or ?device=<uuid>&platform=<p> |
| Link share | x-codai-share-token or ?share= | Same |
| Frame shape | event: <t> + data: {"v":1,"seq"?,…data} — data flattened | { "v":1, "t", "seq"?, "data": {…} } — data nested |
| Keep-alive | : ping <ms> comment every 15 s of silence | — |
| Max connection | 30 min, then the server closes | — |
SSE
curl -N "https://ai.codai.ro/v1/sessions/5b3e…/stream?after=6" \
-H "Authorization: Bearer $CODAI_API_KEY" \
-H "x-codai-device: 9a0b…" -H "x-codai-device-platform: web"event: event
data: {"v":1,"seq":7,"kind":"tool_call","ts":1757757852512,"sender_device_id":"1c2f…","turn_id":"t-1","client_event_id":"c9f2…","payload":{"name":"open_app"}}
event: lease
data: {"v":1,"holder_device_id":"1c2f…","expires_at":"2026-09-13T10:04:40.000Z"}
event: presence
data: {"v":1,"device_id":"9a0b…","user_id":"…","role":"editor","executor":false,"last_seen":1757757852600,"driving":false,"online":true}
event: control
data: {"v":1,"id":"ctl-8d1a…","kind":"steer","text":"Use the second result.","turn_id":"t-1","ask_id":null,"from_device_id":"9a0b…","seq":8,"applied":false}
event: control
data: {"v":1,"id":"ctl-8d1a…","kind":"steer","seq":8,"applied":true}
: ping 1757757870000A lease frame with "holder_device_id": null, "expires_at": null means the lease is free. A control frame arrives twice per control: once when accepted (applied: false, full body) and once when the executor marks it applied (applied: true, short body).
The browser's EventSource cannot set Authorization or x-codai-device, so use a fetch-based SSE reader (fetch + ReadableStream) or the WebSocket. Never pass the API key in the URL — the protocol forbids it and the WebSocket enforces it.
A minimal fetch-based reader that tracks seq and reconnects:
async function follow(sessionId: string, apiKey: string, device: string, onFrame: (t: string, d: any) => void) {
let after = 0;
for (;;) {
const res = await fetch(`https://ai.codai.ro/v1/sessions/${sessionId}/stream?after=${after}`, {
headers: { Authorization: `Bearer ${apiKey}`, 'x-codai-device': device, 'x-codai-device-platform': 'web' },
});
const reader = res.body!.pipeThrough(new TextDecoderStream()).getReader();
let buf = '';
for (;;) {
const { value, done } = await reader.read();
if (done) break; // 30-min cut or network drop → reconnect with the last seq
buf += value;
let i: number;
while ((i = buf.indexOf('\n\n')) >= 0) {
const block = buf.slice(0, i);
buf = buf.slice(i + 2);
const t = /^event: (.+)$/m.exec(block)?.[1];
const raw = /^data: (.+)$/m.exec(block)?.[1];
if (!t || !raw) continue; // ": ping" comments
const d = JSON.parse(raw);
if (t === 'event') {
if (d.seq <= after) continue;
after = d.seq;
}
onFrame(t, d);
}
}
}
}WebSocket
const ws = new WebSocket(`wss://ai.codai.ro/v1/sessions/${id}/ws?after=${after}&device=${device}&platform=web`, {
headers: { Authorization: `Bearer ${apiKey}` }, // Node `ws`; browsers cannot set headers on WebSocket
});Close codes: 4401 missing/invalid key (or a key in the query string) · 4403 not a member · 4404 session not found · 4400 other client error (e.g. missing device) · 1011 internal.
Down frames are the SSE objects with data nested:
{ "v": 1, "t": "event", "seq": 7, "data": { "kind": "tool_call", "ts": 1757757852512, "sender_device_id": "1c2f…", "turn_id": "t-1", "client_event_id": "c9f2…", "payload": { "name": "open_app" } } }
{ "v": 1, "t": "lease", "data": { "holder_device_id": "1c2f…", "expires_at": "2026-09-13T10:04:40.000Z" } }
{ "v": 1, "t": "presence", "data": { "device_id": "9a0b…", "role": "viewer", "executor": false, "driving": false, "last_seen": 1757757852600, "online": true } }
{ "v": 1, "t": "control", "data": { "id": "ctl-8d1a…", "kind": "send", "text": "…", "from_device_id": "9a0b…", "seq": 9, "applied": false } }Up messages (text frames, JSON):
t | Body | Min role | Ack |
|---|---|---|---|
control | { "t": "control", "id", "kind", "text"?, "turn_id"?, "ask_id"? } | editor | { "v": 1, "t": "ack", "ref": <id>, "accepted": true, "seq", "duplicate" } |
events | { "t": "events", "events": IncomingEvent[1..200], "expected_last_seq"? } | lease holder | { "v": 1, "t": "ack", "last_seq", "events": [{ "client_event_id", "seq" }] } |
presence | { "t": "presence", "driving": bool } | viewer | A presence frame to everyone |
An error on an up message is answered with { "v": 1, "t": "error", "ref"?: <control id>, "error": { "message", "type", "code", "details" } } and the socket stays open. Binary frames are ignored.
Resuming
There is exactly one resume mechanism: ?after=<seq>. Frames carry no id: line and Last-Event-ID is not read. Keep the highest seq you have processed; on any disconnect — network, the 30-minute SSE cut, a redeploy — reconnect with it and the server replays what you missed.
Two things to get right:
- Replay is capped at 1000 events. If
last_seq − aftermay exceed that (a long session you have not watched for a while), pageGET /v1/sessions/:id/events?after=&limit=1000first until you reachlast_seq, then open the stream from there. Otherwise the events between the end of the replay and the first live frame are never delivered. - Only
eventframes have aseq.lease,presenceandcontrolframes are state, not log entries — take the latest, do not try to order them against events. (Thecontrolframe does carry theseqof its echoedcontrolevent, which is how you correlate the two.)
Writing idempotently
Retries are safe when every write carries its own key:
| Write | Your key | On retry |
|---|---|---|
POST …/events / WS events | client_event_id per event | The duplicate is not stored; you get its original seq in events[] and it is excluded from accepted. Send one on every event — an event without it is never deduplicated. The key is scoped to your device: the same client_event_id from another device is a different event. |
POST …/control / WS control | control id | 200 { accepted: true, seq: <original>, duplicate: true } — no new event, nothing re-echoed. |
POST …/dispatch | control_id | 200 duplicate: true, no push re-sent. Always supply your own control_id when you may retry. |
POST …/lease | your device | Re-claiming refreshes expires_at. |
To guard against writing on a stale view, add expected_last_seq to an events batch: if the session moved on, the whole batch is refused with 409 seq_conflict and nothing is written — reload, then retry.
The executor loop
The reference executor (the phone) behaves as follows; other executors should match.
- On start or dispatch wake, for each open session:
POST …/lease; on success start the agent run. - Write every trace line locally and batch it to
POST …/events(or WSevents) — flush every ≤ 200 ms or 20 events, always withclient_event_id. - Drain
GET …/controls?applied=false(optionally&target=me), then subscribe for livecontrolframes. - Apply each control; when done,
POST …/control/:cid/applied. Skip controls whosetarget_device_idis set and is not your own id. - Heartbeat
PUT …/leaseevery 10 s. On409: stop executing, become a viewer, show "driven by<device>". - If a turn needs the screen, emit
{ "kind": "screen_wait", "payload": { "position" } }so viewers understand why the session is idle.