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 validated only during the handshake; the connection stays authenticated afterward.

Connection

ws://<host>:8080/ws?token=<jwt>

Authentication Flow

  1. Obtain a short-lived JWT token: GET /api/v1/ws-token (requires session or CLI auth)
  2. Connect to the WebSocket endpoint with the token as a query parameter
  3. The token is validated during the handshake using the same JWT validator as the REST API
  4. 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.

Message Format

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.
{
  "type": "ping"
}
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

ChannelFormatAccess RuleDescription
workspaceworkspace:{workspaceId}Workspace memberWorkspace-wide events
crewcrew:{crewId}Member of crew’s workspaceCrew-specific events
agentagent:{agentId}Member of agent’s workspaceAgent-specific events
sessionsession:{chatId}Member of chat’s workspaceInteractive chat session
missionmission:{missionId}Member of mission’s workspaceMission status updates
keeperkeeper:{workspaceId}Workspace memberKeeper credential access events
filesfiles:{crewId}Member of crew’s workspaceFile change events
providersprovidersAny authenticated userProvider 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 TypeDescriptionPayload
crew.createdCrew was created{ id, name, slug }
crew.updatedCrew was updated{ id }
crew.deletedCrew was deleted{ id }
agent.createdAgent was created{ id, ... }
agent.updatedAgent was updated{ id }
agent.deletedAgent was deleted{ id }
agent.hiredAgent was hired{ id, ... }
agent.rehiredAgent was rehired{ id, ... }
agent.hire_approvedAgent hire request approved{ id, ... }
agent.skill_assignedSkill assigned to agent{ id, ... }
agent.skill_unassignedSkill removed from agent{ id, ... }
agent.expiredEphemeral agent reached TTL and was ghosted{ id, crew_id, name, expired_at }
agent.logAgent log output{ ts, level, agent, agent_id, event, content, metadata }
agent.statusAgent run status change{ agent_id, status, ... }
run.startedAn agent run started{ agent_id, ... }

Missions, tasks & issues

Event TypeDescriptionPayload
mission.createdMission was created{ id, ... }
mission.updatedMission was updated / status changed{ id, crew_id, title, status }
task.updatedMission task status changed{ id, mission_id, status }
confidence.lowA task’s planning confidence is low{ task_id, mission_id, level }
approval.requiredA task is awaiting human approval{ task_id, mission_id }
approval.resolvedA pending approval was resolved{ task_id, mission_id, ... }
issue.createdIssue was created{ id }
issue.updatedIssue was updated{ id } (sometimes identifier, status)
issue.deletedIssue was deleted{ identifier }
issue.startedIssue execution started{ id, identifier, status }
issues.bulk_updatedMultiple issues updated in bulk{ count }

Projects & milestones

Event TypeDescriptionPayload
project.createdProject was created{ id }
project.updatedProject was updated{ id }
project.deletedProject was deleted{ id }
milestone.createdMilestone was created{ id, project_id }
milestone.updatedMilestone was updated{ id, project_id }
milestone.deletedMilestone was deleted{ id, project_id }

Integrations & credentials

Event TypeDescriptionPayload
integration.createdIntegration was added{ id, ... }
integration.updatedIntegration was changed{ id, ... }
integration.deletedIntegration was removed{ id, ... }
credential.expiredAn OAuth credential expired (token refresh failed){ credential_id, reason }

Assignments, escalations & peers

Event TypeDescriptionPayload
assignment.updatedAgent-to-agent assignment changed{ id, status, ... }
assignment_queuedAssignment was queued for dispatch{ ... }
assignment_unqueuedAssignment was removed from the queue{ ... }
escalation.createdAn escalation was raised{ id, crew_id, from_slug, reason }
escalation.resolvedAn escalation was resolved{ id, ... }
peer_conversation.updatedPeer (agent-to-agent) conversation changed{ ... }

Containers & provisioning

