# SSE Protocol
Source: https://dorkos.ai/docs/integrations/sse-protocol

Server-Sent Events streaming protocol reference



# SSE Protocol [#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 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, a bit like ordering a coffee and getting a text when it's ready.

<Callout type="info">
  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.
</Callout>

## Triggering a Turn [#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 precise write-lock ownership tracking across a page reload. Send a stable id if your integration reconnects.

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-write-coordination).

## Session Event Stream [#session-event-stream]

Subscribe to a session's durable event stream:

```
GET /api/sessions/:id/events
```

The stream has three phases:

1. **Snapshot.** On a cold connect the server emits a single `snapshot` event carrying the completed message history (`messages`), the in-progress turn (`inProgressTurn`, or `null` when idle), the server-held `status`, any `pendingInteractions` awaiting your response, and a `cursor` (the highest event sequence number the snapshot reflects).
2. **Replay.** On reconnect, the browser (or your SSE client) echoes the last received `id:` back as `Last-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.
3. **Live.** Events stream as the turn produces them. Every event carries a per-session monotonic `seq`, and every SSE frame carries an `id:` 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 [#session-event-types]

All events share `{ seq: number, type: string }`. Payloads below list the type-specific fields.

### Turn Events [#turn-events]

<TypeTable
  type="{
  turn_start: {
    type: '{ userMessage?: string }',
    description:
      'An assistant turn began. Carries the triggering user message when the turn was DorkOS-triggered.',
  },
  turn_end: {
    type: &#x22;{ terminalReason?: 'completed' | 'aborted_tools' | 'aborted_streaming' | 'max_turns' | 'blocking_limit' | 'rapid_refill_breaker' | 'prompt_too_long' | 'image_error' | 'model_error' | 'stop_hook_prevented' | 'hook_stopped' | 'tool_deferred' | 'error' | string }&#x22;,
    description: 'The assistant turn finished.',
  },
}"
/>

`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 `completed` but don't assume; new SDK versions can add reasons the server passes through unchanged.

### Text Events [#text-events]

<TypeTable
  type="{
  text_delta: {
    type: '{ text: string }',
    description: 'Incremental text chunk for the assistant message',
  },
  thinking_delta: {
    type: '{ text: string }',
    description: 'Incremental extended thinking content (when extended thinking is enabled)',
  },
}"
/>

### Tool Events [#tool-events]

<TypeTable
  type="{
  tool_call: {
    type: &#x22;{ toolCallId: string, toolName: string, input?: string, status: 'pending' | 'running' | 'complete' | 'error' }&#x22;,
    description: 'A tool invocation began or changed status',
  },
  tool_result: {
    type: '{ toolCallId: string, toolName: string, result?: string, status: ... }',
    description: 'Tool execution result',
  },
  tool_progress: {
    type: '{ toolCallId: string, content: string }',
    description: 'Incremental live output from a running tool (e.g., Bash stdout)',
  },
}"
/>

### Interactive Events [#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.

<TypeTable
  type="{
  approval_required: {
    type: '{ id: string, toolName: string, input: string, startedAt: number, remainingMs: number, hasSuggestions: boolean, ... }',
    description: 'Tool needs user approval before executing',
  },
  question_prompt: {
    type: '{ id: string, questions: QuestionItem[], startedAt: number, remainingMs: number }',
    description: 'Claude is asking the user a question with options',
  },
  elicitation_prompt: {
    type: '{ id: string, serverName: string, message: string, requestedSchema?: object, startedAt: number, remainingMs: number, ... }',
    description:
      'Agent is requesting structured form input via the MCP elicitation protocol (e.g., OAuth credentials, API keys)',
  },
  interaction_resolved: {
    type: &#x22;{ id: string, resolution?: 'approved' | 'denied' | 'answered' | 'cancelled' }&#x22;,
    description:
      'A pending interaction was resolved, either by an operator on any client or without one (timeout, superseding steer). Remove the prompt card.',
  },
}"
/>

### Status & Progress Events [#status--progress-events]

