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 changemessage_data— a message was created or updatedmessage_chunk— streamed visible content of an assistant messagereasoning_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
ticketstringWebSocket ticket from POST /api/auth/websocket-ticket (required when the
runtime requires authentication).
depthnumberDeepest 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.
includeSilentbooleantrue to also receive silent messages. Default false.
reasoningbooleanAny 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.statestringidle— 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 (sinceis0).running— an execution is in flight.failed— the last run ended in an error.
status.reasonstring | nullThe 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.