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, …)
@standardagents/client, @standardagents/react,
@standardagents/vue) decode all of these; the sections below are the wire
contract for other clients.
Connecting
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=:
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" } }.
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
string
WebSocket ticket from
POST /api/auth/websocket-ticket (required when the
runtime requires authentication).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.boolean
true to also receive silent messages. Default false.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.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 (sinceis0).running— an execution is in flight.failed— the last run ended in an error.
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.message_data
A message was created or updated (including status changes such aspending → 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. 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.
message_chunk
A streamed fragment of an assistant message’s visible content. Accumulate bymessage_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.
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.
event
A custom event emitted by application code withThreadState.emit() (tools,
hooks, effects, and thread endpoints), and runtime notifications such as
file_created, file_updated, file_deleted, and effect_changed.
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’stype is the telemetry
kind itself; timestamp is milliseconds since epoch. Telemetry is advisory —
use thread_status for whether the thread is running.
Keepalive
Send the stringstream_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
Reconnection
Reconnect with backoff when the socket closes. On reconnect, mint a new ticket, then reconcile once: the first frame is a freshthread_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).