workspace:{id}, session:{chatId}, …); the server validates each subscription against database membership and fans state changes out to the subscribers. Most dashboard state arrives on the workspace channel, while a few high-fanout streams (chat, mission detail, files) use narrower channels.
Connecting requires a short-lived JWT from
GET /api/v1/ws-token (itself session- or CLI-authenticated). The token is sent as the first message after the connection opens, not a URL query parameter — a ?token= would leak into proxy/access logs, browser history, and Referer headers. It is validated only at that point; the connection stays authenticated afterward.Connection
Authentication Flow
- Obtain a short-lived JWT token:
GET /api/v1/ws-token(requires session or CLI auth) - Open the WebSocket connection (no token in the URL)
- Send
{"type":"auth","token":"<jwt>"}as the first message; the server validates it using the same JWT validator as the REST API and closes the connection if it’s missing, invalid, or the underlying session was revoked - After that, the token is no longer needed — the connection remains authenticated
golang.org/x/net/websocket (not gorilla/websocket). Authentication is token-based. Inbound frames are capped at 64 KiB.
Message Format
All messages (client-to-server and server-to-client) are JSON.Client Messages
Server Messages
Client Message Types
subscribe
Subscribe to a channel for real-time events. Channel access is validated against workspace/crew membership in the database.unsubscribe
ping
Keep-alive ping. The server also sends periodic pings every 30 seconds.send_message
Send a chat message to an agent session.cancel_message
Cancel an in-progress agent chat response.Error frames
The server answers a refused or undeliverable client frame with a top-levelerror message (not a chat_event). The reason appears under both message
and error; what tells a client where to put it is the channel:
- Addressed (
"channel": "session:chat_abc123") — the error is about that conversation: a denied subscribe, a denied or malformedsend_message, a chat handler that is unavailable. Asend_messagerefusal is addressed to the session in its payload, whether or not the client setchannelon the frame it sent. - Unaddressed (
"channel": "") — a connection-level fault that names no conversation: a frame the server could not parse, or a message type it does not route. Clients must not attribute these to a chat.
chats row appears on the first send),
and the web client re-subscribes once it does; it renders an error frame only
when one terminates work it has in flight.
Channels
Channels follow the formattype:id. Access is validated against database membership when subscribing.
Channel Types
Server Event Types
Events are broadcast to channels when state changes occur. Below are the event types by channel. Most state changes fan out on the workspace channel so a single subscription drives the whole dashboard; a few high-fanout streams (chat, mission detail, files) use narrower channels. Many payloads carry only the entityid (and sometimes identifier/status)
as a refresh hint — the client is expected to refetch the full resource over
REST. The shapes below reflect what the server actually sends.
Workspace Channel (workspace:{id})
Crews & agents
Missions, tasks & issues
Projects & milestones
Integrations & credentials
Assignments, escalations & peers
Containers & provisioning
Pipelines
Pipeline run/step events broadcast on the workspace channel (not a dedicated channel) so the Graph view can updatePipelineRunNode status
without polling. Every payload also carries pipeline_id, pipeline_slug,
and run_id.
Configuration & inbox
Journal Channel (journal:{id})
The journal→WS bridge forwards durably-committed journal entries onto a
dedicated, opt-in channel — journal:{workspaceId} — not the workspace
channel. A client only receives these frames if it explicitly subscribes to
journal:{workspaceId} (authorized against the same workspace membership as the
other tenant channels). Tabs that don’t subscribe pay nothing, so this is never
an unconditional firehose.
Status: realtime plumbing, no dashboard consumer yet. The authoritative
journal feed is still the SSE stream, which keeps a gap-free
Last-Event-ID
replay and server-side filtering. This channel is the foundation for later
retiring SSE, but it does not yet replace it.
Caveats to know before consuming it:
- Best-effort delivery. Under sustained backpressure the bridge drops live
frames rather than stalling the journal write path. A consumer that needs a
gap-free view must reconcile via the SSE stream’s
Last-Event-IDreplay or aGET /api/v1/journal?since=refetch on (re)connect. - Coarse server-side filtering only. High-frequency telemetry (streamed exec
output, per-sample container metrics, tracing spans, sweep bookkeeping) is
dropped at the bridge and never broadcast. Beyond that the channel is
workspace-wide: fine-grained filtering (
crew_id,entry_type,severity, …) is server-side on the SSE/REST paths but must be applied client-side here.
Crew Channel (crew:{id})
The
files:{crewId} channel exists for subscription (file change events) and
shares its access rule with crew. File-change events are broadcast on the
crew:{crewId} channel as file.event.Mission Channel (mission:{id})
Session Channel (session:{chatId})
Chat streaming events from the agent ChatHandler
Session-scoped lifecycle events
These mirror their*.updated/*.created workspace counterparts but are
scoped to the originating chat session.
Keeper Channel (keeper:{workspaceId})
Providers Channel (providers)
User Channel (user:{userId})
The identity-scoped channel. It currently carries no events: its only
producer was notification.created, from the entity-scoped in-app
notifications table removed in
#1751. Subscribing is
still allowed, and the channel is where a future per-user feed would land.
For “what needs my attention”, use the workspace inbox channel
(inbox.updated) instead.
Only the user themselves may subscribe to their own
user:{userId} channel —
the authorizer grants access when the connecting user’s id equals the channel
id, with no workspace membership lookup (the channel is scoped to the identity,
not a tenant).Connection Lifecycle
- Connect — token-authenticated WebSocket upgrade
- Subscribe — client subscribes to channels (access validated per-channel)
- Receive events — server pushes events to subscribed channels
- Send messages — client can send chat messages to session channels
- Keepalive — server sends pings every 30 seconds; client can also send pings
- Disconnect — client disconnects; all channel subscriptions are cleaned up