Event TypeDescriptionPayload
container.statsLive 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.startedCrew container provisioning started{ ... }
provision.progressProvisioning progress update{ ... }
provision.completedProvisioning finished{ ... }
provision.failedProvisioning failed{ ... }
port_expose.createdA container port was exposed{ ... }
port_expose.revokedAn 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 TypeDescriptionPayload (plus the run/pipeline trio)
pipeline.run.startedPipeline run kicked off{ mode, author_crew_id, invoking_crew_id, invoking_agent_id, step_count, inputs_preview }
pipeline.run.completedPipeline run finished successfully{ total_duration_ms, total_cost_usd }
pipeline.run.failedPipeline run failed{ failed_at_step, error_message }
pipeline.step.startedStep began{ step_id, step_index, step_type, tier_adapter, tier_model }
pipeline.step.completedStep completed{ step_id, output_preview, duration_ms, cost_usd }
pipeline.step.skippedStep skipped (if condition false){ step_id, condition }
pipeline.step.retryTransient failure, step is being retried{ step_id, attempt, error_message_preview, sleep_ms }
pipeline.step.failedStep failed terminally{ step_id, error_class, error_message_preview }
pipeline.step.validation_failedStep output failed a validation gate{ step_id, reason, action }

Configuration & inbox

Event TypeDescriptionPayload
inbox.updatedUnified inbox changed{ ... }
instance_setting.updatedAn instance-level setting changed{ key }
feature_flag.createdA feature flag was created{ key }
feature_flag.updatedA feature flag was updated{ key }
feature_flag.deletedA feature flag was deleted{ key }
feature_flag.override_setA workspace flag override was set{ ... }
feature_flag.override_clearedA workspace flag override was cleared{ ... }
workflow_template.createdWorkflow template created{ id }
workflow_template.updatedWorkflow template updated{ id }
workflow_template.deletedWorkflow template deleted{ id }
triage_rule.createdTriage rule created{ id }
triage_rule.updatedTriage rule updated{ id }
triage_rule.deletedTriage rule deleted{ id }
triage.processedA POST /triage/process batch matched at least one issue{ processed, matched } (string-encoded counts)
recurring_issue.createdRecurring issue created{ id }
recurring_issue.updatedRecurring issue updated{ id }
recurring_issue.deletedRecurring issue deleted{ id }

Crew Channel (crew:{id})

Event TypeDescriptionPayload
mission.createdMission was created in crew{ id, title }
file.eventFile 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 TypeDescriptionPayload
mission.statusMission status changed{ id, title, status }
task.statusA task within the mission changed status{ id, status }

Session Channel (session:{chatId})

Chat streaming events from the agent ChatHandler

Event TypeDescription
textText content from agent
tool_callAgent is calling a tool
tool_resultTool call result
thinkingAgent thinking/reasoning
statusStatus update
doneAgent finished responding
errorError occurred
resultFinal result (includes metadata: total_cost_usd, num_turns, usage)
systemSystem message

Session-scoped lifecycle events

These mirror their *.updated/*.created workspace counterparts but are scoped to the originating chat session.
Event TypeDescriptionPayload
assignment_createdAssignment created in this session{ id, target, task }
assignment_runningAssignment started running{ ... }
assignment_completedAssignment completed{ ... }
assignment_failedAssignment failed{ ... }
assignment_queuedAssignment queued{ ... }
assignment_unqueuedAssignment unqueued{ ... }
escalation_createdEscalation raised in this session{ id, from, reason }
escalation_resolvedEscalation resolved{ ... }
peer_query_runningPeer query started{ ... }
peer_query_completedPeer query completed{ ... }
peer_query_failedPeer query failed{ ... }
port_expose_createdPort exposed from this session{ ... }

Keeper Channel (keeper:{workspaceId})

Event TypeDescriptionPayload
keeper_eventCredential access request/decision{ request_id, request_type, agent_name, credential_name, intent, decision, reason, risk_score, decided_at }

Providers Channel (providers)

Event TypeDescriptionPayload
provider_statusContainer/credential provider status update{ connection_id, old_status, new_status }

User Channel (user:{userId})

Event TypeDescriptionPayload
notification.createdA 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

  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

// 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 }
}));