# WebSocket Event Stream (`/ws`)

Connect once — receive every runtime event from every agent and session in real time.

**Trigger-path parity**: Message content, tool activity, AND live streaming chunks are emitted to WS regardless of how a session was triggered:
- **SSE-triggered sessions** → full set of `session.stream` events (`inference.chunk`, `thinking.chunk`, `tool.chunk`, `done`, `error`)
- **Tool-driven sessions** (sub-agent invocations via `agent_spawn`, `agent_message`, `async_inform`, wake-driven drains) → the **same** `session.stream` envelope set, plus `chat.message` and `chat.inference_tool` for stable backward compatibility
- **JSON-mode HTTP chats** (`POST /agents/:name/chat` without `sse: true`) → both shapes; `session.stream` events fan out on the bus even though the HTTP response body is plain JSON

The bus event shape is identical regardless of trigger; consumers don't need to differentiate. There is no longer a chunk-streaming gap between SSE-triggered and tool-driven turns — one of the highest-impact UX gaps in earlier releases.

---

## Connecting

```js
const ws = new WebSocket('ws://localhost:5050/ws');

ws.onopen  = () => console.log('Connected');
ws.onmessage = (msg) => {
  const ev = JSON.parse(msg.data);
  // ev.type identifies the event category
};
```

On connection the server sends an initial handshake:
```json
{ "type": "connected", "timestamp": 1709550000000 }
```

**Authentication** — include the secret as a query parameter or header (same as HTTP endpoints):
```js
// Query param
new WebSocket('ws://localhost:5050/ws?secret=my-secret');

// Header (Node.js / server-side clients)
new WebSocket('ws://localhost:5050/ws', [], {
  headers: { 'X-Veil-Secret': 'my-secret' }
});
```

---

## Event Envelope

Every event follows this structure:

```json
{
  "type": "<event_type>",
  "sessionId": "sess_abc123",
  "agentName": "assistant",
  "event":     { ... },
  "eventType": "<sub_type>"
}
```

| Field | Present when | Description |
|-------|-------------|-------------|
| `type` | Always | Top-level event category |
| `sessionId` | Chat/session events | Identifies the session |
| `agentName` | Always (except `connected`) | Agent that generated the event |
| `event` | All except `session.stream` | Event-specific payload |
| `eventType` | `session.stream` only | SSE event sub-type (see below) |
| `data` | `session.stream` only | SSE event payload |

`sessionId` is `undefined` where not applicable — filter on the client.

---

## Event Reference

### Session Lifecycle

| `type` | When | `event` fields |
|--------|------|----------------|
| `session.created` | A new chat session is started | `{ mode: 'chat', timestamp }` |
| `session.closed` | Session soft-closed (`DELETE /sessions/:id`) | `{ timestamp }` |
| `session.deleted` | Session hard-deleted (`DELETE /sessions/:id?hard=true`) | `{ timestamp }` |
| `session.budget_exceeded` | Per-call budget cap (`max_tokens` or `max_wall_seconds`) was breached during the loop | `{ budgetType: 'max_tokens' \| 'max_wall_seconds', limit: number, actual: number, timestamp }` |

> **Cancel events.** There is no top-level `session.cancelled` WS event. When `POST /sessions/:id/cancel` (or `agent_control` `stop`) interrupts a chat loop, the cancellation surfaces are: SSE `error { code: "CANCELLED" }` to anyone subscribed via `POST /agents/:name/chat?sse=true`, and the `cancelled: true` / `cancelReason` fields on the chat-API `done` payload (HTTP JSON path). On the WS side, you'll see no further `session.stream` / `chat.message` events for that turn after the cancel signal is processed.

```json
{ "type": "session.created", "sessionId": "sess_abc123", "agentName": "assistant",
  "event": { "mode": "chat", "timestamp": 1709550000000 } }

{ "type": "session.closed",  "sessionId": "sess_abc123", "agentName": "assistant",
  "event": { "timestamp": 1709550999000 } }
```

---

### Chat — Streaming (session.stream)

`session.stream` mirrors every event that the SSE endpoint (`POST /agents/:name/chat` with `sse: true`) sends to a directly connected SSE client. **Emitted for SSE-triggered chat turns AND for tool-driven sub-agent turns** (`agent_spawn`, `agent_message`, `async_inform`, wake-driven drains). `chat.message` and `chat.inference_tool` continue to fire on top-level types for backward compatibility — consumers can listen to either.

The sub-type is in `eventType`; the payload is in `data`.

