# WebSocket Protocol

Full protocol reference for the streaming agent interface. File: `navi/api/websocket.py`.

## Connection

`ws://host/ws/sessions/{session_id}`

The session must exist before connecting (create via `POST /sessions`). If the session is not found, the WebSocket closes with code `4004`. Auth failures (anonymous denied in auth-enabled mode, or access denied to a session the user does not own) close with code `4003`.

Both codes are final: reconnecting against the same session cannot succeed, so a client must stop retrying and tell the user why (the web client shows a fatal banner and offers sign-in instead of the reconnect loop).

### Authentication

**Browser clients**: cookies are sent automatically during the WebSocket handshake. The server resolves the user from the `navi_auth_session` cookie.

**Headless clients**: append the API token as a query parameter:
```
ws://host/ws/sessions/{session_id}?api_token=nav_aB3xYz9W...
```

**No-auth mode** (`NAVI_AUTH_ENABLED=false`): neither cookies nor tokens are required. Every WebSocket connection is treated as the local `anonymous` admin user.

> **Security note**: `?api_token` is visible in server access logs.

On connect the server immediately sends either `session_sync` (no active run) or begins the reconnect flow (active run detected).

### Future work: WebSocket auth message (deferred)

Status: **not implemented** — recorded here for later. Tracked as task #248.

The current headless auth path puts the API token in the connection URL:
`?api_token=nav_...`. Query strings are written to reverse-proxy / uvicorn
access logs, so a long-lived API token leaks into log files — anyone who can
read the logs (or a leaked log) gets the token's full access.

Planned hardening for log-sensitive deployments: accept the token as the first
WebSocket frame instead of a query parameter.

```json
{"type": "auth", "api_token": "nav_aB3xYz9W..."}
```

Sketch:
- On connect, the server holds the socket in an unauthenticated state and sends
  nothing except an optional `auth_required` prompt.
- The client must send `{type: "auth", api_token}` as its first message within a
  short timeout (e.g. 5s); messages received before auth are buffered and
  replayed once the user is resolved.
- The server resolves the user (`_resolve_user_from_api_token`), then proceeds
  with the existing access-check / `session_sync` / reconnect flow.
- Any non-`auth` message before auth (or timeout) → close `4003`. `?api_token`
  query param stays as a fallback for backward compatibility.

Scope is limited to headless clients with auth enabled. Browser clients use the
`navi_auth_session` cookie (no URL token) and local `navi_code` runs in no-auth
mode — neither is affected. Only remote headless clients (CLI/TUI/automation
against an auth-enabled server) benefit.

---

## Messages: client → server

```json
{
    "type": "message",
    "content": "user text",
    "cwd": "/home/user/projects/my-app",
    "client_id": "6f1c0b1e-…",
    "images": ["base64string", ...],
    "files": [{"name": "file.pdf", "path": "/abs/path"}]
}
```

