-
Notifications
You must be signed in to change notification settings - Fork 1
WebSocket Protocol
siamakerlab edited this page May 23, 2026
·
1 revision
All long-running operations stream output over WebSocket. There are three
channel families, all defined as WsFrame subtypes in shared/.../ws/WsFrame.kt.
| 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 |
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).
All frames are JSON objects with a "type" discriminator. The server
serializes via kotlinx.serialization polymorphism.
// 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" }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" }// 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": {...} }- Client opens WS, sends
auth, waits for first frame. - Receives
console_session_started(live), recordsseq. - Receives streaming frames, updates
seqafter each. - Network drop. Client immediately reconnects with
wss://.../logs?since=42. - Server emits
console_replay_begin {fromSeq, toSeq}followed by all missed frames (with their originalseq), thenconsole_replay_end, then resumes live.
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;
}
};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
}
}
})