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 validated only during the handshake; the connection stays authenticated afterward.
Connection
ws://<host>:8080/ws?token=<jwt>
Authentication Flow
- Obtain a short-lived JWT token:
GET /api/v1/ws-token (requires session or CLI auth)
- Connect to the WebSocket endpoint with the token as a query parameter
- The token is validated during the handshake using the same JWT validator as the REST API
- After connection, 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.
All messages (client-to-server and server-to-client) are JSON.
Client Messages
{
type: "subscribe" | "unsubscribe" | "ping" | "send_message" | "cancel_message",
channel?: string,
payload?: any
}
Server Messages
{
type: string,
channel?: string,
payload: any
}
Client Message Types
subscribe
Subscribe to a channel for real-time events. Channel access is validated against workspace/crew membership in the database.
{
"type": "subscribe",
"channel": "workspace:ws_abc123"
}
If access is denied, the server responds with:
{
"type": "error",
"channel": "workspace:ws_abc123",
"payload": { "error": "access denied" }
}
unsubscribe
{
"type": "unsubscribe",
"channel": "workspace:ws_abc123"
}
ping
Keep-alive ping. The server also sends periodic pings every 30 seconds.
Server responds with:
{
"type": "pong",
"payload": null
}
send_message
Send a chat message to an agent session.
{
"type": "send_message",
"channel": "session:chat_abc123",
"payload": {
"session_id": "chat_abc123",
"content": "Review the latest PR and suggest improvements"
}
}
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.
{
"type": "cancel_message",
"payload": {
"session_id": "chat_abc123"
}
}
Channels
Channels follow the format type:id. Access is validated against database membership when subscribing.
Channel Types
| Channel | Format | Access Rule | Description |
|---|
workspace | workspace:{workspaceId} | Workspace member | Workspace-wide events |
crew | crew:{crewId} | Member of crew’s workspace | Crew-specific events |
agent | agent:{agentId} | Member of agent’s workspace | Agent-specific events |
session | session:{chatId} | Member of chat’s workspace | Interactive chat session |
mission | mission:{missionId} | Member of mission’s workspace | Mission status updates |
keeper | keeper:{workspaceId} | Workspace member | Keeper credential access events |
files | files:{crewId} | Member of crew’s workspace | File change events |
providers | providers | Any authenticated user | Provider status updates |
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
| Event Type | Description | Payload |
|---|
crew.created | Crew was created | { id, name, slug } |
crew.updated | Crew was updated | { id } |
crew.deleted | Crew was deleted | { id } |
agent.created | Agent was created | { id, ... } |
agent.updated | Agent was updated | { id } |
agent.deleted | Agent was deleted | { id } |
agent.hired | Agent was hired | { id, ... } |
agent.rehired | Agent was rehired | { id, ... } |
agent.hire_approved | Agent hire request approved | { id, ... } |
agent.skill_assigned | Skill assigned to agent | { id, ... } |
agent.skill_unassigned | Skill removed from agent | { id, ... } |
agent.expired | Ephemeral agent reached TTL and was ghosted | { id, crew_id, name, expired_at } |
agent.log | Agent log output | { ts, level, agent, agent_id, event, content, metadata } |
agent.status | Agent run status change | { agent_id, status, ... } |
run.started | An agent run started | { agent_id, ... } |
Missions, tasks & issues
| Event Type | Description | Payload |
|---|
mission.created | Mission was created | { id, ... } |
mission.updated | Mission was updated / status changed | { id, crew_id, title, status } |
task.updated | Mission task status changed | { id, mission_id, status } |
confidence.low | A task’s planning confidence is low | { task_id, mission_id, level } |
approval.required | A task is awaiting human approval | { task_id, mission_id } |
approval.resolved | A pending approval was resolved | { task_id, mission_id, ... } |
issue.created | Issue was created | { id } |
issue.updated | Issue was updated | { id } (sometimes identifier, status) |
issue.deleted | Issue was deleted | { identifier } |
issue.started | Issue execution started | { id, identifier, status } |
issues.bulk_updated | Multiple issues updated in bulk | { count } |
Projects & milestones
| Event Type | Description | Payload |
|---|
project.created | Project was created | { id } |
project.updated | Project was updated | { id } |
project.deleted | Project was deleted | { id } |
milestone.created | Milestone was created | { id, project_id } |
milestone.updated | Milestone was updated | { id, project_id } |
milestone.deleted | Milestone was deleted | { id, project_id } |
Integrations & credentials
| Event Type | Description | Payload |
|---|
integration.created | Integration was added | { id, ... } |
integration.updated | Integration was changed | { id, ... } |
integration.deleted | Integration was removed | { id, ... } |
credential.expired | An OAuth credential expired (token refresh failed) | { credential_id, reason } |
Assignments, escalations & peers
| Event Type | Description | Payload |
|---|
assignment.updated | Agent-to-agent assignment changed | { id, status, ... } |
assignment_queued | Assignment was queued for dispatch | { ... } |
assignment_unqueued | Assignment was removed from the queue | { ... } |
escalation.created | An escalation was raised | { id, crew_id, from_slug, reason } |
escalation.resolved | An escalation was resolved | { id, ... } |
peer_conversation.updated | Peer (agent-to-agent) conversation changed | { ... } |
Containers & provisioning
| Event Type | Description | Payload |
|---|
container.stats | Live container resource metrics (~5s cadence) | { container_id, crew_id, cpu_percent, memory_used, memory_limit, memory_percent, net_rx_bytes, net_tx_bytes, pids, timestamp } |
provision.started | Crew container provisioning started | { ... } |
provision.progress | Provisioning progress update | { ... } |
provision.completed | Provisioning finished | { ... } |
provision.failed | Provisioning failed | { ... } |
port_expose.created | A container port was exposed | { ... } |
port_expose.revoked | An exposed port was revoked | { ... } |
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.
| Event Type | Description | Payload (plus the run/pipeline trio) |
|---|
pipeline.run.started | Pipeline run kicked off | { mode, author_crew_id, invoking_crew_id, invoking_agent_id, step_count, inputs_preview } |
pipeline.run.completed | Pipeline run finished successfully | { total_duration_ms, total_cost_usd } |
pipeline.run.failed | Pipeline run failed | { failed_at_step, error_message } |
pipeline.step.started | Step began | { step_id, step_index, step_type, tier_adapter, tier_model } |
pipeline.step.completed | Step completed | { step_id, output_preview, duration_ms, cost_usd } |
pipeline.step.skipped | Step skipped (if condition false) | { step_id, condition } |
pipeline.step.retry | Transient failure, step is being retried | { step_id, attempt, error_message_preview, sleep_ms } |
pipeline.step.failed | Step failed terminally | { step_id, error_class, error_message_preview } |
pipeline.step.validation_failed | Step output failed a validation gate | { step_id, reason, action } |
Configuration & inbox
| Event Type | Description | Payload |
|---|
inbox.updated | Unified inbox changed | { ... } |
instance_setting.updated | An instance-level setting changed | { key } |
feature_flag.created | A feature flag was created | { key } |
feature_flag.updated | A feature flag was updated | { key } |
feature_flag.deleted | A feature flag was deleted | { key } |
feature_flag.override_set | A workspace flag override was set | { ... } |
feature_flag.override_cleared | A workspace flag override was cleared | { ... } |
workflow_template.created | Workflow template created | { id } |
workflow_template.updated | Workflow template updated | { id } |
workflow_template.deleted | Workflow template deleted | { id } |
triage_rule.created | Triage rule created | { id } |
triage_rule.updated | Triage rule updated | { id } |
triage_rule.deleted | Triage rule deleted | { id } |
triage.processed | A POST /triage/process batch matched at least one issue | { processed, matched } (string-encoded counts) |
recurring_issue.created | Recurring issue created | { id } |
recurring_issue.updated | Recurring issue updated | { id } |
recurring_issue.deleted | Recurring issue deleted | { id } |
Crew Channel (crew:{id})
| Event Type | Description | Payload |
|---|
mission.created | Mission was created in crew | { id, title } |
file.event | File created/modified/deleted in the crew’s output dir | { event, path, agent, size, timestamp } |
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})
| Event Type | Description | Payload |
|---|
mission.status | Mission status changed | { id, title, status } |
task.status | A task within the mission changed status | { id, status } |
Session Channel (session:{chatId})
Chat streaming events from the agent ChatHandler
| Event Type | Description |
|---|
text | Text content from agent |
tool_call | Agent is calling a tool |
tool_result | Tool call result |
thinking | Agent thinking/reasoning |
status | Status update |
done | Agent finished responding |
error | Error occurred |
result | Final result (includes metadata: total_cost_usd, num_turns, usage) |
system | System message |
Session-scoped lifecycle events
These mirror their *.updated/*.created workspace counterparts but are
scoped to the originating chat session.
| Event Type | Description | Payload |
|---|
assignment_created | Assignment created in this session | { id, target, task } |
assignment_running | Assignment started running | { ... } |
assignment_completed | Assignment completed | { ... } |
assignment_failed | Assignment failed | { ... } |
assignment_queued | Assignment queued | { ... } |
assignment_unqueued | Assignment unqueued | { ... } |
escalation_created | Escalation raised in this session | { id, from, reason } |
escalation_resolved | Escalation resolved | { ... } |
peer_query_running | Peer query started | { ... } |
peer_query_completed | Peer query completed | { ... } |
peer_query_failed | Peer query failed | { ... } |
port_expose_created | Port exposed from this session | { ... } |
Keeper Channel (keeper:{workspaceId})
| Event Type | Description | Payload |
|---|
keeper_event | Credential access request/decision | { request_id, request_type, agent_name, credential_name, intent, decision, reason, risk_score, decided_at } |
Providers Channel (providers)
| Event Type | Description | Payload |
|---|
provider_status | Container/credential provider status update | { connection_id, old_status, new_status } |
User Channel (user:{userId})
| Event Type | Description | Payload |
|---|
notification.created | A new notification was created for the user | { id, action, entity_type, entity_id, entity_title } |
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). REST (GET /api/v1/notifications) remains available for polling.
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
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
// 1. Get WS token
const response = await fetch('/api/v1/ws-token', {
headers: { 'Authorization': 'Bearer <session-token>' }
});
const { token } = await response.json();
// 2. Connect
const ws = new WebSocket(`ws://localhost:8080/ws?token=${token}`);
ws.onopen = () => {
// 3. Subscribe to workspace events
ws.send(JSON.stringify({
type: 'subscribe',
channel: `workspace:${workspaceId}`
}));
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
switch (msg.type) {
case 'agent.log':
console.log(`[${msg.payload.agent}] ${msg.payload.content}`);
break;
case 'mission.updated':
console.log(`Mission ${msg.payload.id} -> ${msg.payload.status}`);
break;
case 'issue.updated':
// Refresh issue list
break;
}
};
Example: Interactive Chat
// Subscribe to session channel
ws.send(JSON.stringify({
type: 'subscribe',
channel: `session:${chatId}`
}));
// Send a message
ws.send(JSON.stringify({
type: 'send_message',
channel: `session:${chatId}`,
payload: {
session_id: chatId,
content: 'Explain the authentication flow'
}
}));
// Receive streamed response
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.channel === `session:${chatId}`) {
switch (msg.type) {
case 'text':
// Append text chunk to UI
break;
case 'tool_call':
// Show tool call indicator
break;
case 'done':
// Agent finished
break;
}
}
};
// Cancel if needed
ws.send(JSON.stringify({
type: 'cancel_message',
payload: { session_id: chatId }
}));