SSE Protocol
Server-Sent Events streaming protocol reference
SSE Protocol
Read this before building any client that talks to a DorkOS session over HTTP.
DorkOS delivers all real-time state over two durable 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, a bit like ordering a coffee and getting a text when it's ready.
Both streams are also WebSocket endpoints, at the same URLs. Server-Sent Events is the documented contract for integrations and is not going anywhere — everything on this page keeps working. The DorkOS app itself connects over WebSocket, for a browser-specific reason that probably does not apply to you: a browser allows only about six connections per origin, and an SSE stream holds one open for as long as it lasts, so a few browser windows would use them all up. A script, a server, or a CLI has no such limit. Use SSE unless you are writing something that runs in a browser tab and needs several streams at once — in which case see WebSocket streams below.
When config.auth.enabled is on, every /api/* route (including these SSE endpoints) requires a
Better Auth session cookie or a per-user API key. With auth disabled, the endpoints are reachable
to anyone who can reach the port, which is fine on localhost and a real risk on a public bind.
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" }X-Client-Id is optional. Omit it and the server generates a UUID for you, but then it can't tell your own reconnects apart from a fresh client, so you lose track of which queued messages are yours across a page reload. Send a stable id if your integration reconnects.
The endpoint is trigger-only, and it never waits: it validates the request, gives the message to the session, and responds straight away whether or not the turn can start yet.
202 Accepted
{
"sessionId": "canonical-session-id",
"messageId": "0d5e...",
"outcome": { "messageId": "0d5e...", "requested": "queue", "applied": "queue" },
"queuePosition": 1
}A 202 means the server has your message, not that a turn is running. If the session was idle, the turn started. If a turn was already in flight, your message joined the session's queue and runs when that turn ends. queuePosition tells you which happened: 1 means nothing was ahead of it. If you previously treated the 202 as "the turn has begun" — starting a timer, or polling from that moment — take the real start from the turn_start event instead.
messageId is the server's id for this message, and it is what the queue is keyed by. Keep it if you want to reword or withdraw the message before it runs. outcome reports what the server did with it: requested is what you asked for, applied is what happened, and a degradedBecause appears only when the two differ. Ask for queue and it is always what you get — the server owns the queue, so every runtime can do it. Ask for steer or stage and you may get queue back with a reason: unsupported (this runtime cannot do it at all), not-steerable (this runtime can steer, but the session you asked about cannot take a message mid-task), turn-owned-elsewhere (a turn is running and a different client started it, so this one may not write into it), pending-interaction (the agent is waiting on a person), or session-idle (nothing was running, so your message just ran — the one reason not worth reporting to anybody).
When a stage does come back as stage, degradedBecause tells you where the words went. Absent, they reached the agent's own record right away. unsupported means this runtime keeps no such record, and not-stageable means it keeps one but the session you asked about is not holding its agent open; in both of those the words are held and handed to the agent with its next reply instead. All three emit a context_staged event, so a stage that landed is never silent.
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.
This endpoint no longer returns 409 SESSION_LOCKED. A session that was already working used
to refuse a second message; now it accepts it and queues it. Retry-on-409 logic against this route
is dead code, and the retry it stood in for is the server's job now. See The Message
Queue.
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, the messages waiting to run (queuedMessages, empty when nothing is), and acursor(the highest event sequence number the snapshot reflects). Those five fields are the whole of a session's live state: hydrate from them and you are exactly as correct as a client that has been connected all along. - 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":[],"queuedMessages":[],"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
terminalReason tells you why the turn stopped so you can render the right thing:
completed: normal finish. Show the final message as-is.aborted_tools,aborted_streaming: the user or client interrupted the turn. Show it as stopped, not failed.max_turns,blocking_limit,rapid_refill_breaker,prompt_too_long: a limit was hit. Tell the user what limit and let them retry or adjust (shorter prompt, new session).image_error,model_error: something went wrong upstream. Show a retry affordance.stop_hook_prevented,hook_stopped: a configured hook intervened. Surface the hook's message if you have one; otherwise say a hook stopped the turn.tool_deferred: the turn ended waiting on a background task. Treat the session as still in progress.error: an unhandled runtime or SDK crash mid-stream. Show a generic failure state and offer retry.- Absent, or a value not listed here: treat it like
completedbut don't assume; new SDK versions can add reasons the server passes through unchanged.
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. The countdown (timeoutMs)
is how long the card counts down for; past it the prompt parks, the pending interaction is
reported with parked: true and no timeoutMs, and remainingMs runs on to the park ceiling.
The tool call is auto-denied only when that runs out.
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.
The Message Queue
Every session has one queue, and the server holds it. A message sent while a turn is running lands there and runs when that turn ends; a message sent to an idle session runs immediately and never appears. The queue belongs to the session rather than to whoever filled it, so a message queued in the app is visible to your integration, and one queued by your integration is visible in every open browser window. It survives a dropped connection, a reconnect, and a server restart.
You learn the queue two ways, and they carry the same list:
queuedMessageson the snapshot, so a cold connect starts out knowing what is waiting.queue_updateon the stream, on every change: a message accepted, dispatched, reworded, moved, or removed, by any client.
queue_update always carries the whole queue, head first, never a diff. Replace your copy with it rather than merging into one. That is deliberate: a client that missed an update is fully corrected by the next one, so there is no incremental state to get wrong. Like every other event it carries a seq, so apply it under the same watermark rule as the rest — an event at or below the highest seq you have already applied is a duplicate, and dropping it is correct.
outcome is present when the update was caused by a message being accepted, and it is the same receipt the 202 returned. Use it to tell "my own send landed" from "someone else changed the queue".
Every entry in queue and in queuedMessages is a QueuedMessage:
Prop
Type
Three routes read and change it. Each answers with the queue as it now stands, so you do not need to wait for the stream to catch up:
| Route | Body | Answers |
|---|---|---|
GET /api/sessions/:id/queue | — | { queue } |
PATCH /api/sessions/:id/queue/:messageId | { content?, move? } — move is { before: id } or { after: id } | { message, queue } |
DELETE /api/sessions/:id/queue/:messageId | — | { queue } |
GET is redundant with the snapshot on purpose: opening a stream to read one small list is a poor trade for a script or a debugging session.
A move names another message to land before or after, never an index. An index means something different to every client the moment anyone else edits the queue, and two clients editing one queue is exactly what this surface is for. A PATCH that names neither content nor move is refused as 400; an id this queue does not hold — including a move anchored to one — is 404 QUEUED_MESSAGE_NOT_FOUND.
Removing a message removes its pending run with it, so a message you withdraw does not surface later.
Two clients editing the same message race, and the last write wins. Both the PATCH response and
the queue_update event carry the whole queue, so the losing client is corrected rather than
merged — an edit in flight can be overwritten by someone else's. Render from the last queue you
received rather than from what you sent.
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 |
interaction_pending | { sessionId: string, cwd: string, interaction: PendingInteraction, roomId?: string, roomAuthorId?: string } | An agent anywhere on this machine has stopped and is waiting on a person. Addressed — see below |
interaction_resolved | { sessionId: string, interactionId: string, outcome, resolvedAt: string } | That prompt is over: answered, cancelled, or timed out. Drop the card |
approval_pending | { approval: PendingApproval } | Something needs your permission before it runs. Addressed — see below |
notification | { notification: Notification } | An inbox row appeared, or an existing one changed. Addressed — see below |
notification_read | { ids: string[], all: boolean, readAt: string, unreadCount: number } | Notifications were marked read somewhere. Addressed — see below |
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.
Four events go only to whoever they are about
Most events on this stream are written to every connection. Four are not:
interaction_pending, approval_pending, notification and notification_read. Each carries
something about what your agents are doing or what is waiting on you, so each goes only to a
connection that could act on it.
interaction_pending carries what an agent is waiting for — the tool it wants to run, the
command or path it would run it against, and the session's working directory.
approval_pending carries the capability being asked for and a sentence describing what would
happen. The two notification events carry the titles and bodies of your inbox.
In practice that means one thing: a connection presenting an X-DorkOS-Agent header never
receives it. The same connection also stops receiving activity on a session_status frame
whose lifecycle is blocked — that field names the tool and the command a session is parked
on, which is the same detail by another route. The lifecycle itself is still sent, so a
reader still knows the session is waiting; it just does not learn what for. And on a session's
own stream (GET /api/sessions/:id/events) the snapshot's pendingInteractions comes back
empty and the approval_required / question_prompt / elicitation_prompt frames are not
sent. An agent could never answer a prompt (POST /api/sessions/:id/approve refuses
every one of them), so it no longer sees them either. A connection holding one of your per-user
API keys still receives them, because it can already read the same detail on that session's own
stream. GET /api/sessions/pending-interactions, which is what a page reads on load, filters
the same way — and answers 200 with an empty list rather than an error, so a caller cannot
tell "you may not see this" from "there is nothing waiting".
interaction_resolved is deliberately NOT filtered. It carries a session id, an interaction id
and an outcome, and nothing about what was asked; a client that never received the pending has
nothing to close. approval_resolved and approval_grant_changed are unfiltered for the same
reason: one says a card is over, the other says only that the permission list moved.
GET /api/notifications filters the same way the events do, and answers 200 with an empty
list rather than an error. Marking something read is refused outright instead, because "I have
seen this" is a claim only a person looking at a screen can make.
What you get when you connect
Shortly after connecting you receive one session_status event for every session the server
currently sees as working, waiting on a person, or stopped with an error. Sessions that are idle
send nothing, because there is nothing to say about them. Treat these as ordinary session_status
events — same shape, same handler, no special ordering to wait for.
This is what lets a page that opened five minutes late still know that a session is stuck: the transitions themselves happened while you were not connected, so only the state they left behind can tell you. The next real transition replaces whatever these told you.
They are read from what the server is holding in memory right now, so they are a picture of the live fleet rather than of all history: a restart clears it, and Claude Code sessions are also forgotten after about half an hour of quiet. Nothing else expires on a timer today, so a session left waiting on a person — or one whose turn never finished — keeps being announced on every connect until it moves again. Reconnecting is not what clears it.
The DorkOS client uses a single GET /api/events connection for all background state. The older
per-resource SSE endpoints (/api/relay/stream, /api/tunnel/stream) still exist for backward
compatibility, but they're deprecated and log a warning on every connection. Build new
integrations against GET /api/events only.
Session Write Coordination
Several clients may write to one session at the same time. Their messages do not collide, and none of them is turned away: the queue puts them in one order and every client watches that order change on the stream.
Prop
Type
X-Client-Ididentifies you across reconnects and labels the messages you queued (enqueuedBy). It is not a claim on the session and it is never checked for permission.- A write lock still exists, and it is now entirely internal: it is what guarantees only one turn runs on a session at a time. It is bound to the turn rather than to the HTTP request, released when the turn completes or errors, and expires after 5 minutes of inactivity as a backstop against a turn that went dark.
- Because the queue answers the "someone else is writing" case, the lock is no longer an answer any endpoint gives you.
409 SESSION_LOCKEDis gone fromPOST /api/sessions/:id/messages.
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, pending interactions, and queued messages.
Trigger a turn
Send POST /api/sessions/:id/messages. On 202, record the returned canonical sessionId and messageId, and read queuePosition to know whether the turn started now or the message is waiting. Either way its 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/eventsNext Steps
Building Integrations
Build custom clients using the Transport interface or REST API.
Tool Approval
How tool approval flows work and how to configure permission modes.
API Reference
Full interactive API documentation with request and response schemas.
WebSocket streams
Every durable stream on this page — GET /api/events, GET /api/sessions/:id/events, and
GET /api/rooms/:id/events — also answers a WebSocket upgrade at the same URL. Same three
phases, same snapshot, same replay, same ordering guarantees. Only the packaging differs.
Reach for this only if your client runs inside a browser tab and needs several streams open at once. Everywhere else, plain SSE is simpler.
// Same path, ws:// (or wss://) instead of http://
const socket = new WebSocket(
'ws://localhost:4242/api/sessions/' + sessionId + '/events?cwd=' + encodeURIComponent(cwd)
);
socket.onmessage = (message) => {
const frame = JSON.parse(message.data);
if (frame.event === '__heartbeat') return; // liveness only, see below
handle(frame.event, frame.data);
if (frame.id) lastEventId = frame.id; // keep this — it is your resume cursor
};Each message is one JSON text frame carrying the same three things an SSE frame carries:
| Field | Meaning |
|---|---|
event | The event name — what event: carried. |
data | The payload — what data: carried, already parsed. |
id | The resume cursor, <resourceId>-<epoch>-<seq> — what the id: line carried. Present only on frames that occupy a place in the sequence. |
Four differences to know about:
- Resuming uses a query parameter, not a header. A browser
WebSockettakes a URL and nothing else, so send the lastidyou saw as?resume=<id>rather than asLast-Event-ID. Send the whole id, not just the trailing number — the middle part identifies the server process, and it is what stops a cursor from a restarted server replaying the wrong events. - Heartbeats are frames. SSE keeps a connection alive with a comment your parser ignores. A
WebSocket has no comment, and its built-in ping is invisible to browser JavaScript, so the server
sends a frame named
__heartbeatinstead. Treat any frame as proof the stream is alive, and skip that one before handling it. - Refusals arrive as a close code, not an HTTP status. A browser cannot see the status of a
handshake that failed, so the server accepts the connection and immediately closes it with
4000 + status—4401for "sign in",4403for "not allowed",4404for "no such thing". Readevent.codeinoncloseand subtract 4000. This is how these three streams refuse a request they understood — a missing credential, an unknown room, a bad id — for browser and non-browser clients alike: the handshake completes and the socket closes immediately, so do not wait for an HTTP status that will not come. Two cases still answer at the handshake instead: an unexpected server-side error is a real500, and a path no endpoint claims is closed with no response at all. - Reconnecting is yours to do. A WebSocket does not retry on its own the way
EventSourcedoes. Reconnect with backoff, and pass?resume=so you pick up exactly where you left off.
If you are behind a reverse proxy or a dev server, make sure it forwards WebSocket upgrades as well as ordinary requests — most need to be told explicitly, and the symptom is a stream that connects to nothing while every other request works fine.