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

EndpointNotes
/ws/executionsTop level
/api/v1/ws/executionsSame 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

TypeMeaning
EXECUTION_STARTRun began — carries executionId, workflowId, startedAt, inputs
EXECUTION_PROGRESSOverall progress
EXECUTION_STATE_CHANGEDState machine transition
NODE_COMPLETEOne node succeeded, with its outputs
NODE_FAILEDOne node failed, with the error
EXECUTION_COMPLETETerminal: success
EXECUTION_FAILEDTerminal: failure
EXECUTION_CANCELLEDTerminal: cancelled
ERRORProtocol or server error

Streaming node output

TypeMeaning
NODE_STREAM_STARTA node began streaming
NODE_STREAM_CHUNKAn incremental piece of output
NODE_STREAM_COMPLETEStreaming finished
NODE_STREAM_FAILEDStreaming 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:

TypeMeaning
AGENT_ITERATION_COMPLETEDOne reason-act-observe cycle finished
AGENT_TURN_STARTED / AGENT_TURN_COMPLETEDConversational turn boundaries
TOOL_EXECUTION_STARTED / _PROGRESS / _COMPLETED / _FAILEDTool call lifecycle
CONTEXT_WINDOW_STATUS_UPDATEDContext usage — watch for compaction
AGENT_PAUSED_FOR_REVIEWWaiting 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:

TypeDirection
CLIENT_WORKSPACE_READYClient announces its local workspace
WORKSPACE_TOOL_REQUESTServer asks the client to run a tool
WORKSPACE_TOOL_RESULTClient returns the result
WORKSPACE_TOOL_CANCELServer cancels a pending request
CLIENT_WORKSPACE_DISCONNECTEDThe 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