Skip to content

WebSocket Protocol

siamakerlab edited this page May 23, 2026 · 1 revision

WebSocket Protocol

All long-running operations stream output over WebSocket. There are three channel families, all defined as WsFrame subtypes in shared/.../ws/WsFrame.kt.

Channels

URL pattern Purpose
/ws/projects/{id}/console/logs Per-project Claude stream-json log
/ws/projects/{id}/builds/{buildId}/logs Gradle build output
/ws/env-setup/{taskId}/logs vibe-doctor install / MCP install / Claude login

Authentication (first frame)

The very first frame the client sends MUST be Auth:

{ "type": "auth", "token": "<bearer-token>" }

The server closes the connection within 5 seconds if no valid auth frame arrives. The bearer token is the same one issued by POST /api/auth/login.

URL query strings are never used to carry the token (avoids leaking it in proxy logs).

Frame types

All frames are JSON objects with a "type" discriminator. The server serializes via kotlinx.serialization polymorphism.

Common (all channels)

// log line — any source process
{ "type": "log", "taskId": "...", "level": "INFO|WARN|ERROR|STDOUT|STDERR",
  "message": "...", "ts": "2026-05-23T12:00:00Z" }

// task finished
{ "type": "done", "taskId": "...", "status": "SUCCESS|FAILED|CANCELED|TIMEOUT",
  "errorMessage": "..." }

// out-of-band error
{ "type": "error", "code": "...", "message": "..." }

// keepalive (server → client every 20 s)
{ "type": "ping" }

Console-only (Claude stream-json frames)

Each frame carries a monotonically increasing "seq". Clients persist the last seen seq and reconnect with ?since=<seq> to replay missed history.

// session_started — emitted right after `system/init`
{ "type": "console_session_started",
  "sessionId": "...", "model": "claude-3.5-sonnet",
  "cwd": "/workspace/my-app", "seq": 42 }

// streaming assistant text (may be partial)
{ "type": "console_assistant",
  "text": "I'll add ...", "isPartial": true, "seq": 43 }

// tool call request
{ "type": "console_tool_use",
  "toolName": "Edit", "input": {...}, "toolUseId": "...", "seq": 44 }

// tool call result
{ "type": "console_tool_result",
  "toolUseId": "...", "output": {...}, "isError": false, "seq": 45 }

// turn finished — UI shows "ready for next prompt"
{ "type": "console_done", "reason": "end_turn", "seq": 46 }

// model-emitted error
{ "type": "console_error", "code": "...", "message": "...", "seq": 47 }

// server-originated notice
{ "type": "console_system",
  "code": "process_crashed|resume_failed|idle_terminated|...",
  "message": "...", "seq": 48 }

// replay markers — wrap frames coming from history
{ "type": "console_replay_begin", "fromSeq": 1, "toSeq": 42 }
{ "type": "console_replay_end" }

Console — client → server (over the same WS)

// send a prompt without a separate REST call
{ "type": "user_prompt", "text": "..." }

// invoke a chip / quick action by ID
{ "type": "action_invoke", "actionId": "build_debug", "params": {...} }

Reconnect / replay flow

  1. Client opens WS, sends auth, waits for first frame.
  2. Receives console_session_started (live), records seq.
  3. Receives streaming frames, updates seq after each.
  4. Network drop. Client immediately reconnects with wss://.../logs?since=42.
  5. Server emits console_replay_begin {fromSeq, toSeq} followed by all missed frames (with their original seq), then console_replay_end, then resumes live.

Example — JavaScript

const ws = new WebSocket(`ws://${host}:17880/ws/projects/${pid}/console/logs`);
ws.onopen = () => ws.send(JSON.stringify({ type: 'auth', token }));
ws.onmessage = (e) => {
  const f = JSON.parse(e.data);
  switch (f.type) {
    case 'console_assistant': appendText(f.text, f.isPartial); break;
    case 'console_tool_use':  renderToolCall(f); break;
    case 'console_done':      markTurnComplete(); break;
    case 'done':              showFinalStatus(f.status, f.errorMessage); break;
    case 'error':             toast(`${f.code}: ${f.message}`); break;
    case 'ping':              break;
  }
};

Example — Kotlin (OkHttp)

val req = Request.Builder()
  .url("ws://$host:17880/ws/projects/$pid/console/logs")
  .build()

val ws = client.newWebSocket(req, object : WebSocketListener() {
  override fun onOpen(ws: WebSocket, resp: Response) {
    val auth = WsFrame.Auth(bearerToken)
    ws.send(Json.encodeToString(WsFrame.serializer(), auth))
  }
  override fun onMessage(ws: WebSocket, text: String) {
    when (val f = Json.decodeFromString<WsFrame>(text)) {
      is WsFrame.ConsoleAssistant -> applyText(f.text, f.isPartial)
      is WsFrame.ConsoleDone      -> markReady()
      is WsFrame.Done             -> showStatus(f.status)
      is WsFrame.Error            -> toast(f.message)
      else -> Unit
    }
  }
})

Clone this wiki locally