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

Forced compression

{"type": "compact"}

Runs the context compressor immediately, bypassing the token threshold. The server streams back compression_startedcontext_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_startedtool_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).