| `eventType` | When | `data` fields |
|-------------|------|---------------|
| `inference.chunk` | Each streaming text token from the LLM | `{ content: string }` |
| `inference.tool` | LLM is about to call a tool (before execution) | `{ name: string }` |
| `thinking.chunk` | Streaming reasoning / extended-thinking text from the model. Engine-blind — fires on openai (when the provider streams reasoning, e.g. DeepSeek-R1, o1-line, Anthropic-via-OpenRouter with reasoning enabled) and on claude-cli (when `reasoning.effort` is set to a thinking-capable level — `'low'`/`'medium'`/`'high'`/`'xhigh'`/`'max'` — and the SDK emits `thinking_delta` blocks). | `{ content: string }` |
| `tool.chunk` | Live stdout/stderr from a streaming tool (e.g. `bash`) while it runs | `{ toolCallId: string, toolName: string, content: string }` |
| `message` | A message was persisted (assistant reply, tool result) | Full message object (see below) |
| `done` | Chat turn completed | `{ session, agentName, model, iterations, durationMs, tokenUsage, toolCalls, cancelled?, cancelReason? }` |
| `error` | Run-level error during the turn | `{ error: string, code: string }` (code `CANCELLED` when interrupted via `POST /sessions/:id/cancel` or budget breach) |

```json
{ "type": "session.stream", "sessionId": "sess_abc123", "agentName": "assistant",
  "eventType": "inference.chunk", "data": { "content": "Hello" } }

{ "type": "session.stream", "sessionId": "sess_abc123", "agentName": "assistant",
  "eventType": "inference.tool", "data": { "name": "bash" } }

{ "type": "session.stream", "sessionId": "sess_abc123", "agentName": "assistant",
  "eventType": "thinking.chunk",
  "data": { "content": "Let me work this out step by step. First, " } }

{ "type": "session.stream", "sessionId": "sess_abc123", "agentName": "assistant",
  "eventType": "done",
  "data": { "agentName": "assistant", "model": "anthropic/claude-opus-4-5",
            "iterations": 2, "durationMs": 3412,
            "tokenUsage": { "input": 820, "output": 310, "cache": 0, "cost": 0.0045 },
            "toolCalls": [{ "name": "bash", "durationMs": 124, "success": true }],
            "session": { "id": "sess_abc123", "status": "open", ... } } }
```

**`message` object fields:**

| Field | Type | Description |
|-------|------|-------------|
| `id` | string | Message ID |
| `session_id` | string | Session this message belongs to |
| `role` | string | `"assistant"` or `"tool"` |
| `content` | string | Message content |
| `tool_calls` | array | Tool calls made (assistant messages) |
| `tool_call_id` | string | Which tool call this is a result for (tool messages) |
| `finishReason` | string | LLM stop reason (`"end_turn"`, `"tool_use"`, etc.) |
| `iteration` | number | Loop iteration this message was generated in |
| `tokenUsage` | object | `{ input, output, cache, cost }` for this message |

---

### Chat — Messages (non-SSE sessions)

When a session is triggered via HTTP JSON, `agent_message`, `agent_spawn`, or any path that does not use SSE streaming, message content is delivered via these events instead of `session.stream`.

| `type` | When | `event` fields |
|--------|------|----------------|
| `chat.message` | An assistant or tool-result message was persisted | Same fields as `session.stream` → `message` (see message object table above) |
| `chat.inference_tool` | LLM is about to call a tool (before execution) | `{ name: string, timestamp }` |

```json
{ "type": "chat.message", "sessionId": "sess_abc123", "agentName": "assistant",
  "event": { "id": "msg_001", "session_id": "sess_abc123", "role": "assistant",
             "content": "Here is the analysis...", "tool_calls": null,
             "finishReason": "end_turn", "iteration": 1,
             "tokenUsage": { "input": 820, "output": 310, "cache": 0, "cost": 0.0045 },
             "timestamp": 1709550003000 } }

{ "type": "chat.inference_tool", "sessionId": "sess_abc123", "agentName": "assistant",
  "event": { "name": "bash", "timestamp": 1709550002500 } }
```

> **Rule of thumb**: filter on both `session.stream` (eventType `message`) and `chat.message` if you want to catch all persisted messages regardless of trigger path.

---

### Chat — User Message

Emitted when a user message is received, before the AI starts processing. Use this to render the user's turn in a UI.

| `type` | When | `event` fields |
|--------|------|----------------|
| `chat.user_message` | User message added to a session | `{ content: string, timestamp }` |

```json
{ "type": "chat.user_message", "sessionId": "sess_abc123", "agentName": "assistant",
  "event": { "content": "Summarise this file please", "timestamp": 1709550001000 } }
```

