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

```text
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

```rust
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:

```json
{
  "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:

```json
{ "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](/docs/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](/docs/api-rest-overview) — the request side.
- [Authentication](/docs/api-authentication) — tokens, keys, and tickets.
- [Execution model](/docs/exec-model) — what generates these events.
