Newer
Older
navi-1 / docs / websocket.md

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.

{"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

{
    "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 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: ...]).

Liveness probe

{"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

{"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 dropped silently and dropped_total in the frame grows. 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"} 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 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_queued", "position": N, "queue_len": N, "max": N, "dropped_total": N} 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.
{"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.


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


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