---

### Chat — Internal Loop Events

Lower-level events emitted during each loop iteration. Useful for debugging or building detailed progress UIs.

| `type` | When | `event` fields |
|--------|------|----------------|
| `chat.event` | Iteration start, LLM error | `{ type: 'iteration.start'\|'llm.error', iteration?, error? }` |
| `chat.tool` | Tool starts or ends inside a chat turn | `{ type: 'tool.start'\|'tool.end', toolName, toolInput?, durationMs?, success?, outputPreview? }` |
| `chat.response` | Chat turn fully complete (summary) | `{ content: string (≤500 chars), toolCount, timestamp }` |

> These events fire for **all** trigger paths (SSE, HTTP JSON, `agent_message`, `agent_spawn`, etc.).

```json
{ "type": "chat.event", "sessionId": "sess_abc123", "agentName": "assistant",
  "event": { "type": "iteration.start", "iteration": 1, "tokensSoFar": 0, "timestamp": 1709550002000 } }

{ "type": "chat.tool", "sessionId": "sess_abc123", "agentName": "assistant",
  "event": { "type": "tool.end", "toolName": "read_file", "durationMs": 14, "success": true,
             "outputPreview": "# README\n...", "timestamp": 1709550003000 } }

{ "type": "chat.response", "sessionId": "sess_abc123", "agentName": "assistant",
  "event": { "content": "The file contains a README with...", "toolCount": 1, "timestamp": 1709550004000 } }
```

---

**Filtering by trace.** The WS receives every trace event; the per-trace SSE stream `GET /orchestration/trace/:trace_id/stream` is the targeted alternative if you only care about one trace tree. Use the WS when you want the global firehose; use the per-trace SSE when you want a single trace replay + live tail.

---

## Building a Full Conversation UI on WS Alone

A WS-only client can render a complete, real-time conversation UI for any number of sessions without any SSE connections or polling:

```js
const ws = new WebSocket('ws://localhost:5050/ws?secret=my-secret');

// Keyed by sessionId
const sessions = {};

ws.onmessage = (msg) => {
  const ev = JSON.parse(msg.data);
  const { type, sessionId, agentName, event, eventType, data } = ev;

  switch (type) {

    // ── Session lifecycle ──────────────────────────────────────────────────
    case 'session.created':
      sessions[sessionId] = { agentName, messages: [], streaming: false };
      renderSessionTab(sessionId, agentName);
      break;

    case 'session.closed':
    case 'session.deleted':
      markSessionClosed(sessionId);
      break;

    // ── User message (render immediately, before AI responds) ────────────
    case 'chat.user_message':
      appendMessage(sessionId, { role: 'user', content: event.content });
      break;

    // ── Streaming (SSE-triggered sessions) ─────────────────────────────
    case 'session.stream':
      if (eventType === 'inference.chunk') {
        appendStreamingChunk(sessionId, data.content);    // live token-by-token
      } else if (eventType === 'inference.tool') {
        showToolIndicator(sessionId, data.name);           // "Using bash…"
      } else if (eventType === 'message' && data.role === 'assistant') {
        finalizeAssistantMessage(sessionId, data);         // full message persisted
      } else if (eventType === 'done') {
        hideLoader(sessionId);
        updateUsage(sessionId, data.tokenUsage);
      } else if (eventType === 'error') {
        showError(sessionId, data.error);
      }
      break;

    // ── Messages (non-SSE sessions: HTTP JSON, agent_message, agent_spawn)
    case 'chat.message':
      if (event.role === 'assistant') finalizeAssistantMessage(sessionId, event);
      break;

    case 'chat.inference_tool':
      showToolIndicator(sessionId, event.name);            // "Using bash…"
      break;

    // ── Loading indicator (AI started thinking) ──────────────────────────
    case 'chat.event':
      if (event.type === 'iteration.start') showLoader(sessionId);
      break;

  }
};
```

---

## Filtering Events

The WebSocket receives events for **all** agents and sessions. Filter by any combination of `sessionId` or `agentName`:

```js
// Watch only one session
if (ev.sessionId !== mySessionId) return;

// Watch all sessions for one agent
if (ev.agentName !== 'researcher') return;
```

---

## Reconnection

The WebSocket is stateless — the server stores no subscription state. On reconnect:

1. Fetch missed messages: `GET /sessions/:id/messages`
2. Reconnect to WS and resume live updates

```js
async function reconnect() {
  const { messages } = await api.get(`/sessions/${sessionId}/messages`);
  renderHistory(messages);
  connectWS(); // then subscribe to live updates
}
```
