Skip to content

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=:

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 includes { "error": { "code", "message" }, "requestId" }.

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

ticketstring

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

depthnumber

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.

includeSilentboolean

true to also receive silent messages. Default false.

reasoningboolean

Any non-empty value except 0 or false enables 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.

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;
}
{ "type": "thread_status", "status": { "state": "running", "since": 1727870000000000, "reason": null } }
{ "type": "thread_status", "status": { "state": "idle", "since": 1727870004210000, "reason": "stop_on_response" } }
status.statestring
  • 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.reasonstring | 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, message_hook_error, fatal_error, execution_lost. Treat unknown codes as opaque. null while running.

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 returns for that message, with the same value and in the same form. Nullable record fields use null. Optional metadata and child projections can be absent. attachments is an array or null. 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.

interface MessageDataEvent {
  type: 'message_data';
  message_id: string;
  /** 0 = top-level thread, 1+ = subagent / sub-prompt. */
  depth: number;
  data: Message;
}
{
  "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.

interface MessageChunkEvent {
  type: 'message_chunk';
  message_id: string;
  depth: number;
  chunk: string;
}

reasoning_chunk

A streamed fragment of model reasoning. Only sent to sockets connected with ?reasoning=1. The chunks are transient. The provider's completed reasoning is persisted as reasoning_content and reasoning_details on the assistant message. Those fields are included in message reads and message_data frames regardless of the reasoning opt-in. 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.

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.

interface ThreadEvent<T = unknown> {
  type: 'event';
  eventType: string;
  data: T;
  /** Milliseconds since epoch. */
  timestamp: number;
}
{ "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
{ "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

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

import { useThread } from '@standardagents/react';

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

See React Integration for full documentation.