Skip to main content
Crewship pushes real-time updates — log streaming, chat token streams, and entity-change events — over a single token-authenticated WebSocket. Clients subscribe to typed channels (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

  1. Obtain a short-lived JWT token: GET /api/v1/ws-token (requires session or CLI auth)
  2. Open the WebSocket connection (no token in the URL)
  3. 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
  4. After that, the token is no longer needed — the connection remains authenticated
The WebSocket server uses golang.org/x/net/websocket (not gorilla/websocket). Authentication is token-based. Inbound frames are capped at 64 KiB.
The handshake validates the Origin header against the request Host (cross-site WebSocket hijacking protection). localhost/127.0.0.1 origins are allowed outside production; in production (CREWSHIP_ENV=production) the origin host must match exactly.

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.
If access is denied, the server responds with:

unsubscribe

ping

Keep-alive ping. The server also sends periodic pings every 30 seconds.
Server responds with:

send_message

Send a chat message to an agent session.
The server processes the message through the ChatHandler, which streams responses back on the same session channel.

cancel_message

Cancel an in-progress agent chat response.

Error frames

The server answers a refused or undeliverable client frame with a top-level error 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 malformed send_message, a chat handler that is unavailable. A send_message refusal is addressed to the session in its payload, whether or not the client set channel on 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.
A deny is not by itself an error worth showing a user. A subscribe to a session that does not exist yet is refused (the 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 format type: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 entity id (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 update PipelineRunNode 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-ID replay or a GET /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

  1. Connect — token-authenticated WebSocket upgrade
  2. Subscribe — client subscribes to channels (access validated per-channel)
  3. Receive events — server pushes events to subscribed channels
  4. Send messages — client can send chat messages to session channels
  5. Keepalive — server sends pings every 30 seconds; client can also send pings
  6. Disconnect — client disconnects; all channel subscriptions are cleaned up
The server maintains a send buffer of 64 messages per client. If the buffer is full, messages are dropped silently for that client — a slow consumer can miss events, so treat most payloads as refresh hints and refetch over REST.

Example: Subscribing to Workspace Events

Example: Interactive Chat