# Navi API Reference

Base URL: `http://localhost:8099`

---

## REST API

### Health

#### `GET /health`

Server availability check. `status` is the worst of the `checks` entries
(`down > degraded > ok`); an embed or LLM failure degrades the service without
taking it down. Reads in-process state only — no LLM call — so it is cheap
enough for a 30 s poll (the embed probe is cached for 10 s).

When the history export is configured, the body also carries an informational
`export` object (`built`, `built_at`, `rows`, `age_seconds`, `stale`) — see
[`export.md`](export.md). It never affects `status`.

**Response `200`**
```json
{
  "status": "degraded",
  "service": "navi",
  "checks": {
    "embed": {"status": "ok", "ms": 14},
    "hive": {"status": "ok", "detail": "not configured"},
    "llm": {"status": "degraded", "detail": "running on a backup entry: default=qwen3.6:35b@home"}
  },
  "llm": {
    "configured": true,
    "degraded": true,
    "active": {
      "default": {
        "model": "qwen3.6:35b",
        "target": "home",
        "primary": false,
        "primary_model": "glm-5.3-flash:cloud"
      }
    },
    "targets": {
      "cloud": {"state": "cooling", "reason": "quota", "retry_in_sec": 42},
      "home": {"state": "ok"}
    },
    "missing_models": []
  },
  "embed": {"ok": true, "backend": "ollama:11434", "error": null, "ms": 14},
  "hive": {"configured": false}
}
```

- `checks.llm` is `ok` while every route is served by its primary entry and
  `degraded` as soon as one runs on a backup. The detail names the entries.
- `llm.targets` carries a state per `llm.json` target: `ok`, `down` (network),
  `cooling` (`quota` or `server`), `auth`. Non-`ok` states also carry
  `reason`, `retry_in_sec` and a truncated `detail`.
- `llm.missing_models` lists `(target, model)` pairs the host reported as 404.
- Without a configured backend the block is `{"configured": false}` and
  `checks.llm` reports `ok` / `not configured`.
- `embed` and `hive` at the top level are the pre-contract payloads, kept for
  existing tooling.