- `type` is either `"message"` (a user turn) or `"compact"` (force context compression now — see [Forced compression](#forced-compression) below). Any other type returns an error frame.
- `content` is required for `"message"` and must be non-empty.
- `client_id`: optional client-chosen id (the web client and the TUI send a UUID). The server echoes it in `message_accepted` (run started) or in the `client_id` field of `message_queued` (run queued behind another). Opt-in: without it the frames are unchanged, which is what older clients rely on. A write to a socket that looks open proves nothing about delivery — this echo is the only end-to-end confirmation, so a client that sent one can mark the bubble as not delivered when no answer arrives.
- `cwd`: optional absolute path of the client's current working directory. Recommended for terminal clients; the server treats it as the project root and resolves relative paths in the file tools, `terminal`, and `code_exec` against it. Stored per-session in `session_metadata["cwd"]` and injected into the LLM system context.
- `images`: optional list of base64-encoded images (data URIs accepted; the `data:...;base64,` prefix is stripped server-side). **Limits:** max 8 images per message, 50 MB total payload (across all images). Excess is rejected with a WebSocket error.
- `files`: optional list of uploaded file references (appended to content as `[Uploaded files on disk: ...]`).

### Liveness probe

```json
{"type": "ping", "ts": 1748000000000}
```

Answered with `{"type": "pong", "ts": ...}` (same `ts`, no other work). A `close` event never arrives for a half-open socket — a phone that slept, a suspended PWA or a dropped NAT mapping leaves `readyState` at `OPEN` while writes go nowhere — so the only evidence that the connection still works end to end is traffic coming back. The server also sends `heartbeat` after 20 s of its own silence, mid-run or idle, which serves the same purpose without a client probe.

### Forced compression

```json
{"type": "compact"}
```

Runs the context compressor immediately, **bypassing the token threshold**. The server streams back `compression_started` → `context_compressed` (or an `error` frame if there is nothing to compress / compression is disabled). Notable behavior:

- It does **not** send `stream_start` — this is not a generation turn.
- It is rejected with an error if a run is already active for the session (concurrent-run guard below).

The TUI binds this to `/compact` (`Ctrl+X C`).

### Concurrent run guard
Only one agent run may be active per session at a time. If a second message arrives while a run is already in progress (either a WebSocket run or a headless recall), the server **queues** it instead of erroring and replies with a `message_queued` frame (see below). Queued messages execute back-to-back on the same socket once the active run finishes (drained in submit order, each as a normal streaming turn); a max of `message_queue_max` (default 5) are retained — when full, the oldest queued message is evicted and `dropped_total` in the frame grows. When the evicted message carried a `client_id`, every socket watching the session also gets `message_dropped` with that id, so its sender can mark the bubble as lost instead of leaving it queued forever. If the socket disconnects while messages are still queued, the server runs them headlessly (turn-completion push still fires).

**When the guard is reachable.** The WebSocket read loop is sequential: the handler awaits the whole agent run before reading the next frame, so a message sent on the *same* socket during a run is read only after the run ends (it then executes immediately, without a queue hop or a `message_queued` frame — nothing is lost). The queue path is therefore reachable only when a run is started outside the socket's own read loop: a second socket/tab on the same session, or a headless recall running concurrently. Messages sent on the same socket during a run are executed back-to-back after it finishes.

---

## Messages: server → client

All frames are JSON objects with a `type` field.

### Stream lifecycle

| Frame | When |
|---|---|
| `{"type": "stream_start"}` | Before any agent output begins |
| `{"type": "stream_end", "content": "...", "context_tokens": N, "max_context_tokens": N, "elapsed_seconds": N, "tool_call_count": N, "token_count": N, "message_index": N}` | After final text, before workers |
| `{"type": "stream_stopped", "message_index": N}` | If the user stopped generation |
| `{"type": "error", "message": "..."}` | On any unhandled error |

`message_index` (both end frames, nullable) is the **global display rank** of the turn's first assistant row — the same value `GET /sessions/{id}/messages/history` stamps as `metadata.display_index`. Clients build the assistant block's stable id (`h_<rank>`) and the message-feedback key from it, so the live turn and the same turn after a history reload agree. A store that cannot answer sends `null`; clients then fall back to counting assistant blocks locally.

### Thinking (reasoning)

| Frame | When |
|---|---|
| `{"type": "thinking_delta", "delta": "..."}` | Reasoning chunk during streaming |
| `{"type": "thinking_end"}` | Reasoning phase complete |
| `{"type": "turn_thinking", "thinking": "...", "is_subagent": bool}` | Full reasoning block from a tool-calling turn (complete(), non-streaming) |

Thinking blocks are collapsible in the UI: open during reasoning, auto-collapsed on `thinking_end`.

### Planning

| Frame | When |
|---|---|
| `{"type": "planning_status", "phase": 1|3, "label": "...", "is_subagent": bool}` | During a planning phase — progress label for UI. `phase`: 1=analysis, 3=plan |
| `{"type": "plan_ready", "plan": "...", "is_subagent": bool}` | When the `plan` tool finishes generating a plan (mid-turn, after its `tool_started` frame). For subagents: automatically before their tool-calling loop if `subagent_planning_enabled` |

`planning_status` frames arrive during each planning phase (analysis → plan). `is_subagent: true` means the planning is running inside a subagent — route it into the spawn_agent card, never into the top-level UI.

`plan_ready` carries the formatted step list. Rendered as a collapsible plan card in the UI. At top level the card appears mid-turn — planning is agent-invoked via the `plan` tool, so there is no pre-turn planning step.

### Tool calls

| Frame | When |
|---|---|
| `{"type": "tool_started", "tool": "name", "args": {...}, "is_subagent": bool, "metadata": {...}, "tool_call_id": "..."}` | Immediately when a tool call begins (before execution) |
| `{"type": "tool_call", "tool": "name", "args": {...}, "result": "...", "success": bool, "is_subagent": bool, "metadata": {...}, "tool_call_id": "..."}` | When the tool finishes |

`is_subagent: true` indicates the tool call was made by a nested subagent, not the top-level agent. `tool_call_id` is a stable id for client-side `tool_started` → `tool_call` pairing. `metadata` carries extra rendering data (e.g. `step_text` for todo update cards, `is_image`/`base64` for image results).

### Text output

| Frame | When |
|---|---|
| `{"type": "stream_delta", "delta": "..."}` | Text chunk of the final response |

### Other events

| Frame | When |
|---|---|
| `{"type": "model_info", "model": "..."}` | Once per turn after the backend resolves a model (on the first successful chunk). May differ from the profile's configured model when a server/model was down and fallback picked another. Additive. |
| `{"type": "todo_updated", "session_id": "...", "tasks": [{"index": N, "text": "...", "status": "...", "validation": "..."}, ...]}` | Session todo list changed (auto-populated from the plan, or via the `todo` tool). Sub-agent todos are NOT emitted here. Additive. |
| `{"type": "compression_started", "context_tokens": N, "max_context_tokens": N}` | Immediately before context compression begins (UI can show a spinner) |
| `{"type": "context_compressed", "messages_before": N, "messages_after": N, "summary": "...", "context_tokens": N, "max_context_tokens": N}` | After context compression runs. `summary` is the produced summary text (rendered as a card in the TUI). |
| `{"type": "profile_switched", "profile_id": "...", "profile_name": "..."}` | When `switch_profile` tool succeeds |
| `{"type": "recall_update", "session_id": "...", "recall_id": "...", "call_type": "...", "trigger_at": "...", "status": "...", "action": "scheduled\|cancelled\|skipped\|fired\|rescheduled"}` | Recall state changed |
| `{"type": "mcp_status_update", "server_name": "...", "status": "connected\|disconnected", "tool_count": N, "error": "..."}` | MCP server connection status changed — broadcast to all sessions for toast notifications |
| `{"type": "terminal_output", "terminal_name": "...", "stream": "stdout\|stderr", "delta": "..."}` | Streamed stdout/stderr chunk from a background persistent terminal |
| `{"type": "terminal_closed", "terminal_name": "...", "reason": "explicit\|idle_timeout\|error\|shutdown\|session_ended"}` | A persistent terminal session ended |
| `{"type": "task_update", "task_id": "bt-...", "session_id": "...", "tool": "...", "status": "running\|completed\|failed\|cancelled", "result_preview": "...", "parent_tool_call_id": "...", "started_at": "...", "finished_at": "...", "subagent_tokens": N}` | A background task (tool call detached with `background: true`, see `docs/tasks.md`) changed state. Out-of-band: NOT part of the run's replay buffer. `parent_tool_call_id` links it to the tool card that spawned it. |
| `{"type": "message_accepted", "client_id": "..."}` | The server took ownership of a message that carried `client_id` and is starting its run. Sent before the first event, so a slow turn never looks like a lost message. Absent for messages without `client_id`. |
| `{"type": "message_queued", "position": N, "queue_len": N, "max": N, "dropped_total": N, "client_id": "..."}` | The user's message arrived while a run was active and was queued (not an error). It will execute after the current run finishes; `dropped_total` counts messages dropped when the queue was full, and `client_id` echoes the sender's id (nullable). |
| `{"type": "message_dropped", "client_ids": ["..."]}` | The queue overflowed and these messages were evicted — they will never run. Broadcast to every socket watching the session, so the tab that sent one can mark its bubble as lost. |
| `{"type": "heartbeat"}` | Periodic keepalive after 20 s of server silence — mid-run and on an idle socket alike. Also the client's liveness signal: silence past ~45 s means the socket is half-open |
| `{"type": "pong", "ts": N}` | Answer to a client `ping`, `ts` echoed untouched |
| `{"type": "session_sync", "session_id": "...", "profile_id": "..."}` | Client should reload session history from REST (`GET /sessions/{id}`) |

`session_sync` is sent in three situations:
1. On fresh connect when no run is active — in case the agent finished while the client was disconnected.
2. After a reconnect-and-replay completes — to ensure the client sees the fully saved response.
3. After a headless recall run finishes — so the client sees the full recall turn (recall user message + assistant response).

It carries the active `session_id` and `profile_id` so the client can sync its header / profile indicator without an extra round-trip.

---

## Stopping generation

`POST /sessions/{session_id}/stop`

Sets `_AgentRun.stop_event`. The agent checks this event:
- Before each LLM call
- During streaming (breaks out, calls `aclose()` on the generator)
- After tool execution

The client sends this via `fetch()`, not over the WebSocket, to avoid corrupting the WebSocket receive state.

Response: `{"ok": true}` if a run was active, `{"ok": false, "reason": "no active run"}` otherwise.

Every `tool_started` is closed by a `tool_call`, including on Stop: a tool
cancelled mid-run (a sub-agent included) reports its end with the result
`"Tool execution was cancelled."`, and the parent then sends its own synthetic
`"Tool execution was stopped by the user."` for the call it was running. Clients
still settle any card left pending at `stream_end` / `stream_stopped` / `error`
— a safety net, not the primary mechanism.

---

## Reconnection

If the client reconnects to an in-progress run (e.g. page reload mid-stream), `websocket_session()` detects the existing `_AgentRun` in `_runs` and replays the full event buffer before routing live events:

```
← stream_start
← replay_start   {"type": "replay_start", "count": N}
← ev_0 ... ev_N  (all buffered events replayed verbatim)
← replay_end     {"type": "replay_end"}
← (live events continue from here)
...
← session_sync   (after stream finishes — sync final saved state)
```

The client should suppress cursor animations and other in-progress effects while `replay_start`..`replay_end` is in flight.

If the client reconnects after the run has already finished, there is no active `_AgentRun`, so it receives only `session_sync` and must fetch history via REST.

**On the client side**, a socket that dies mid-stream leaves a half-written assistant message behind. That message must be dropped as soon as a new socket opens, before `session_sync` is handled: the server either replays the still-live run from scratch or answers with `session_sync` for a finished one, and a client that keeps the stale message (or treats itself as "still streaming") ignores both. The web client does exactly this in `useWebSocket.js` (`chat.dropStaleStream()` on `open`) and `chat.js` (`dropStaleStream`); the TUI drops everything the in-flight turn had appended (everything after `ChatModel._turn_start`) when `WsBridge` posts `WsResumed` on a successful connect.

---

## Run state management

`_runs: dict[str, _AgentRun]` — global dict of active runs, keyed by session ID.

`_AgentRun` holds:
- `task: asyncio.Task` — the running agent task
- `stop_event: asyncio.Event` — cooperative stop signal
- `subscribers: list[Queue]` — one queue per connected WebSocket client
- `events: list[dict]` — replay buffer; every serialised event dict emitted this turn

Events are broadcast to all subscribers **and** appended to `events`. When the run finishes, `_runs.pop(session_id)` is called from the `finally` block. The subscribe-then-note-count ordering guarantees no events are missed between the two steps (single-threaded async Python).
