> ## Documentation Index
> Fetch the complete documentation index at: https://docs.standardagents.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# WebSocket Stream

> Live thread frames: messages, chunks, run status, telemetry, and custom events

## Overview

The thread message stream pushes JSON frames to the client as the thread
changes. It is the client's only change signal: on every (re)connect the server
first sends the current run status, then each change as it happens, so clients
never poll.

* `thread_status` — authoritative run status: a snapshot on connect, then every change
* `message_data` — a message was created or updated
* `message_chunk` — streamed visible content of an assistant message
* `reasoning_chunk` — streamed model reasoning (opt-in)
* `event` — custom events from tools, hooks, and endpoints (`ThreadState.emit()`)
* telemetry frames — execution progress (`step_started`, `tool_started`, `stopped`, …)

The official SDKs (`@standardagents/client`, `@standardagents/react`,
`@standardagents/vue`) decode all of these; the sections below are the wire
contract for other clients.

## Connecting

```
GET /api/threads/:threadId/stream   (WebSocket upgrade)
```

Browsers cannot set an `Authorization` header on a WebSocket, so authenticated
clients first mint a short-lived single-use ticket for the exact path and pass
it as `?ticket=`:

```typescript theme={null}
const pathname = `/api/threads/${threadId}/stream`;
const { ticket } = await fetch('/api/auth/websocket-ticket', {
  method: 'POST',
  headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({ pathname }),
}).then((r) => r.json());

const ws = new WebSocket(`wss://your-worker.workers.dev${pathname}?ticket=${ticket}`);
ws.onmessage = (event) => {
  if (event.data === 'stream_pong' || event.data === 'pong') return;
  handleFrame(JSON.parse(event.data));
};
```

The `pathname` must be the normalized path of the WebSocket URL, and the
upgrade path must equal it as an exact string. The endpoint returns `400` and
no ticket when `pathname` is missing, is not beneath `/api/`, contains a query
or fragment, has a `.` or `..` segment, an empty segment (`//`), or a trailing
slash, or holds a percent-encoded `.`, `/`, or `\` (`%2E`, `%2F`, `%5C`, in
either letter case).

### Refused upgrades

A refused upgrade returns an HTTP response and does not open the socket. The
body is `{ "error": { "code", "message" } }`.

| Status | Cause |
| - | - |
| `401` | No credential, or a ticket that is unknown, expired, already used, or issued for another path |
| `403` | The caller has no access to the thread |
| `404` | The thread does not exist or was deleted |

The local development server and a deployed project answer the same way.
Browsers hide the status of a failed `WebSocket` handshake, so a browser client
sees only the `error` and `close` events. Request a new ticket and reconnect.

### Query parameters

**ticket** `string`

WebSocket ticket from `POST /api/auth/websocket-ticket` (required when the
runtime requires authentication).

**depth** `number`

Deepest message depth to receive. `0` (default) is the top-level thread;
higher values include subagent / sub-prompt messages. Applies to
`message_data`, `message_chunk`, and `reasoning_chunk` frames alike.

**includeSilent** `boolean`

`true` to also receive silent messages. Default `false`.

**reasoning** `boolean`

Truthy (`1`, `true`) to receive `reasoning_chunk` frames. Default off.

## Server → Client Frames

### thread\_status

The thread's authoritative run status. Sent **immediately after the socket is
accepted** (a snapshot of the current status) and then **on every change**
(state or reason). A reconnecting client therefore always resynchronizes from
the snapshot; no polling is ever needed.

```typescript theme={null}
interface ThreadStatusEvent {
  type: 'thread_status';
  status: ThreadStatus;
}

