# 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`.

### 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",
    "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.
- `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 `filesystem`, `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: ...]`).

### 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 rejects it with a WebSocket error. The client should wait for `stream_end` before sending the next message.

---

## 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"}` | If the user stopped generation |
| `{"type": "error", "message": "..."}` | On any unhandled error |

### 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|2|3, "label": "...", "is_subagent": bool}` | During planning phase — progress label for UI. `phase`: 1=analysis, 2=reflect, 3=plan |
| `{"type": "plan_ready", "plan": "...", "is_subagent": bool}` | Before tool-calling loop if `planning_enabled` and a plan was generated |

`planning_status` frames arrive during each planning phase (analysis → optional reflect → 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.

### 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": "heartbeat"}` | Periodic keepalive during long silent operations (every 20 s) |
| `{"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.

---

## 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.

---

## 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).