See [`config.md`](config.md#llm-routing-llmjson) for how the routing chain and
the background probe work.

#### `GET /health/embed`

Embedding model health check. Returns the first vector of a test string to verify the embed backend is responsive.

**Response `200`**
```json
{
  "status": "ok",
  "model": "nomic-embed-text",
  "dimensions": 768
}
```

---

### Auth

Full auth documentation: [`docs/auth.md`](auth.md). API token docs: [`docs/api_tokens.md`](api_tokens.md).

Set `NAVI_AUTH_ENABLED=false` to disable all auth checks. In that mode every request is treated as the local `anonymous` admin user and OAuth endpoints return `503` if called.

#### `GET /auth/login`

Redirect to gnexus-auth OAuth authorization endpoint. Sets PKCE + state internally.

**Query params**
- `return_to` — URL to redirect back to after login (default: `/`)
- `platform` — `browser` (default) or `android` (affects redirect after callback)

**Response `302`** → Location: gnexus-auth `/oauth/authorize`

---

#### `GET /auth/callback`

OAuth callback. Validates state, exchanges code for tokens, creates DB session.

**Query params**
- `code` — authorization code from gnexus-auth
- `state` — state parameter

**Response `302`**
- **Browser** → Location: `/` (with `Set-Cookie`)
- **Android** → Location: `/auth/mobile-done?sid=<session_id>`

**Errors**
- `400` — invalid state, PKCE failure, or token exchange failed
- `503` — OAuth is not configured (missing `gnexus_auth_client_id` or `gnexus_auth_client_secret`)

---

#### `GET /auth/mobile-done`

Bridge page for Android OAuth. Renders HTML that attempts an automatic deep-link back into the native app via Chrome Intent URL (`intent://...`), and falls back to a manual button for browsers that block automatic navigation to custom schemes (e.g. DuckDuckGo). Styled with the gnexus UI kit design system.

**Query params**
- `sid` — session id that will become the `navi_auth_session` cookie

**Response `200`** — HTML page

---

#### `POST /auth/logout`

Logout current user. Deletes DB session and clears cookie.

**Response `200`**
```json
{ "ok": true }
```

---

#### `GET /auth/me`

Return current authenticated user.

**Response `200`**
```json
{
  "id": "user-uuid",
  "email": "user@example.com",
  "display_name": "User Name",
  "username": "username",
  "first_name": "First",
  "last_name": "Last",
  "phone": "+1234567890",
  "birth_date": "1990-01-01",
  "country": "US",
  "city": "New York",
  "locale": null,
  "locale_effective": "en-US",
  "avatar_url": "https://...",
  "profile_url": "https://...",
  "role": "admin",
  "permissions": ["navi.sessions.read_all", "navi.memory.read_all"]
}
```

`locale` is the interface language chosen in Settings — `null` (or `"auto"`) means
"follow the account". `locale_effective` is the language the interface actually uses:
that choice, else the gnexus-auth account locale, else `en`. The account's own tag is
not part of this payload.

**Errors**
- `401` — not authenticated

---

#### `PATCH /auth/me`

Choose the interface language for the current user.

**Request**
```json
{ "locale": "ru" }
```

`en`, `uk`, `ru` (or a regional tag like `en-US`, which normalizes to `en`) set the
choice; `""` or `"auto"` clear it, so the account language applies again. The choice
lives in `navi_users.locale_override` and is untouched by the `user.profile_updated`
webhook, which only mirrors the account locale.

**Response `200`** — the same body as `GET /auth/me`

**Errors**
- `401` — not authenticated
- `422` — a language Navi does not ship
- `503` — the choice could not be stored

---

#### `GET /auth/status`

Return whether auth is enabled on the backend and whether OAuth is configured.

**Response `200`**
```json
{
  "enabled": true,
  "configured": true
}
```

- `enabled` — `NAVI_AUTH_ENABLED` value. When `false`, the server is in no-auth mode and every request is treated as the `anonymous` admin user.
- `configured` — `true` if `GNAUTH_CLIENT_ID` and `GNAUTH_CLIENT_SECRET` are both set. Always `false` when `enabled` is `false`.

---

### Profiles & Tools

#### `GET /agents/profiles`

List available agent profiles. Non-admin users do not see `is_admin_only` profiles — the
filter is the role gate described in [`auth.md`](auth.md#profile-visibility-is-a-role-gate-not-a-permission), so the list
differs by role rather than merely being trimmed of secrets.

Order is the profile load order (directory name). The webclient takes the **first** entry as
the default for a new session, so for a `user` that is the first of the restricted
profiles.

**Response `200`**
```json
[
  {
    "id": "assistant",
    "name": "Assistant",
    "description": "Everyday assistant for a regular user",
    "llm_backend": "ollama",
    "model": ["glm-5.3-flash:cloud", "gemma4:31b-cloud"],
    "temperature": 0.45,
    "top_k": null,
    "top_p": null,
    "max_iterations": 10,
    "iteration_budget_enabled": true,
    "think_enabled": true,
    "subagent_think_enabled": null,
    "tools": {
      "agent": {
        "native": ["todo", "scratchpad", "filesystem"],
        "mcp": {
          "navi-web": ["search"]
        }
      },
      "subagent": {
        "native": ["todo", "filesystem"],
        "mcp": {}
      }
    }
  }
]
```

#### `GET /agents/tools`

List all registered tools (built-in + user tools).

**Response `200`**
```json
[
  {
    "name": "mcp__navi-web__web_search",
    "description": "Search the web using DuckDuckGo.",
    "parameters": {"type": "object", "properties": {...}, "required": [...]}
  },
  {
    "name": "filesystem",
    "description": "Read, write and list files.",
    "parameters": {"type": "object", "properties": {...}, "required": [...]}
  }
]
```

---

#### `GET /agents/prompts`

Return the fully resolved system prompt for each profile (persona + profile system_prompt + context provider injections + one line per MCP server).

**Response `200`**
```json
{
  "assistant": "system prompt text...",
  "server_admin": "system prompt text..."
}
```

---

#### `GET /agents/mcp_servers`

Return all configured MCP servers with their resolved tools per profile.

**Response `200`**
```json
{
  "navi-web": {
    "connected": true,
    "tools": [
      {"name": "mcp__navi-web__web_search", "description": "..."},
      {"name": "mcp__navi-web__web_view", "description": "..."},
      {"name": "mcp__navi-web__http_request", "description": "..."}
    ],
    "instructions": "MANDATORY: Before answering ANY question..."
  }
}
```

---

### Sessions

#### `POST /sessions`

Create a new session.

**Auth**: requires authenticated user.

**Request body**
```json
{ "profile_id": "assistant" }
```

**Response `201`**
```json
{
  "session_id": "550e8400-e29b-41d4-a716-446655440000",
  "profile_id": "assistant",
  "created_at": "2026-04-10T18:00:00+00:00"
}
```

**Errors**
- `401` — not authenticated
- `403` — the profile is `is_admin_only` and the caller's role is not `admin`
  (`{"detail": "Profile '<id>' requires admin access"}`)
- `404` — profile not found

---

#### `GET /sessions`

List all sessions sorted by activity (pinned first).

**Auth**: requires authenticated user.

**Query params**
| Param | Default | Description |
|---|---|---|
| `limit` | `50` | Page size |
| `offset` | `0` | Items to skip |
| `profile_id` | — | Filter by profile |
| `search` | — | Full-text search over names/previews |
| `special` | — | Session type filter. Omitted — no filtering (all sessions). `false` — service (`special=True`) sessions excluded, **the webclient default**. `true` — service sessions only |

**Response `200`** (when pagination params provided)
```json
{
  "items": [
    {
      "session_id": "550e8400-...",
      "profile_id": "assistant",
      "name": "Research task",
      "message_count": 12,
      "preview": "Last 60 chars of the most recent message",
      "pinned": false,
      "created_at": "2026-04-10T15:00:00+00:00",
      "last_active": "2026-04-10T18:00:00+00:00"
    }
  ],
  "limit": 50,
  "offset": 0,
  "has_more": true,
  "next_offset": 50
}
```

**Response `200`** (plain list when no pagination params)
```json
[
  {
    "session_id": "550e8400-...",
    "profile_id": "assistant",
    "name": "Research task",
    "message_count": 12,
    "preview": "Last 60 chars of the most recent message",
    "pinned": false,
    "created_at": "2026-04-10T15:00:00+00:00",
    "last_active": "2026-04-10T18:00:00+00:00"
  }
]
```

`name` is `null` until `POST /sessions/{id}/generate-name` is called.

---

#### `GET /sessions/{session_id}`

Full session with message history (display history — never compressed).

**Auth**: requires authenticated user (or ownership of the session).

**Response `200`**
```json
{
  "session_id": "550e8400-...",
  "profile_id": "assistant",
  "name": "Research task",
  "context_token_count": 4913,
  "max_context_tokens": 65536,
  "created_at": "...",
  "last_active": "...",
  "messages": [
    {
      "role": "user",
      "content": "Hello",
      "created_at": "2026-04-10T18:00:00+00:00"
    },
    {
      "role": "assistant",
      "content": "Hi. How can I help?",
      "created_at": "2026-04-10T18:00:05+00:00"
    },
    {
      "role": "assistant",
      "tool_calls": [
        {
          "id": "abc123",
          "name": "mcp__navi-web__web_search",
          "arguments": { "query": "..." }
        }
      ]
    },
    {
      "role": "tool",
      "content": "tool result",
      "tool_call_id": "abc123",
      "name": "mcp__navi-web__web_search"
    }
  ]
}
```

Message fields (`role` is always present, others by availability):

| Field          | Type                  | Description |
|----------------|----------------------|-------------|
| `role`         | `user\|assistant\|tool\|system` | Message author |
| `content`      | `string\|null`       | Text content |
| `images`       | `string[]`           | Base64 images (user/assistant) |
| `tool_calls`   | `ToolCall[]`         | Tool invocations (assistant) |
| `tool_call_id` | `string`             | ID of the call this result belongs to (tool) |
| `name`         | `string`             | Tool name (tool messages) |
| `thinking`     | `string\|null`       | LLM reasoning captured during a tool-calling turn |
| `is_plan`      | `bool`               | Planning phase output — rendered as a plan card, not text |
| `is_compression` | `bool`             | Marker injected when context compression ran |
| `is_summary`   | `bool`               | Summary message replacing compressed history |
| `created_at`   | `string` (ISO 8601)  | Creation time |
| `elapsed_seconds` | `number\|null`   | Time to complete the turn (final assistant message) |
| `tool_call_count` | `number\|null`   | Number of tool calls in the turn |
| `token_count`  | `number\|null`       | Tokens used in the turn |

**Errors**
- `404` — session not found

---

#### `GET /sessions/{session_id}/meta`

The session head without the message payload — what the webclient fetches
before paging history.

`max_context_tokens` is the window the agent actually holds this session's
prompts to: the active entry of the profile's `llm.json` route, then the
profile's own `context_window`, then the global `OLLAMA_NUM_CTX`. It is not
always the global default.

---

#### `GET /sessions/{session_id}/info`

Everything the session-info dialog shows, in one authenticated call: the
session head, the profile's live LLM route and the run state. Reads in-process
state only — the background health probe keeps it current.

**Response `200`**
```json
{
  "session_id": "550e8400-...",
  "name": "Research task",
  "profile_id": "server_admin",
  "profile_name": "Server Administrator",
  "created_at": "...",
  "last_active": "...",
  "message_count": 3049,
  "context_token_count": 51013,
  "max_context_tokens": 131072,
  "archive_threshold": 2050,
  "compression": {
    "enabled": true,
    "threshold": 0.9,
    "keep_recent": 4,
    "summary_max_tokens": 6000
  },
  "llm": {
    "configured": true,
    "route": "default",
    "models": ["glm-5.3-flash:cloud"],
    "active": {
      "model": "glm-5.3-flash:cloud",
      "target": "cloud",
      "host": "https://ollama.com",
      "primary": true,
      "context_window": 131072
    },
    "chain": [
      {"model": "glm-5.3-flash:cloud", "target": "cloud", "host": "https://ollama.com",
       "primary": true, "state": "ok"},
      {"model": "qwen3.6:35b-128k", "target": "home", "host": "http://192.168.1.129:11434",
       "primary": false, "state": "degraded", "reason": "connect failed"}
    ]
  },
  "live": {"running": false, "clients": 1},
  "recall": {
    "id": "r1",
    "call_type": "recurring",
    "trigger_at": "2026-11-02T14:00:00+00:00",
    "interval_seconds": 1800,
    "internal_comment": "Check build logs",
    "additional_context_message": "Read /tmp/build.log...",
    "status": "pending",
    "created_at": "2026-11-02T13:30:00+00:00"
  }
}
```

`llm.configured` is `false` when the deployment has no `llm.json` routes; the
profile's `model` list is then all there is to show. Chain `state` is `ok`,
`degraded` (with `reason`) or `missing` (the target up but the model absent).
`live.running` covers a turn started from any tab; `live.clients` counts the
WebSocket clients attached to the session. `recall` is the session's pending
scheduled recall, or `null` — the same object `GET /sessions/{id}/recall`
returns.

**Errors**
- `404` — session not found
- `403` — not the owner and no `navi.sessions.read_all` permission

---

#### `GET /sessions/{session_id}/recall`

Get the pending recall for a session, if any. A session carries at most one
pending recall, and the answer always has the same shape — the recall wrapped in
`recall`, or `null` when nothing is scheduled.

**Response `200`**
```json
{
  "recall": {
    "id": "r1",
    "call_type": "once",
    "trigger_at": "2026-05-16T14:00:00+00:00",
    "interval_seconds": null,
    "internal_comment": "Check build logs",
    "additional_context_message": "Read /tmp/build.log...",
    "status": "pending",
    "created_at": "2026-11-02T13:30:00+00:00"
  }
}
```

**Response `200` (no pending recall)**
```json
{ "recall": null }
```

**Errors**
- `404` — session not found

---

#### `DELETE /sessions/{session_id}/recall`

Cancel the pending recall for this session.

**Response `200`**
```json
{ "ok": true }
```

**Errors**
- `404` — session not found

---

#### `POST /sessions/{session_id}/recall/skip`

Skip the next occurrence of a recurring recall (advances `trigger_at` by `interval_seconds`).

**Response `200`**
```json
{ "ok": true }
```

**Errors**
- `404` — session not found
- `400` — no recurring pending recall

---

#### `DELETE /sessions/{session_id}`

Delete a session and its files.

**Response `204`** — no body

**Errors**
- `404` — session not found

---

#### `PATCH /sessions/{session_id}/pin`

Pin or unpin a session.

**Request body**
```json
{ "pinned": true }
```

**Response `200`**
```json
{ "session_id": "...", "pinned": true }
```

---

#### `POST /sessions/{session_id}/generate-name`

Generate a short display name for a session from its message history.
Called automatically by the client after the first exchange. No-op if the session already has a name.

**Response `200`**
```json
{ "name": "Web search for recipes" }
```

Returns `{"name": null}` if there are no user messages yet.

**Errors**
- `404` — session not found

---

#### `GET /sessions/{session_id}/context`

LLM context (what the model actually sees). May differ from `messages` — compressed history replaces old turns with a summary. Debug endpoint.

**Auth**: requires `admin` role.

**Response `200`**
```json
{
  "session_id": "...",
  "profile_id": "assistant",
  "message_count": 8,
  "total_chars": 4200,
  "context": [ ...same format as messages... ]
}
```

**Errors**
- `403` — not admin

---

#### `GET /sessions/{session_id}/planning`

All planning phase debug logs for the session. Each entry is one planning run.

**Auth**: requires `admin` role.

**Response `200`**
```json
{ "session_id": "...", "logs": [ { "phase": "...", "output": "..." }, ... ] }
```

**Errors**
- `403` — not admin

---

#### `GET /sessions/{session_id}/tasks`

List the session's background tasks (TaskManager jobs). `task_update` WS
events are not replayed on reconnect, so clients fetch this snapshot to
rebuild their backgrounds view after a reload.

**Response `200`**
```json
{
  "session_id": "...",
  "tasks": [
    {
      "task_id": "bt-ab12cd34",
      "tool": "terminal",
      "status": "running",
      "args_summary": "sleep 60",
      "parent_tool_call_id": "tc-1",
      "started_at": "2026-04-10T18:00:00+00:00",
      "finished_at": null,
      "subagent_tokens": null,
      "result_preview": ""
    }
  ]
}
```

**Errors**
- `404` — session not found

---

#### `GET /sessions/{session_id}/content`

List published session content (artifacts registered via `content_publish` tool).

**Response `200`**
```json
{
  "content": [
    {
      "id": "...",
      "filename": "report.html",
      "path": "/abs/path/to/content/...",
      "size": 102400,
      "content_type": "text/html",
      "created_at": "2026-04-10T18:00:00+00:00"
    }
  ]
}
```

**Errors**
- `404` — session not found

---

#### `POST /sessions/{session_id}/files`

Upload a file for a session. Call before sending a message to attach the file.

**Request**: `multipart/form-data`, field `file`.

**Limits**
- Max size: 200 MB
- Forbidden extensions: `.exe`, `.dll`, `.so`, `.sh`, `.bat`, `.cmd`, `.ps1`, `.vbs`, `.bin`, `.elf`, and other executable formats
- Duplicate filenames get a numeric suffix

**Response `201`**
```json
{
  "name": "report.pdf",
  "size": 102400,
  "path": "/abs/path/to/session_files/550e8400-.../report.pdf",
  "content_type": "application/pdf"
}
```

**Errors**
- `400` — forbidden extension
- `404` — session not found
- `413` — file exceeds limit

---

#### `GET /sessions/{session_id}/files/{filename}`

Download or view an uploaded file. Images, audio, video, PDF, HTML and text/source files are served inline; everything else as an attachment. The type comes from `navi/content_types.py` — extension first, then the file's own signature — rather than from the host's `/etc/mime.types`, which calls a TypeScript `.ts` file a video stream.

**Query params**
- `download` — force attachment download regardless of content type

**Response `200`** — file bytes

**Errors**
- `403` — path traversal attempt
- `404` — session or file not found

---

#### `GET /sessions/{session_id}/files`

List all files and directories in the session's file directory (recursive, depth 10).

**Response `200`**
```json
{ "session_id": "...", "files": [{"name": "...", "size": N, "is_dir": false, "path": "...", "content_type": "audio"}, ...] }
```

**Errors** — `404` session not found; `403` no access.

---

#### `GET /sessions/{session_id}/todos`

Return the session's current todo list (the parent agent's plan). Sub-agent todos live in isolated KV rows and are not exposed here. Used by the TUI to seed the side panel on attach/switch.

**Response `200`**
```json
{ "session_id": "...", "tasks": [{"index": 0, "text": "...", "status": "pending", "validation": "..."}, ...] }
```

**Errors** — `404` session not found; `403` no access.

---

#### `GET /sessions/{session_id}/messages/archive`

Return older archived messages for scroll-up (lazy history loading), paginated by `sequence_number`.

**Query params** — `before_seq` (int, optional — load messages older than this sequence number), `limit` (int, default 50, 1–200).

**Response `200`**
```json
{ "items": [<Message>, ...], "has_more": true, "next_before_seq": 123 }
```

**Errors** — `404` session not found; `403` no access.

---

#### `POST /sessions/{session_id}/stop`

Cooperatively stop an in-progress generation for the session. Sent via `fetch()` (not over the WebSocket) to avoid corrupting the WebSocket receive state.

**Response `200`**
```json
{ "ok": true }          // a run was active and signalled to stop
{ "ok": false, "reason": "no active run" }
```

---

#### `POST /sessions/{session_id}/share`

Issue the session's public read-only link. The token is a capability: whoever holds the
link reads this chat's history without a Navi account, and cannot do anything else. One
share per session — calling again hands back the same link rather than multiplying
exposure.

Only the owner (or an admin) may issue it. `navi.sessions.read_all` lets staff *read*
another user's session; it does not let them publish it. With auth disabled only ownerless
(local) sessions can be shared.

**Response `200`**
```json
{ "token": "…", "url": "https://navi.example/#share=…", "created_at": "2026-11-08T14:00:00+00:00" }
```

**Errors**
- `403` — not the owner
- `404` — session not found
- `409` — the session has no saved messages yet (lazily persisted session)

---

#### `GET /shared/{token}` — public, no auth

The shared chat's head plus its newest page of display history. This is everything the
token unlocks: no session list, no artifacts panel, no file listing, no LLM context, no
write path. The payload deliberately omits `session_id` and `user_id`.

**Query params** — `limit` (int, default 200, 1–200).

**Response `200`**
```json
{
  "name": "A chat",
  "profile_id": "assistant",
  "profile_name": "Assistant",
  "created_at": "2026-11-08T13:00:00+00:00",
  "last_active": "2026-11-08T14:00:00+00:00",
  "messages": [<Message>, ...],
  "has_more": true,
  "next_before_seq": 42
}
```

**Errors** — `404` for an unknown token and for a deleted session alike, so a link cannot
be used to probe for sessions.

---

#### `GET /shared/{token}/messages` — public, no auth

Older page of the same history, for scroll-up loading.

**Query params** — `before_seq` (int, optional), `limit` (int, default 150, 1–200).

**Response `200`**
```json
{ "messages": [<Message>, ...], "has_more": false, "next_before_seq": 7 }
```

**Lifetime** — the link never expires and there is no revoke endpoint, by decision.
Deleting the session removes its share row (FK cascade) and the link stops resolving.
Messages may contain artifact URLs of the form `/api/sessions/{session_id}/files/{name}`;
those are already public by unguessable session id, so sharing does not widen that surface
but does place such URLs in front of the recipient.

---

### Messages (non-streaming)

#### `POST /sessions/{session_id}/messages`

Send a message and receive a response synchronously (no streaming). Blocks until the full agent loop completes.

**Request body**
```json
{ "content": "How many stars are in the galaxy?" }
```

**Response `200`**
```json
{ "role": "assistant", "content": "Estimates range from 100 to 400 billion." }
```

**Errors**
- `404` — session not found
- `500` — agent error or iteration limit exceeded

> For production clients prefer WebSocket — it provides streaming, tool progress, and model reasoning.

---

### API Tokens

Headless client authentication. See [`docs/api_tokens.md`](api_tokens.md) for full details.

#### `POST /api-tokens`

Create a new API token. Plain token is returned **only in this response**.

**Auth**: requires authenticated user.

**Request**
```json
{ "name": "Smart Watch" }
```

**Response `200`**
```json
{
  "id": 1,
  "name": "Smart Watch",
  "token": "nav_aB3xYz9WqLmNpQrStUvXyZaBCdEfGhIjKlMnOpQrStUvX",
  "token_prefix": "nav_aB3xYz9W…",
  "created_at": "2026-05-24T10:00:00+00:00",
  "last_used_at": null
}
```

---

#### `GET /api-tokens`

List active (non-revoked) tokens for the current user. Does not expose plain tokens.

**Auth**: requires authenticated user.

**Response `200`**
```json
{
  "items": [
    {
      "id": 1,
      "name": "Smart Watch",
      "token_prefix": "nav_aB3xYz9W…",
      "created_at": "2026-05-24T10:00:00+00:00",
      "last_used_at": "2026-05-24T12:00:00+00:00"
    }
  ]
}
```

---

#### `DELETE /api-tokens/{token_id}`

Revoke (soft-delete) a token belonging to the current user.

**Auth**: requires authenticated user.

**Response `204`** — no body

**Errors**
- `404` — token not found or does not belong to user

---

### History export

Two endpoints, both requiring `Authorization: Bearer <NAVI_EXPORT_TOKEN>`
(machine credential, not user auth; `401` for missing and wrong alike, `503`
when the export is not configured or nothing has been built). Full design,
corpus rules and threat model: [`export.md`](export.md).

#### `GET /export/v1/manifest`

Describes the current snapshot without transferring it.

**Response `200`**
```json
{
  "export_version": 1,
  "built_at": "2026-11-04T12:00:00+00:00",
  "content_id": "9f2c…",
  "file": {"name": "messages-v1.jsonl.gz.age", "bytes": 5242880, "sha256": "…"},
  "rows": 23672,
  "sessions": 1177,
  "sessions_total": 1177,
  "text_bytes": 14680064,
  "fields": ["v", "session", "seq", "role", "content", "ts"],
  "roles": ["user", "assistant"],
  "window": {"from": "…", "to": "…", "truncated": false, "max_bytes": 157286400},
  "age_recipient": "age1…"
}
```

---

#### `GET /export/v1/messages`

The age-encrypted gzip-JSONL snapshot, streamed from disk.

**Headers**: `ETag` (from `content_id`), `X-Export-Content-Id`, `X-Export-Built-At`

**Response `304`** — empty body, when `If-None-Match` carries the current `ETag`

---

## WebSocket

### `WS /ws/sessions/{session_id}`

Main channel for real-time agent interaction. Supports text streaming, thinking streaming, tool events, file and image attachment.

**Connect**: if the session is not found, the server closes with code `4004`.

**On connect**: the server immediately sends `session_sync` (no active run) or starts the reconnect replay flow (run in progress).

---

### Client → Server

All client messages are JSON objects.

#### Send a message

```json
{
  "type": "message",
  "content": "Message text",
  "images": ["base64string...", "..."],
  "files": [
    { "name": "report.pdf", "size": 102400, "path": "session_files/.../report.pdf" }
  ]
}
```

| Field     | Required | Description |
|-----------|----------|-------------|
| `type`    | yes      | `"message"` (a user turn) or `"compact"` (force context compression now — server streams `compression_started` → `context_compressed`, no `stream_start`; rejected if a run is active) |
| `content` | yes      | Message text (non-empty), required for `"message"` |
| `images`  | no       | Base64 image list. Max 8 images, 50 MB total payload. Both raw base64 and `data:image/...;base64,...` are accepted — server strips the prefix. Each image is written into the session directory as `image_N.<ext>` and the notice naming those paths goes into the hidden context copy; see [sessions.md](sessions.md#images-pasted-into-the-chat) |
| `files`   | no       | Files uploaded via `POST /sessions/{id}/files`. Server appends their paths to the message content |

---

### Server → Client

Events arrive in the order they are emitted.

#### `stream_start`
```json
{ "type": "stream_start" }
```
Processing started. Client should block input.

---

#### `thinking_delta`
```json
{ "type": "thinking_delta", "delta": "reasoning fragment..." }
```
Streaming chunk of model reasoning. Accumulate until `thinking_end`.

---

#### `thinking_end`
```json
{ "type": "thinking_end" }
```
Reasoning phase complete. Next will be `stream_delta` or tool calls.

---

#### `turn_thinking`
```json
{
  "type": "turn_thinking",
  "thinking": "full reasoning text...",
  "is_subagent": false
}
```
Complete reasoning block from a tool-calling turn. Not streamed — arrives whole.
`is_subagent: true` means this reasoning came from a subagent inside `spawn_agent`.

---

#### `planning_status`
```json
{
  "type": "planning_status",
  "phase": "analysis",
  "label": "Analysing request...",
  "is_subagent": false
}
```
Progress update during the planning phase. `phase` is one of `analysis`, `reflect`, `plan`.
`is_subagent: true` — route into the spawn_agent card, not the top-level UI.

---

#### `plan_ready`
```json
{
  "type": "plan_ready",
  "plan": "1. Step one\n2. Step two\n...",
  "is_subagent": false
}
```
Planning complete — full step list. Rendered as a collapsible plan card.
`is_subagent: true` — route into the spawn_agent card.

---

#### `tool_started`
```json
{
  "type": "tool_started",
  "tool": "mcp__navi-web__web_search",
  "args": { "query": "weather in moscow" },
  "is_subagent": false
}
```
Agent started executing a tool. Arrives before execution completes — show a spinner.
`is_subagent: true` — call from a subagent.

---

#### `tool_call`
```json
{
  "type": "tool_call",
  "tool": "mcp__navi-web__web_search",
  "args": { "query": "weather in moscow" },
  "result": "Today +12°C, cloudy.",
  "success": true,
  "is_subagent": false
}
```
Tool finished. Arrives after `tool_started` with the same `tool` and `args`.
`success: false` — tool returned an error.

---

#### `stream_delta`
```json
{ "type": "stream_delta", "delta": "response fragment..." }
```
Streaming chunk of the final text response. Accumulate into a string.

---

#### `stream_end`
```json
{
  "type": "stream_end",
  "content": "full response text",
  "context_tokens": 4913,
  "max_context_tokens": 65536,
  "elapsed_seconds": 12.4,
  "tool_call_count": 3,
  "token_count": 1842
}
```
Agent finished. `content` is the full accumulated text (duplicates the sum of `stream_delta`). Client should unblock input.

---

#### `stream_stopped`
```json
{ "type": "stream_stopped" }
```
Generation was stopped by `POST /sessions/{id}/stop`.

---

#### `profile_switched`
```json
{
  "type": "profile_switched",
  "profile_id": "server_admin",
  "profile_name": "Server Administrator"
}
```
Agent switched profile via `switch_profile` tool. New profile takes effect on the **next** user message. Client should update the profile indicator. Arrives during the stream — before `tool_call` for `switch_profile`.

---

#### `context_compressed`
```json
{
  "type": "context_compressed",
  "messages_before": 42,
  "messages_after": 12,
  "summary": "User asked about..."
}
```
Context was automatically compressed (triggers at ≥90% of `OLLAMA_NUM_CTX`, or on demand via `{"type":"compact"}`). `summary` is the produced summary text. Informational.

---

#### `heartbeat`
```json
{ "type": "heartbeat" }
```
Keepalive sent every 20 s of server silence — mid-run and on an idle socket alike. It is also the client's liveness signal: a socket that stops delivering frames past ~45 s is half-open and must be reopened. A client may ignore the frame itself, but not its absence.

---

#### `pong`
```json
{ "type": "pong", "ts": 1748000000000 }
```
Answer to a client `{"type": "ping", "ts": ...}` frame; `ts` is echoed untouched so the client can measure the round trip.

---

#### `recall_update`
```json
{
  "type": "recall_update",
  "session_id": "550e8400-...",
  "recall_id": "r1",
  "call_type": "once",
  "trigger_at": "2026-05-16T14:00:00+00:00",
  "status": "pending",
  "action": "scheduled"
}
```
Recall state changed. Sent when a recall is scheduled, cancelled, skipped, fired, or rescheduled. Client should refresh the recall banner via `GET /sessions/{id}/recall`.

**Fields**
| Field | Type | Description |
|-------|------|-------------|
| `session_id` | `string` | Affected session |
| `recall_id` | `string\|null` | Recall ID (null for cancel without ID) |
| `call_type` | `string\|null` | `once`, `recurring`, `immediate` |
| `trigger_at` | `string\|null` | Next trigger time (ISO 8601) |
| `status` | `string\|null` | `pending`, `fired`, `cancelled` |
| `action` | `string\|null` | `scheduled`, `cancelled`, `skipped`, `fired`, `rescheduled` |

---

#### `model_info`
```json
{ "type": "model_info", "model": "qwen3.6:35b", "target": "home", "fallback": true }
```
Emitted once per turn after the backend resolves a model (may differ from the profile's configured model when a backup entry answered). `target` is the `llm.json` target name and `fallback` is `true` when the route's primary is not the one serving — `target`/`fallback` are only present when set. Additive.

---

#### `todo_updated`
```json
{ "type": "todo_updated", "session_id": "...", "tasks": [{"index": 0, "text": "...", "status": "in_progress", "validation": "..."}] }
```
Session todo list changed (auto-populated from the plan, or via the `todo` tool). Sub-agent todos are NOT emitted here. Additive.

---

#### `compression_started`
```json
{ "type": "compression_started", "context_tokens": 46000, "max_context_tokens": 65536 }
```
Emitted immediately before context compression begins — client can show a spinner.

---

#### `task_update`
```json
{
  "type": "task_update",
  "task_id": "bt-1a2b3c4d",
  "session_id": "...",
  "tool": "terminal",
  "status": "running",
  "result_preview": "",
  "parent_tool_call_id": "",
  "started_at": "2026-04-30T10:00:00+00:00",
  "finished_at": null,
  "subagent_tokens": null
}
```
A background task (a tool call detached with `background: true`, see `docs/tasks.md`) changed state. `status` is `running | completed | failed | cancelled`. Out-of-band: NOT part of the run's replay buffer — a reconnecting client learns task state via the `tasks` tool/notes, not replay. `parent_tool_call_id` (when non-empty) binds the update to the tool card that started the task, so the client can attach sub-agent progress to it.

---

#### `message_queued`
```json
{ "type": "message_queued", "position": 1, "queue_len": 1, "max": 5, "dropped_total": 0 }
```
The user's message arrived while a run was active and was queued instead of erroring. It will execute after the current run finishes. `queue_len` counts queued messages, `max` is `MESSAGE_QUEUE_MAX`, `dropped_total` grows when the queue was full and the oldest message was dropped.

---

#### `session_sync`
```json
{ "type": "session_sync", "session_id": "...", "profile_id": "..." }
```
Client must reload session history from `GET /sessions/{id}`. Carries the active `session_id` and `profile_id`. Sent:
1. On connect when no run is active (agent may have finished while disconnected).
2. After a reconnect-replay flow completes (ensures client sees the fully saved response).
3. After a headless recall run finishes (so the client sees the recall user message + assistant response).

---

#### `replay_start`
```json
{ "type": "replay_start", "count": 14 }
```
About to replay `count` buffered events from a mid-stream reconnect.
Client should suppress cursor animations and in-progress effects during replay.

---

#### `replay_end`
```json
{ "type": "replay_end" }
```
Replay complete. Live events will follow.

---

#### `error`
```json
{ "type": "error", "message": "Session not found" }
```
Processing error. Stream may or may not continue after this.

---

### Typical event sequences

**Simple question, no tools:**
```
stream_start
thinking_delta × N   (if model has thinking enabled)
thinking_end
stream_delta × N
stream_end
```

**Request with tool calls:**
```
stream_start
turn_thinking         (reasoning before tool selection, if any)
tool_started
tool_call
turn_thinking         (before next tool, if any)
tool_started
tool_call
thinking_delta × N   (final response reasoning)
thinking_end
stream_delta × N
stream_end
context_compressed    (optional, if context was near full)
```

**Request with planning enabled:**
```
stream_start
planning_status       (phase: analysis)
planning_status       (phase: plan)
plan_ready
turn_thinking
tool_started
tool_call
...
stream_end
```

**Request with subagent (`spawn_agent`):**
```
stream_start
tool_started          (spawn_agent, is_subagent=false)
  turn_thinking       (is_subagent=true)
  planning_status     (is_subagent=true, if subagent has planning)
  plan_ready          (is_subagent=true, if subagent has planning)
  tool_started        (subagent tool, is_subagent=true)
  tool_call           (is_subagent=true)
tool_call             (spawn_agent done, is_subagent=false)
stream_delta × N
stream_end
```

**Reconnect mid-stream:**
```
stream_start
replay_start          {"count": N}
ev_0 ... ev_N-1       (buffered events replayed verbatim)
replay_end
(live events continue)
...
stream_end
session_sync
```

**Profile switch (`switch_profile`):**
```
stream_start
tool_started          (switch_profile)
profile_switched      (client updates UI here — before tool_call)
tool_call             (switch_profile done)
stream_delta × N
stream_end
```

---

### Admin

All admin endpoints require `admin` role or specific permissions.

#### `GET /admin/sessions`

All sessions across all users. Supports pagination, search, and sorting.

**Query params**
| Param | Default | Description |
|---|---|---|
| `limit` | `50` | Page size |
| `offset` | `0` | Items to skip |
| `search` | — | Filter by session_id, name, user_id or profile_id (case-insensitive) |
| `sort_by` | `last_active` | `last_active`, `created_at`, `name`, `profile_id`, `user_id`, `pinned` |
| `sort_order` | `desc` | `asc` or `desc` |

**Response `200`**
```json
{
  "total": 128,
  "limit": 50,
  "offset": 0,
  "items": [
    {
      "session_id": "...",
      "profile_id": "assistant",
      "user_id": "user-uuid",
      "name": "Research task",
      "message_count": 12,
      "pinned": false,
      "created_at": "2026-05-04T10:00:00+00:00",
      "last_active": "2026-05-04T10:30:00+00:00"
    }
  ]
}
```

---

#### `GET /admin/users`

All registered `navi_users`.

**Response `200`**
```json
[
  {
    "id": "user-uuid",
    "email": "user@example.com",
    "display_name": "User Name",
    "role": "admin",
    "permissions": ["navi.sessions.read_all"],
    "created_at": "...",
    "updated_at": "..."
  }
]
```

---

#### `GET /admin/memory`

All memory facts (global view). Requires `navi.memory.read_all`. Supports pagination, search, and sorting.

**Query params**
| Param | Default | Description |
|---|---|---|
| `limit` | `50` | Page size |
| `offset` | `0` | Items to skip |
| `search` | — | Filter by key, value or category (case-insensitive) |
| `sort_by` | `updated_at` | `updated_at`, `category`, `key`, `confidence`, `source` |
| `sort_order` | `desc` | `asc` or `desc` |
| `user_id` | — | If set, return only facts for this user instead of global view |

**Response `200`**
```json
{
  "total": 128,
  "limit": 50,
  "offset": 0,
  "items": [
    {
      "id": "...",
      "category": "profile",
      "key": "name",
      "value": "Eugene",
      "source": "conversation",
      "confidence": 90,
      "updated_at": "2026-05-04T10:00:00+00:00"
    }
  ]
}
```

---

#### `GET /admin/profiles`

All profiles including admin-only ones. Requires `navi.profiles.manage`.

---

#### `GET /admin/sessions/{session_id}`

Full session details including messages. Bypasses ownership check.

**Response `200`**
```json
{
  "session_id": "...",
  "profile_id": "assistant",
  "user_id": "user-uuid",
  "name": "Research task",
  "messages": [...],
  "context_token_count": 4913,
  "max_context_tokens": 65536,
  "pinned": false,
  "created_at": "...",
  "last_active": "..."
}
```

**Errors**
- `404` — session not found

---

#### `DELETE /admin/sessions/{session_id}`

Delete any session (bypasses ownership). Also deletes session files.

**Response `204`** — no body

**Errors**
- `404` — session not found

---

#### `GET /admin/users/{user_id}`

Single user details.

**Response `200`**
```json
{
  "id": "user-uuid",
  "email": "user@example.com",
  "display_name": "User Name",
  "role": "admin",
  "permissions": ["navi.sessions.read_all"],
  "created_at": "...",
  "updated_at": "..."
}
```

**Errors**
- `404` — user not found

---

#### `GET /admin/users/{user_id}/sessions`

Sessions owned by a specific user.

**Response `200`**
```json
[
  {
    "session_id": "...",
    "profile_id": "assistant",
    "name": "Research task",
    "message_count": 12,
    "pinned": false,
    "created_at": "...",
    "last_active": "..."
  }
]
```

---

#### `POST /admin/ollama/clear-blacklists`

Forget every recorded LLM target failure — down, cooling (quota/5xx), auth and missing models — and restart each route at its primary entry. Useful after fixing a key or bringing a host back by hand, instead of waiting for the background probe. Degraded targets are probed once a minute anyway; this just makes it immediate.

**Response `204`** — no body

---

#### `GET /admin/profiles/{profile_id}`

Full profile configuration including system prompt.

**Response `200`**
```json
{
  "id": "assistant",
  "name": "Assistant",
  "description": "Everyday assistant for a regular user",
  "short_description": "...",
  "full_description": {"specialization": "...", "when_to_use": "...", "key_tools": [...]},
  "system_prompt": "...",
  "subagent_system_prompt": "...",
  "llm_backend": "ollama",
  "model": ["gemma4:31b-cloud", "gemma4:26b-a4b-it-q4_K_M"],
  "temperature": 0.65,
  "top_k": null,
  "top_p": null,
  "num_thread": null,
  "max_iterations": 10,
  "planning_phase1_enabled": true,
  "planning_phase3_enabled": true,
  "think_enabled": true,
  "iteration_budget_enabled": true,
  "goal_anchoring_enabled": true,
  "goal_anchoring_interval": 5,
  "anti_stall_enabled": true,
  "anti_stall_threshold": 8,
  "step_validation_enabled": false,
  "tools": {
    "agent": {
      "native": ["todo", "scratchpad", "filesystem"],
      "mcp": {
        "navi-web": ["search"]
      }
    },
    "subagent": {
      "native": ["todo", "filesystem"],
      "mcp": {}
    }
  },
  "context_providers": [],
  "is_admin_only": false
}
```

**Errors**
- `404` — profile not found

---

#### `PUT /admin/profiles/{profile_id}`

Update profile configuration on disk and in-memory. Accepts partial updates — only provided fields are modified.

Writes `config.json` back through `save_profile_to_dir`, so a field the caller did not
mention keeps its current value **including `is_admin_only`**, which the writer emits
alongside `is_hidden` and `is_subagent_only`. Both this endpoint and the availability
toggle drop the cached system prompts afterwards — every profile's prompt embeds the
"Available profiles" block, so a change to one profile's availability changes all of them.

**Request body** (partial)
```json
{
  "temperature": 0.5,
  "max_iterations": 20,
  "think_enabled": true
}
```

**Response `200`**
```json
{ "ok": true }
```

**Errors**
- `400` — invalid profile data
- `404` — profile not found

---

#### `PATCH /admin/users/{user_id}/role`

Update cached role. Requires admin.

**Request body**
```json
{ "role": "admin" }
```

**Response `200`**
```json
{ "ok": true }
```

---

#### `PATCH /admin/profiles/{profile_id}/availability`

Toggle `is_admin_only`. Requires `navi.profiles.manage`.

Writes a row into `profile_overrides`, which is applied on top of `config.json` at every
startup — so this toggle outlives a restart and wins over the value committed in the file.
`config.json` remains the baseline for any profile with no row. Takes effect immediately
for profile lists and for the injected "Available profiles" block.

**Request body**
```json
{ "is_admin_only": true }
```

**Response `200`**
```json
{ "ok": true }
```

---

#### Admin MCP management

All require admin role.

| Endpoint | Purpose |
|---|---|
| `GET /admin/mcp/config` | Bulk read all MCP server configs (`mcp_servers.d/`). |
| `PUT /admin/mcp/config` | Bulk write all MCP server configs. |
| `GET /admin/mcp/config/{server_name}` | Read one server config. |
| `PUT /admin/mcp/config/{server_name}` | Create/update one server config. |
| `DELETE /admin/mcp/config/{server_name}` | Delete one server config. |
| `POST /admin/mcp/{server_name}/reconnect` | Drop old client, unregister tools, connect fresh, re-register. |
| `GET /admin/mcp/status` | List servers with connection status and exposed tools. |
| `POST /admin/mcp/test` | Execute a single MCP tool call in isolation for diagnostics. |

#### `GET /admin/profiles/{profile_id}/mcp` · `PUT /admin/profiles/{profile_id}/mcp`

Read/write a profile's `tools.agent.mcp` / `tools.subagent.mcp` mapping (which MCP groups the profile exposes). Requires `navi.profiles.manage`.

#### `GET /admin/recalls`

List all scheduled recalls with pagination/filtering (admin view across all users/sessions).

---

### Webhooks

#### `POST /webhooks/gnexus-auth`

Receive webhooks from gnexus-auth. Verified via HMAC.

**Response `200`**
```json
{ "ok": true }
```

**Errors**
- `400` — invalid payload
- `503` — OAuth not configured

#### `POST /webhooks/synapse` (also `/webhooks/synapse/`)

Receive one GNEXUS Synapse s2s delivery (see [`synapse.md`](synapse.md)).
Signature verified against every active per-user target secret; replays are
re-acked but never acted on twice. A non-duplicate delivery schedules a
fire-and-forget agent reaction (per-user `reactions_enabled` gate).

**Response `200`**
```json
{ "received": true, "event_id": "evt-..." }
```

**Errors**
- `401` — invalid webhook signature (or unparseable body)
- `503` — no navi-side Synapse targets configured

---

### Synapse

Per-user reaction settings (auth required). See details in
[`synapse.md`](synapse.md).

#### `GET /synapse-settings`

Returns the caller's settings plus `source_ready` (not persisted):
`reactions_enabled`, `push_target` (`app` | `app_synapse` | `synapse`),
`completion_notify` (`always` | `important` | `never`), `instructions`
(reaction instructions), `dispatcher_instructions` (routing instructions, read
by the dispatcher only), `reaction_session_ttl_minutes` (how long an idle
conversation may still be continued; `0` = never), `source_ready`.

#### `PUT /synapse-settings`

Full save. `reactions_enabled`, `push_target`, `completion_notify` and
`instructions` are required; `dispatcher_instructions` and
`reaction_session_ttl_minutes` are optional and an omitted field means "leave as
is" (never "clear") — a client that predates them cannot wipe them. Records an
edit version per document that actually changed (`edited_by="user"`). Returns
the saved settings + `source_ready`.

#### `GET /synapse-settings/versions?limit=20&doc=reaction`

Instruction edit history, newest first: `content`, `edited_by`
(`user` | `navi`), `reason`, `created_at`. `limit` 1..100. `doc`
(`reaction` | `dispatcher`, default `reaction`) picks which document's history
to read — the two are kept separately.

#### `POST /synapse-targets` · `GET /synapse-targets` · `DELETE /synapse-targets/{id}`

Per-user Synapse s2s target secrets: create (`token_ref` + `secret`,
secret is write-only), list (`id`, `token_ref`, `created_at`), revoke.

---

### MCP user keys (BYOK)

Per-user credentials for MCP servers whose config declares a `user_key` slot
(see [`mcp.md`](mcp.md)). Auth required. Keys are never returned in any
response — only presence and update time.

#### `GET /mcp-keys`

Every MCP server referenced by at least one profile, keyed ones first, with a
flag marking which of them accept a personal key. Response `200`:

```json
{"items": [
  {
    "server_name": "gnexus-creds",
    "transport": "streamable_http",
    "accepts_user_key": true,
    "key_type": "header",
    "key_location": "Authorization",
    "prefix": "Bearer ",
    "has_key": false,
    "updated_at": null,
    "profiles": ["discuss", "server_admin"]
  },
  {
    "server_name": "navi-web",
    "transport": "stdio",
    "accepts_user_key": false,
    "key_type": null,
    "key_location": null,
    "prefix": null,
    "has_key": false,
    "updated_at": null,
    "profiles": ["developer", "server_admin"]
  }
]}
```

`key_type` / `key_location` / `prefix` are `null` for servers without a slot;
the per-user store is not queried at all when no listed server has one.

#### `PUT /mcp-keys/{server_name}`

Body `{"key": "..."}` (1..4096 chars, trimmed). Saves the caller's key
(Fernet-encrypted at rest) and invalidates the cached resolution. `404` for
an unknown or key-less server, `422` for an empty/oversized key.

#### `DELETE /mcp-keys/{server_name}`

Drops the caller's key. An admin then falls back to the config default; anyone else simply
loses the server — it disappears from their tool list and a call to it is refused. `204`
always when the server exists (even if no key was stored), `404` for unknown/key-less.

---

## Files

**Client static**: `GET /static/**` — served from `client/` directory. Header `Cache-Control: no-store`.

**Session uploaded files**: stored in `session_files/{session_id}/`. Agent accesses them via the `filesystem` tool. Deleted after 24 h of session inactivity or when the session is deleted.

---

## Error codes

| HTTP | Reason |
|------|--------|
| `400` | Forbidden file type |
| `404` | Session or profile not found |
| `413` | File exceeds 200 MB |
| `500` | Internal agent error |
| WS `4004` | Session not found on connect |
