# Navi API Reference

Base URL: `http://localhost:8000`

---

## REST API

### Health

#### `GET /health`

Server availability check.

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

#### `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": "en-US",
  "avatar_url": "https://...",
  "profile_url": "https://...",
  "role": "admin",
  "permissions": ["navi.sessions.read_all", "navi.memory.read_all"]
}
```

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

---

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

**Response `200`**
```json
[
  {
    "id": "secretary",
    "name": "Personal Secretary",
    "description": "General-purpose assistant",
    "llm_backend": "ollama",
    "model": ["gemma4:31b-cloud", "gemma4:26b-a4b-it-q4_K_M"],
    "temperature": 0.65,
    "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 + MCP server instructions).

**Response `200`**
```json
{
  "secretary": "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": "secretary" }
```

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

**Errors**
- `401` — not authenticated
- `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 |

**Response `200`** (when pagination params provided)
```json
{
  "items": [
    {
      "session_id": "550e8400-...",
      "profile_id": "secretary",
      "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": "secretary",
    "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": "secretary",
  "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}/recall`

Get the pending recall for a session, if any. Returns the first `pending` recall only.

**Response `200`**
```json
{
  "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"
}
```

**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": "secretary",
  "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}/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, PDFs, plain text and HTML are served inline; everything else as an attachment.

**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": "..."}, ...] }
```

**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" }
```

---

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

---

## 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 |
| `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 ≥70% of `OLLAMA_NUM_CTX`, or on demand via `{"type":"compact"}`). `summary` is the produced summary text. Informational.

---

#### `heartbeat`
```json
{ "type": "heartbeat" }
```
Keepalive ping sent every 20 s during long silent operations. Client can ignore.

---

#### `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": "gemma4:31b-cloud" }
```
Emitted once per turn after the backend resolves a model (may differ from the profile's configured model when fallback picked another). 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.

---

#### `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": "secretary",
      "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": "secretary",
  "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": "secretary",
    "name": "Research task",
    "message_count": 12,
    "pinned": false,
    "created_at": "...",
    "last_active": "..."
  }
]
```

---

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

Manually clear dead-server and dead-model blacklists for the Ollama fallback backend. Useful when a transient failure caused a 5-minute blacklist and you want immediate recovery.

**Response `204`** — no body

---

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

Full profile configuration including system prompt.

**Response `200`**
```json
{
  "id": "secretary",
  "name": "Personal Secretary",
  "description": "General-purpose assistant",
  "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_enabled": false,
  "planning_mandatory": false,
  "planning_phase1_enabled": true,
  "planning_phase2_enabled": false,
  "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,
  "adaptive_replan_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.

**Request body** (partial)
```json
{
  "temperature": 0.5,
  "max_iterations": 20,
  "planning_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`.

**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

---

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