WebSocket API
Applies to: ChengOS v0.1.0+ | Last verified: 2026-08-12 | Source:
crates/cheng-api/src/ws/protocol.rs,crates/cheng-api/src/ws/manager.rs,crates/cheng-api/src/rest/routes.rs
Workflow runs stream over WebSocket. Polling GET /executions/:id tells you the outcome; the
socket tells you what is happening — per-node progress, token-by-token model output, agent
iterations, and approval requests.
Connecting
| Endpoint | Notes |
|---|---|
/ws/executions | Top level |
/api/v1/ws/executions | Same handler, versioned alias |
Authenticate one of three ways:
Authorization: Bearer <jwt> # preferred
/ws/executions?token=<jwt> # when headers are unavailable
/ws/executions?ticket=chtk_… # channel clients
The ticket form exists so chk_ channel keys never appear in a URL. Tickets are minted through
the authenticated channel REST surface and consumed once on upgrade — a ticket is not a
reusable credential.
Protocol version
WS_PROTOCOL_VERSION = 1
Bumped on any incompatible change to event shapes. External clients — the cheng CLI does
this — compare it via the CLI session API before relying on event semantics. Check it rather than
assuming; that is what the constant is for.
Message envelope
Every message is wrapped:
{
"messageId": "uuid",
"timestamp": "2026-08-12T10:30:00Z",
"type": "NODE_COMPLETE",
"...": "type-specific fields"
}
messageId exists for deduplication — after a reconnect you may legitimately see a message
twice, so key on it rather than assuming exactly-once delivery. The payload is flattened into the
envelope, so type sits alongside messageId rather than nested.
Message types are SCREAMING_SNAKE_CASE; field names are camelCase.
Subscribing
Connecting does not itself deliver events; you subscribe to a scope. Three granularities:
{ "type": "SUBSCRIBE", "scope": { "type": "workspace", "workspaceId": "…" } }
{ "type": "SUBSCRIBE", "scope": { "type": "conversation", "conversationId": "…" } }
{ "type": "SUBSCRIBE", "scope": { "type": "execution", "executionId": "…" } }
The server confirms with SUBSCRIBED. UNSUBSCRIBE takes the same scope shape and is confirmed
with UNSUBSCRIBED.
Choose the narrowest scope that covers your needs: execution for watching one run, conversation
for a chat UI where each turn is a new execution, workspace for a dashboard. Subscribing to a
workspace to watch one run means filtering out everyone else's traffic client-side.
Keep the connection alive with PING, answered by PONG. Both accept optional data and
timestamp.
Execution events
| Type | Meaning |
|---|---|
EXECUTION_START | Run began — carries executionId, workflowId, startedAt, inputs |
EXECUTION_PROGRESS | Overall progress |
EXECUTION_STATE_CHANGED | State machine transition |
NODE_COMPLETE | One node succeeded, with its outputs |
NODE_FAILED | One node failed, with the error |
EXECUTION_COMPLETE | Terminal: success |
EXECUTION_FAILED | Terminal: failure |
EXECUTION_CANCELLED | Terminal: cancelled |
ERROR | Protocol or server error |
Streaming node output
| Type | Meaning |
|---|---|
NODE_STREAM_START | A node began streaming |
NODE_STREAM_CHUNK | An incremental piece of output |
NODE_STREAM_COMPLETE | Streaming finished |
NODE_STREAM_FAILED | Streaming failed partway |
Accumulate chunks in a buffer separate from finished messages; a *_FAILED after several chunks
means you must discard or mark the partial text rather than presenting it as a complete answer.
Agent events
Agent runs are far more talkative, which is what makes them debuggable:
| Type | Meaning |
|---|---|
AGENT_ITERATION_COMPLETED | One reason-act-observe cycle finished |
AGENT_TURN_STARTED / AGENT_TURN_COMPLETED | Conversational turn boundaries |
TOOL_EXECUTION_STARTED / _PROGRESS / _COMPLETED / _FAILED | Tool call lifecycle |
CONTEXT_WINDOW_STATUS_UPDATED | Context usage — watch for compaction |
AGENT_PAUSED_FOR_REVIEW | Waiting on a human |
Approval events
APPROVAL_REQUESTED announces a decision is needed; APPROVAL_RESOLVED announces it was made.
Answer through REST (POST /executions/:id/approve), not over the socket — the WebSocket is a
notification channel here, not a control channel.
Conversation and message events
CONVERSATION_CREATED, MESSAGE_CREATED, WORKFLOW_TRIGGERED, and MESSAGE_COMPLETED are what
a chat UI consumes: a user message triggers a workflow, output streams, and the turn completes.
Container and loop events
CONTAINER_STARTED / CONTAINER_COMPLETED bracket subflows and batch subflows;
LOOP_ITERATION_STARTED / _COMPLETED / _FAILED report each iteration, so a 100-item batch is
observable per item rather than as one opaque node.
Document and table events
SHADOW_EVENT and TABLE_EVENT carry live document and spreadsheet changes, which is how a
collaborative editor stays in sync while a workflow writes to it.
Client-local workspace events
These implement the reverse direction — the server asking the client to do something, which is
how cheng back lets a browser session operate on files on your machine:
| Type | Direction |
|---|---|
CLIENT_WORKSPACE_READY | Client announces its local workspace |
WORKSPACE_TOOL_REQUEST | Server asks the client to run a tool |
WORKSPACE_TOOL_RESULT | Client returns the result |
WORKSPACE_TOOL_CANCEL | Server cancels a pending request |
CLIENT_WORKSPACE_DISCONNECTED | The local workspace went away |
WORKSPACE_TOOL_RESULT errors carry a stable LOCAL_EXECUTOR_* code alongside the message, so a
client can branch on the code rather than parsing prose.
Reconnection
Implement backoff with jitter, restore subscriptions after reconnecting, and deduplicate on
messageId. Distinguish a deliberate close from a network failure by close code — retrying a
clean close is wasted work. The bundled @chengflow/chat WebSocket client already does all of
this; see the SDK quickstart.
If the stream goes silent, reconcile against GET /executions/:id rather than waiting
indefinitely. The CLI uses a 30-second silence threshold for exactly this.
Next steps
- REST API overview — the request side.
- Authentication — tokens, keys, and tickets.
- Execution model — what generates these events.