<TypeTable
  type="{
  status_change: {
    type: '{ status: Partial<SessionStatus> }',
    description:
      'Partial status update: token usage, cost, model, permission mode, lifecycle phase. Merge field-wise into the held status.',
  },
  todo_update: {
    type: &#x22;{ action: 'create' | 'update' | 'snapshot', task: TaskItem, tasks?: TaskItem[] }&#x22;,
    description: 'Todo-list update',
  },
  subagent_update: {
    type: '{ taskId: string, status: BackgroundTaskStatus, description?: string, toolUses?: number, summary?: string }',
    description: 'Background subagent lifecycle update',
  },
  hook_update: {
    type: '{ hookId: string, status: HookStatus, hookName?: string, stdout?: string, stderr?: string, exitCode?: number }',
    description: 'Hook lifecycle update, merge updates field-wise by hookId',
  },
  memory_recall: {
    type: &#x22;{ mode: 'select' | 'synthesize', memories: MemoryEntry[] }&#x22;,
    description: &#x22;Memories surfaced into the turn by the SDK's memory supervisor&#x22;,
  },
}"
/>

## Responding to Interactive Events [#responding-to-interactive-events]

<Tabs items="[&#x22;Tool Approval&#x22;, &#x22;Question Response&#x22;, &#x22;Elicitation&#x22;]">
  <Tab value="Tool Approval">
    When `approval_required` is received, approve or deny before the turn continues:

    ```bash
    # 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" }
    ```

    <Callout type="warn">
      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.
    </Callout>

    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.
  </Tab>

  <Tab value="Question Response">
    When `question_prompt` is received, submit the user's answer:

    ```bash
    POST /api/sessions/:id/submit-answers
    Content-Type: application/json
    { "toolCallId": "tc_123", "answers": { "0": "option_a" } }
    ```
  </Tab>

  <Tab value="Elicitation">
    When `elicitation_prompt` is received, submit the completed form:

    ```bash
    POST /api/sessions/:id/submit-elicitation
    Content-Type: application/json
    { "interactionId": "eli_123", "action": "accept", "content": { "api_key": "sk-..." } }
    ```

    `action` is one of `accept` (submit `content`), `decline` (user said no, no content), or `cancel` (user dismissed the prompt):

    ```bash
    POST /api/sessions/:id/submit-elicitation
    Content-Type: application/json
    { "interactionId": "eli_123", "action": "cancel" }
    ```

    <Callout type="warn">
      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.
    </Callout>
  </Tab>
</Tabs>

## Global Events Stream [#global-events-stream]

For the session list, relay activity, tunnel status, and extension events, subscribe to the unified global stream:

```
GET /api/events
```

This 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`.

<Callout type="info">
  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.
</Callout>

## Session Write Coordination [#session-write-coordination]

`POST /api/sessions/:id/messages` prevents concurrent writes via client IDs.

<TypeTable
  type="{
  'X-Client-Id': {
    type: 'string',
    description: 'Client identifier sent with each message request',
  },
}"
/>

* If another client holds the write lock, the server returns `409` with `{ 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 [#connection-lifecycle]

<Steps>
  <Step>
    ### Open the session event stream [#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.
  </Step>

  <Step>
    ### Trigger a turn [#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.
  </Step>

  <Step>
    ### Process live events [#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.
  </Step>

  <Step>
    ### Reconnect seamlessly [#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.
  </Step>

  <Step>
    ### Subscribe to the global stream [#subscribe-to-the-global-stream]

    For the live session list and system events, open the unified stream:

    ```
    GET /api/events
    ```
  </Step>
</Steps>

## Next Steps [#next-steps]

<Cards>
  <Card title="Building Integrations" href="/docs/integrations/building-integrations">
    Build custom clients using the Transport interface or REST API.
  </Card>

  <Card title="Tool Approval" href="/docs/guides/tool-approval">
    How tool approval flows work and how to configure permission modes.
  </Card>

  <Card title="API Reference" href="/docs/api">
    Full interactive API documentation with request and response schemas.
  </Card>
</Cards>
