WebSocket Events

The daemon streams real-time events over a WebSocket connection. This is the recommended way to build interactive clients — it provides lower latency than HTTP polling and delivers every event as it happens.

Connecting

WS/v3/ws?token=T

Opens a persistent WebSocket connection. The daemon pushes all session events for the authenticated client. The token is passed as a query parameter.

Connect using the daemon token from ~/.config/opta/daemon/state.json:

connect.js
const ws = new WebSocket("ws://127.0.0.1:9999/v3/ws?token=opta_dk_...");

ws.onopen = () => console.log("connected");
ws.onmessage = (e) => {
  const envelope = JSON.parse(e.data);
  console.log(envelope.event, envelope.seq, envelope.data);
};
ws.onclose = (e) => console.log("closed", e.code, e.reason);
Single connection
One WebSocket connection receives events for all sessions. You do not need a separate connection per session. Filter events client-side using the data.sessionId field.

Envelope Format

Every WebSocket message is a JSON object called a V3Envelope. It always contains three fields:

V3Envelope
interface V3Envelope {
  event: string;    // Event type (e.g. "turn.token", "tool.start")
  seq: number;      // Monotonically increasing sequence number
  data: unknown;    // Event-specific payload
}

The seq field is globally ordered across all sessions. It is the key to reliable reconnection — clients track the last received seq and pass it as afterSeq when reconnecting.

Event Types

The daemon emits the following event types. They are grouped by category.

Session Events

Sent immediately after WebSocket connection or session creation. Contains the full session state.

json
{
  "event": "session.snapshot",
  "seq": 1,
  "data": {
    "sessionId": "sess_abc123",
    "mode": "chat",
    "model": "qwen3-30b-a3b",
    "status": "idle",
    "turns": 0
  }
}

Turn Events

The turn has been accepted and is waiting to start.

json
{
  "event": "turn.queued",
  "seq": 41,
  "data": {
    "sessionId": "sess_abc123",
    "turnId": "turn_001",
    "content": "Explain this code"
  }
}

Tool Events

A tool call has started executing. Includes the tool name and input parameters.

json
{
  "event": "tool.start",
  "seq": 55,
  "data": {
    "sessionId": "sess_abc123",
    "toolName": "file_read",
    "toolCallId": "tc_001",
    "input": { "path": "/src/index.ts" }
  }
}

Permission Events

The daemon needs user approval before executing a tool call. The turn is paused until this is resolved.

json
{
  "event": "permission.request",
  "seq": 57,
  "data": {
    "sessionId": "sess_abc123",
    "requestId": "perm_001",
    "toolName": "file_write",
    "input": { "path": "/src/config.ts", "content": "..." },
    "reason": "Tool requires write access"
  }
}

Background Events

Output from a background process (e.g., a long-running shell command). These stream asynchronously alongside turn events.

json
{
  "event": "background.output",
  "seq": 70,
  "data": {
    "sessionId": "sess_abc123",
    "taskId": "bg_001",
    "stream": "stdout",
    "text": "Build complete. 0 errors."
  }
}

Reconnection

If the WebSocket connection drops, clients should reconnect using the last received sequence number to avoid re-processing events. The daemon supports cursor-based reconnection via the afterSeq query parameter.

reconnect.js
let lastSeq = 0;

function connect() {
  const url = `ws://127.0.0.1:9999/v3/ws?token=${token}&afterSeq=${lastSeq}`;
  const ws = new WebSocket(url);

  ws.onmessage = (e) => {
    const envelope = JSON.parse(e.data);
    lastSeq = envelope.seq;
    handleEvent(envelope);
  };

  ws.onclose = () => {
    // Exponential backoff reconnect
    setTimeout(connect, Math.min(1000 * Math.pow(2, retries), 30000));
  };
}
No duplicate events
When you reconnect with afterSeq, the daemon replays only events with a sequence number greater than the value you provide. This guarantees no duplicates and no gaps.

Client Example

Here is a complete example of a minimal TypeScript client that connects to the daemon, submits a turn, and streams the response:

daemon-client.ts
import { readFileSync } from "fs";
import { join } from "path";
import { homedir } from "os";

// Read token from state file
const statePath = join(homedir(), ".config/opta/daemon/state.json");
const state = JSON.parse(readFileSync(statePath, "utf-8"));
const { token, port } = state;

// Connect WebSocket
const ws = new WebSocket(`ws://127.0.0.1:${port}/v3/ws?token=${token}`);

ws.onopen = async () => {
  // Create a session
  const res = await fetch(`http://127.0.0.1:${port}/v3/sessions`, {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${token}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ mode: "chat" }),
  });
  const { sessionId } = await res.json();

  // Submit a turn
  await fetch(`http://127.0.0.1:${port}/v3/sessions/${sessionId}/turns`, {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${token}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ content: "What is Opta?" }),
  });
};

ws.onmessage = (e) => {
  const { event, data } = JSON.parse(e.data);

  switch (event) {
    case "turn.token":
      process.stdout.write(data.token);
      break;
    case "turn.done":
      console.log("\n--- Done ---");
      console.log(`${data.stats.tokens} tokens at ${data.stats.speed} tok/s`);
      ws.close();
      break;
    case "turn.error":
      console.error("Error:", data.error.message);
      ws.close();
      break;
  }
};