interface ThreadStatus {
  state: 'idle' | 'running' | 'failed';
  /** When the thread entered this state, microseconds since epoch (like created_at). */
  since: number;
  /** Raw stop reason code of the last transition out of running; opaque. */
  reason?: string | null;
}
```

```json theme={null}
{ "type": "thread_status", "status": { "state": "running", "since": 1727870000000000, "reason": null } }
```

```json theme={null}
{ "type": "thread_status", "status": { "state": "idle", "since": 1727870004210000, "reason": "stop_on_response" } }
```

**status.state** `string`

* `idle` — nothing is running and the last run did not fail (it replied,
  called a stop tool, handed the turn to a human side, was stopped by the
  user, was refused by a hook, or hit a step/turn cap), or the thread has
  never run (`since` is `0`).
* `running` — an execution is in flight.
* `failed` — the last run ended in an error.

**status.reason** `string | null`

The stop reason code of the last transition out of `running`, for clients
that want detail: `stop_on_response`, `stop_tool`, `awaiting_human`,
`hook_blocked`, `max_total_steps`, `max_side_steps`, `max_session_turns`,
`session_stop`, `aborted`, `aborted_by_user`, `step_error`,
`repeated_tool_error`, `tool_execution_error`, `session_fail`,
`fatal_error`, `execution_lost`.
Treat unknown codes as opaque. `null` while running.

> [!TIP]
>
> A run the runtime lost (deploy, eviction, crash) never stays `running`: it
> settles to `failed` with reason `execution_lost`.

### message\_data

A message was created or updated (including status changes such as
`pending` → `completed`). Filtered by the socket's `depth` and `includeSilent`.

`data` is the complete message record: every member that
[`GET /threads/{id}/messages`](https://docs.standardagents.ai/api-reference/rest/get-messages) returns for
that message, with the same value and in the same form. A member with no value
is present as `null`, `attachments` is an array or `null`, and `silent` is a
boolean. Replace the record you hold with the one in the frame. While a message
is still being generated its `status` is `pending` and its `content` may be
incomplete. A message has one `created_at`: its first frame, a read while it
waits for a turn, and every later frame and read carry the same value.

```typescript theme={null}
interface MessageDataEvent {
  type: 'message_data';
  message_id: string;
  /** 0 = top-level thread, 1+ = subagent / sub-prompt. */
  depth: number;
  data: Message;
}
```

```json theme={null}
{
  "type": "message_data",
  "message_id": "4f6c…",
  "depth": 0,
  "data": {
    "id": "4f6c…",
    "role": "assistant",
    "content": "Hello!",
    "name": null,
    "tool_calls": null,
    "tool_call_id": null,
    "tool_status": null,
    "log_id": "9a1d…",
    "created_at": 1727870004200000,
    "silent": false,
    "parent_id": null,
    "depth": 0,
    "status": "completed",
    "reasoning_content": null,
    "reasoning_details": null,
    "prompt": "support_prompt",
    "attachments": null,
    "subagent_id": null
  }
}
```

### message\_chunk

A streamed fragment of an assistant message's visible content. Accumulate by
`message_id`; the authoritative final content arrives as `message_data`.
`depth` is the depth of that message, and the frame is filtered by the socket's
`depth` like `message_data`: a sub-prompt's chunks arrive only on a socket whose
`depth` admits them.

```typescript theme={null}
interface MessageChunkEvent {
  type: 'message_chunk';
  message_id: string;
  depth: number;
  chunk: string;
}
```

> [!TIP]
>
> Append chunks in socket order and keep provisional buffers until their terminal
> `message_data` records arrive. Do not erase already-painted text merely because
> a provider retry or reconnect exposes another `message_id`; retain it as a
> reconciliation alias (or as a separate keyed draft).

### reasoning\_chunk

A streamed fragment of model reasoning. Only sent to sockets connected with
`?reasoning=1`; not persisted, so it cannot be replayed from history. `depth`
is the depth of the message the reasoning accompanies, and the frame is
filtered by the socket's `depth`: a sub-prompt's reasoning arrives only on an
opted-in socket whose `depth` admits that message.

```typescript theme={null}
interface ReasoningChunkEvent {
  type: 'reasoning_chunk';
  message_id: string;
  depth: number;
  chunk: string;
}
```

### event

A custom event emitted by application code with `ThreadState.emit()` (tools,
hooks, effects, and thread endpoints), and runtime notifications such as
`file_created`, `file_updated`, `file_deleted`, and `effect_changed`.

```typescript theme={null}
interface ThreadEvent<T = unknown> {
  type: 'event';
  eventType: string;
  data: T;
  /** Milliseconds since epoch. */
  timestamp: number;
}
```

```json theme={null}
{ "type": "event", "eventType": "order_status", "data": { "orderId": "ORD-12345", "status": "shipped" }, "timestamp": 1727870004300 }
```

`data` is always present. An event emitted with no data, or with `undefined`,
carries `"data": null`.

### Telemetry frames

Execution progress, sent as they happen. Each frame's `type` is the telemetry
kind itself; `timestamp` is milliseconds since epoch. Telemetry is advisory —
use `thread_status` for whether the thread is running.

| `type` | Fields |
| - | - |
| `step_started` | `step`, `side` (`a` \| `b`) |
| `step_completed` | `step`, `stopped` |
| `agent_handoff` | `new_agent_id`, `new_agent_title` |
| `llm_request` | `model`, `attempt` |
| `llm_response` | `tokens`, `latency` |
| `validation_failed` | `error` |
| `fallback_triggered` | `from`, `to` |
| `tool_started` | `tool` |
| `tool_completed` | `tool`, `status` |
| `stopped` | `reason` (human-readable), `side` |
| `stopped_by_user` | — |

```json theme={null}
{ "type": "tool_started", "tool": "search", "timestamp": 1727870002100 }
```

## Keepalive

Send the string `stream_ping` periodically; the runtime answers `stream_pong`
at the edge without waking a hibernating thread. (Older servers answer a plain
`ping` with `pong`.) These heartbeats are protocol keepalives, not state
checks.

## Handling Frames

```typescript theme={null}
function handleFrame(frame: StreamFrame) {
  switch (frame.type) {
    case 'thread_status':
      setWorking(frame.status.state === 'running');
      if (frame.status.state === 'failed') showRunFailed(frame.status.reason);
      break;
    case 'message_data':
      upsertMessage(frame.data);
      break;
    case 'message_chunk':
      appendChunk(frame.message_id, frame.chunk);
      break;
    case 'event':
      handleCustomEvent(frame.eventType, frame.data);
      break;
    default:
      // telemetry frames (step_started, tool_started, stopped, …)
      trackProgress(frame);
  }
}
```

## Reconnection

Reconnect with backoff when the socket closes. On reconnect, mint a new
ticket, then reconcile once: the first frame is a fresh `thread_status`
snapshot, and a single `GET /api/threads/:id/messages` read catches up any
messages missed while disconnected. Do not add timers that re-read thread
state.

A close with code `1000` and reason `Thread deleted` means the thread was
deleted. The connection is closed before the delete request returns, and a new
upgrade for that thread returns `404`, so do not reconnect.

## React and Vue

`useThread()` in `@standardagents/react` and `@standardagents/vue` manages the
socket, reconnects, and exposes `runStatus` (the latest `thread_status`, `null`
until the first frame) alongside `connectionStatus`/`status` (the socket's own
connection state).

```typescript theme={null}
import { useThread } from '@standardagents/react';

function Chat() {
  const { messages, runStatus, sendMessage } = useThread();
  const working = runStatus?.state === 'running';
}
```

See [React Integration](https://docs.standardagents.ai/user-interfaces/react.md) for full documentation.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.