Skip to content

UI server and custom clients

nav ui-server runs nav as a local HTTP and WebSocket backend so you can drive the same agent (tools, model, project cwd) from a desktop shell, Electron app, Tauri window, or browser UI you build yourself. The default terminal workflow (nav with no subcommand) is unchanged.

This page is a step-by-step path to a working client. For every message type and field, see the canonical protocol: ui-server protocol (GitHub).

Prerequisites

  • nav installed and on your PATH.
  • Same configuration as the CLI: model, provider, API keys, and project settings apply to ui-server (Configuration). The server uses the current working directory as the project root (cwd is reported in session.ready and /health).
  • Treat the server as local-only: bind to 127.0.0.1 unless you know you need otherwise.

Step 1 — Start the server

From your project directory:

bash
nav ui-server

Optional host and port:

bash
nav ui-server --ui-host 127.0.0.1 --ui-port 7777

Environment variables: NAV_UI_HOST, NAV_UI_PORT.

Health check (optional):

bash
curl -s http://127.0.0.1:7777/health

You should see JSON including ok, protocolVersion, cwd, and threadCount. Other HTTP paths: GET / returns plain text identifying the server; WebSocket upgrades use /ws (below).

Step 2 — Open a WebSocket session

Connect to:

txt
ws://<host>:<port>/ws

On connect, the server sends session.ready with protocolVersion, model, provider, cwd, and sandbox. You do not have to send anything first.

You may send session.start (optional payload { protocolVersion?: number }) to receive session.ready again — useful if your client reconnects logic relies on a explicit handshake. The client protocolVersion field is currently not validated; the server always speaks the version it advertises in session.ready.

Close the socket with session.stop or by closing the WebSocket from the client.

Step 3 — Create threads (conversations)

Protocol v2 is multi-thread: each thread is one independent agent with its own message history. Messages on a single thread are serialized (one run at a time per thread). Different threads can run at the same time.

Send:

json
{ "type": "thread.create" }

Optional payload:

json
{
  "type": "thread.create",
  "payload": {
    "threadId": "<your-uuid>",
    "systemPromptPrefix": "You are a focused code reviewer..."
  }
}
  • If you omit threadId, the server generates one.
  • The server broadcasts thread.created to all connected WebSocket clients with { "threadId": "..." }.

List threads (metadata for sidebars):

json
{ "type": "thread.list" }

The requesting client receives thread.list with threads: threadId, createdAt, messageCount, isRunning.

Delete a thread:

json
{ "type": "thread.delete", "payload": { "threadId": "<id>" } }

On success, thread.deleted is broadcast. If the id is unknown, the server sends error with that threadId instead.

Edge case: If you send thread.create with a threadId that already exists, the server does not create a duplicate thread but still broadcasts thread.created with that id. Track thread ids in your UI or always use server-generated ids to avoid surprises.

Step 4 — Send chat and stream results

Send user text to a specific thread:

json
{
  "type": "message.user",
  "payload": { "threadId": "<id>", "text": "Read src/foo.ts and summarize it." }
}

Empty or whitespace-only text is ignored (no error).

Subscribe to server messages and route by threadId (and by type):

TypePurpose
assistant.deltaStreaming assistant text chunk
assistant.doneFinal assistant message for the turn
tool.callTool name and arguments; optional contextLabel (e.g. [Subagent name]) when the call runs inside a nested subagent
tool.resultShort summary; may include diff when there is a file diff
statusLifecycle and UI hints (phase — see below)
errorFailure; threadId may be absent for global errors

Assistant and tool events always include threadId. status and error may omit threadId for server-wide messages.

status.phase values

Your UI can use these (non-exhaustive; new phases may appear):

PhaseTypical meaning
running / idleAgent run started / finished
queuedUser message held until the current run finishes
promptServer is waiting for the next message.user on this thread (e.g. confirmation in /tasks add or /plan)
info / success / printInformational lines, success toasts, printable text
abortedRun was cancelled
interjectionQueued user input applied during a run
handoverContext handover in progress

Step 5 — Build a multi-thread UI

Broadcast model: Thread lifecycle and agent events are sent to every connected client. Your UI should filter by threadId (e.g. tabs, columns, or a board of cards).

Parallel orchestration: Create several threads, then send message.user to each with different threadIds — runs proceed concurrently across threads.

Suggested layout:

  • Sidebar or list from thread.list (isRunning for a spinner, messageCount for activity).
  • Main area keyed by threadId for transcript + tool timeline.
  • run.cancel per thread when the user stops generation.
  Client 1 ──┐
  Client 2 ──┼── WebSocket ──► ThreadManager ──► Thread A (history + queue)
  Client 3 ──┘                              └──► Thread B (history + queue)

Production clients should not assume they are the only WebSocket — ignore thread.created unless you just requested a new thread, or merge by threadId into your state.

Step 6 — Agent roles (systemPromptPrefix)

When creating a thread, optional systemPromptPrefix defines a custom role:

  • The string is prepended to the same operational system prompt nav uses in the terminal (tools, edit mode, exploration hints, ~/.config/nav/nav.md, .nav/nav.md, AGENTS.md, and skills).
  • Nav’s default one-line identity (“You are nav…”) is omitted when the prefix is non-empty after trim, so your prefix sets the persona instead.

Example prefixes:

  • “You are a security reviewer. Prefer concise findings and severity labels.”
  • “You are writing tests only; do not change production code unless asked.”

Role persistence: /clear and /init reload the system prompt from disk (so changes to nav.md, AGENTS.md, and skills apply) and keep your systemPromptPrefix. /plans split and /plans microsplit run their task-generation step with the same composed prompt, then clear history and restore that prompt so the thread returns to normal chatting with your role intact.

Cancellation and queued messages

  • run.cancel with { "threadId": "<id>" } aborts the active run on that thread (same abort path as the terminal).
  • If the user sends message.user while a run is still active, nav queues the text and emits status with `phase: "queued"**.

Interactive slash commands in ui-server

Flows that need typed confirmation use status with phase: "prompt" and a message explaining the prompt; the user’s reply is the next message.user on that thread. Supported in ui-server include /tasks add, /plan, /plans split, and /plans microsplit.

Not supported over ui-server (use the terminal REPL): /tasks run and /plans run — the server returns an error explaining they are terminal-only.

Hooks

See Hooks: in ui-server mode, stop hooks run after each completed agent turn; taskDone and planDone are terminal-only today.

Full protocol reference

Message shapes, examples, and notes are maintained in the repo:

docs/ui-server-protocol.md

Use that document when implementing parsers and when tracking protocol version changes.

Released under the MIT License.