SSE Protocol
Server-Sent Events streaming protocol reference
SSE Protocol
DorkOS delivers all real-time state over two durable Server-Sent Events (SSE) streams: a per-session event stream (GET /api/sessions/:id/events) that carries everything about one session, and a global stream (GET /api/events) that carries session-list changes and system events. Sending a message does not stream anything back — it triggers a turn whose output arrives on the session event stream.
Triggering a Turn
Send a message to start a turn:
POST /api/sessions/:id/messages
Content-Type: application/json
X-Client-Id: your-client-uuid
{ "content": "Hello, Claude", "cwd": "/path/to/project" }The endpoint is trigger-only. It validates the request, acquires the session write-lock, starts the turn server-side, and responds immediately:
202 Accepted
{ "sessionId": "canonical-session-id" }For a brand-new session the returned sessionId is the canonical id assigned by the runtime during the turn — it may differ from the id you supplied. Use it for all subsequent requests. The turn's tokens are delivered solely on GET /api/sessions/:id/events; if you are not already subscribed, open the stream before (or right after) the POST.
If another client holds the write lock, the server returns 409 with { code: "SESSION_LOCKED", lockedBy, lockedAt }. See Session Write Coordination.
Session Event Stream
Subscribe to a session's durable event stream:
GET /api/sessions/:id/eventsThe stream has three phases:
- Snapshot. On a cold connect the server emits a single
snapshotevent carrying the completed message history (messages), the in-progress turn (inProgressTurn, ornullwhen idle), the server-heldstatus, anypendingInteractionsawaiting your response, and acursor— the highest event sequence number the snapshot reflects. - Replay. On reconnect, the browser (or your SSE client) echoes the last received
id:back asLast-Event-ID. The server skips the snapshot and replays only the events you missed, gap-free. If the cursor can no longer be served (e.g., the server restarted), the stream falls back to a fresh snapshot — you never silently miss events. - Live. Events stream as the turn produces them. Every event carries a per-session monotonic
seq, and every SSE frame carries anid:line of the form<sessionId>-<epoch>-<seq>for resumption.
event: snapshot
data: {"messages":[...],"inProgressTurn":null,"status":{...},"pendingInteractions":[],"cursor":42}
id: abc123-1760000000000-43
event: turn_start
data: {"seq":43,"type":"turn_start","userMessage":"Hello, Claude"}
id: abc123-1760000000000-44
event: text_delta
data: {"seq":44,"type":"text_delta","text":"Hello! How can I help?"}
id: abc123-1760000000000-45
event: turn_end
data: {"seq":45,"type":"turn_end","terminalReason":"completed"}This one stream is also the cross-client sync mechanism: every client subscribed to the same session sees the same snapshot, replay, and live events — including turns triggered by other clients or by the CLI. There is no separate sync endpoint and nothing to enable.
Session Event Types
All events share { seq: number, type: string }. Payloads below list the type-specific fields.
Turn Events
Prop
Type
Text Events
Prop
Type
Tool Events
Prop
Type
Interactive Events
Interactive events carry server-authoritative countdown fields — startedAt (ms since epoch) and remainingMs — so a reconnecting client resumes the timeout where it left off instead of resetting it. The same interactions also appear in the snapshot's pendingInteractions, so a freshly connected client always recovers prompts it has not answered.
Prop
Type
Status & Progress Events
Prop
Type
Responding to Interactive Events
When approval_required is received, approve or deny before the turn continues:
# Approve
POST /api/sessions/:id/approve
Content-Type: application/json
{ "toolCallId": "tc_123" }
# Deny
POST /api/sessions/:id/deny
Content-Type: application/json
{ "toolCallId": "tc_123" }Failing to respond to an approval_required event blocks the agent until the server-side timeout
(remainingMs) elapses and the tool call is auto-denied.
Both /approve, /deny, and /submit-answers may return 409 with { code: "INTERACTION_ALREADY_RESOLVED" } if the SDK resolved the interaction before the request arrived. Treat this as success.
When question_prompt is received, submit the user's answer:
POST /api/sessions/:id/submit-answers
Content-Type: application/json
{ "toolCallId": "tc_123", "answers": { "0": "option_a" } }When elicitation_prompt is received, submit the completed form:
POST /api/sessions/:id/submit-elicitation
Content-Type: application/json
{ "elicitationId": "eli_123", "action": "submit", "content": { "api_key": "sk-..." } }To cancel (agent treats as user declining):
POST /api/sessions/:id/submit-elicitation
Content-Type: application/json
{ "elicitationId": "eli_123", "action": "cancel" }Elicitation has a server-side timeout (remainingMs from the event). If you do not respond within
that window, the agent receives an empty result and continues or fails gracefully.
Global Events Stream
For the session list, relay activity, tunnel status, and extension events, subscribe to the unified global stream:
GET /api/eventsThis single persistent connection multiplexes all background events, distinguished by the SSE event: field:
| Event | Payload | Description |
|---|---|---|
session_upserted | { session: Session } | A session was created or its metadata changed |
session_removed | { sessionId: string } | A session was deleted |
session_status | { sessionId: string, cwd?: string, retiredSessionId?: string, status } | A session's status projection changed (lifecycle, tokens, cost). retiredSessionId announces a first-turn rekey — drop state held under the old id |
relay_* | varies | Relay adapter activity, message delivery |
tunnel_* | varies | Tunnel start/stop, URL changes |
ext:{id}:* | varies | Extension-emitted events (namespaced per extension ID) |
These session-list events keep sidebars and dashboards live without polling GET /api/sessions.
The DorkOS client uses a single GET /api/events connection for all background state, replacing
the older per-resource SSE endpoints (/api/relay/stream, /api/tunnel/stream, etc.). Those
endpoints no longer exist. External clients should use GET /api/events.
Session Write Coordination
POST /api/sessions/:id/messages prevents concurrent writes via client IDs.
Prop
Type
- If another client holds the write lock, the server returns
409with{ error: "Session locked", code: "SESSION_LOCKED", lockedBy, lockedAt } - The lock is bound to the turn: it is released when the turn completes or errors, not when the HTTP response ends
- Locks auto-expire after 5 minutes as a backstop
Connection Lifecycle
Open the session event stream
Connect to GET /api/sessions/:id/events. Process the snapshot event to hydrate your UI — history, in-progress turn, status, and pending interactions.
Trigger a turn
Send POST /api/sessions/:id/messages. On 202, record the returned canonical sessionId. The turn's events arrive on the stream you already hold.
Process live events
Render text_delta, tool_call, and friends as they arrive. Respond to approval_required / question_prompt / elicitation_prompt via the appropriate endpoint.
Reconnect seamlessly
On disconnect, reconnect with the Last-Event-ID header (browsers' EventSource does this automatically). The server replays exactly what you missed, or sends a fresh snapshot if it cannot.
Subscribe to the global stream
For the live session list and system events, open the unified stream:
GET /api/events