# Configuration

All configuration is loaded from `.env` via pydantic-settings (`navi/config.py`). The global `settings` object is imported everywhere as `from navi.config import settings`.

## General

| Variable | Type | Default | Description |
|---|---|---|---|
| `NAVI_DEFAULT_PROFILE_ID` | str | `""` | Default profile used when a client creates a session without specifying a profile_id. Empty string means "no default".


## LLM

| Variable | Type | Default | Description |
|---|---|---|---|
| `LLM_CONFIG_FILE` | str | `llm.json` | Path to the routing config (see [LLM routing](#llm-routing-llmjson)). Empty string disables it. |
| `OLLAMA_HOST` | str | `http://localhost:11434` | Ollama server URL (used when `LLM_CONFIG_FILE` is absent) |
| `OLLAMA_API_KEY` | str | `""` | Ollama Cloud API key (used when `LLM_CONFIG_FILE` is absent) |
| `OLLAMA_DEFAULT_MODEL` | str | `gemma4:31b-cloud` | Default model (can be overridden per profile) |
| `OLLAMA_NUM_CTX` | int | `65536` | Context window size in tokens (a route entry's `context_window` wins) |
| `OLLAMA_THINK` | bool | `true` | Enable extended reasoning (thinking) |
| `OLLAMA_REQUEST_TIMEOUT` | int | `30` | Seconds before Ollama request times out (affects fallback speed) |
| `EMBEDDING_OLLAMA_HOST` | str | `""` | Ollama server for embedding model (falls back to `OLLAMA_HOST` if empty) |
| `EMBEDDING_OLLAMA_API_KEY` | str | `""` | API key for embedding Ollama server |
| `EMBEDDING_MODEL` | str | `nomic-embed-text:latest` | Embedding model for memory vector search |
| `EMBEDDING_ROUTE` | str | `embedding` | `llm.json` route the embedding calls walk. Empty means "pass the model name only". |
| `EMBEDDING_DIMENSIONS` | int | `768` | Vector dimensionality for embeddings |
| `OPENAI_API_KEY` | str | `""` | OpenAI API key (if using OpenAI backend) |
| `OPENAI_MODEL` | str | `"gpt-4"` | Default model for OpenAI backend |
| `OPENAI_BASE_URL` | str \| None | `None` | Custom base URL for OpenAI-compatible endpoints (e.g. vLLM, LM Studio) |
| `ANTHROPIC_API_KEY` | str | `""` | Reserved — no Anthropic backend implemented yet |

Without `llm.json` (a bare dev checkout), Navi talks to one host built from
`OLLAMA_HOST`/`OLLAMA_API_KEY` and the per-profile `model` list, exactly as before.

## LLM routing (`llm.json`)

`llm.json` (repo root, committed — it holds no secrets) declares the hosts and
the ordered chains of models to try on them. Backups are a property of the file,
not of the profile: when the cloud is unreachable or out of quota, the next
entry answers and a local model keeps Navi usable.

```json
{
  "targets": {
    "cloud": { "host": "https://ollama.com", "api_key_env": "OLLAMA_API_KEY" },
    "home":  { "host": "http://192.168.1.129:11434" }
  },
  "routes": {
    "default": [
      { "model": "glm-5.3-flash:cloud", "target": "cloud", "context_window": 131072 },
      { "model": "qwen3.6:35b-128k", "target": "home", "context_window": 131072,
        "options": { "presence_penalty": 0.0 } }
    ],
    "embedding": [
      { "model": "nomic-embed-text:latest", "target": "home" },
      { "model": "nomic-embed-text:latest", "target": "cloud" }
    ]
  },
  "probe": { "interval_sec": 60, "timeout_sec": 5 }
}
```

- **`targets`** — named hosts. `api_key_env` names an environment variable and
  is read from `os.environ` **or** from the `.env` file itself (the process
  environment does not receive it), so the deploy branch's `.env` does not
  change. Any name works — that is how a second Ollama Cloud account joins a
  route: give it its own target and its own variable, e.g. `cloud2` with
  `"api_key_env": "OLLAMA_API_KEY_2"`, then list both in the chain. A literal
  `api_key` is also accepted, but `llm.json` is committed: keep secrets in
  `.env` and reference them by name.
- **`routes`** — ordered chains; **the first entry is the primary**, the rest are
  backups. `context_window` caps `num_ctx` for that model and drives the
  compressor's window, and `options` are merged over the global Ollama options.
  When every entry of a route serves the same budget, give them the same
  `context_window`: the compressor counts against whichever entry is active, so
  an uneven window silently changes how much history a failover can hold.
- **`probe`** — how often the background prober re-checks a degraded target.

A profile joins a route with `"llm_route": "default"` (see
[`profiles.md`](profiles.md#llm_model-and-llm_route)). No `llm_route` means the
profile's `model` list is used, each name resolved to the target the config says
serves it.

#### What fails over, and when

| Failure | Classification | Engine behaviour |
|---|---|---|
| connection refused / timeout / first-chunk timeout | `LLMConnectionError` | target **down** for one probe interval, next entry |
| HTTP 429, or a "rate limit"/"quota" message | `LLMQuotaExceededError` | target **cooling**, at least the `Retry-After` and one interval |
| HTTP 401/403 | `LLMAuthError` | target **auth**, re-checked every 300 s (the key may be rotated) |
| HTTP 5xx | `LLMServerUnavailableError` | target **cooling** for one interval |
| HTTP 404 / model not found | `LLMModelNotFoundError` | only **that (target, model)** pair is marked missing |
| anything else (a malformed request) | `LLMBackendError` | not retried — every entry would say no |

`LLM_COMPLETE_RETRIES` applies only to the **last** entry left: a dead cloud
model must not cost `retries × LLM_STREAM_FIRST_CHUNK_TIMEOUT` on every call
while a healthy local model sits one step down the chain.

For streaming calls the first chunk is awaited before anything is yielded, so a
failure is caught before output reaches the client. A stream that breaks *after*
the first chunk is not retried — the turn fails rather than mixing two partial
answers from two hosts.

#### Switching back

Switch-back never happens mid-turn. `RouteHealth` probes only degraded targets
(once per `probe.interval_sec`, with `probe.timeout_sec`), using `list()` for
reachability/auth, `show()` for a missing model, and a one-token chat for a
quota cooldown. When the probe reports the primary healthy again, the **next**
user turn starts on it; the running turn keeps the host it began on.

`/health` reports which entry serves each route and why a target is degraded —
see [`api.md`](api.md#health). `POST /admin/ollama/clear-blacklists` forgets all
recorded failures (and restarts every route at its primary) after a config fix.

#### If the file is broken

A missing `llm.json` (or `LLM_CONFIG_FILE=""`) means "no routes" and the legacy
single-host path. A *malformed* file raises `LLMConfigError` at startup, naming
the file and the offending target or route: silently routing to the wrong host
is worse than refusing to start.

## Web Search

Web search is not a `Settings` field: it is the `navi-web` MCP server. Its settings
reach it through `mcp_servers.d/navi-web.json`, which maps `SEARXNG_URL` (and,
optionally, `BRAVE_SEARCH_API_KEY`) from `.env` into the server's own environment.
`SEARXNG_URL` is therefore a real `.env` setting — a self-hosted SearXNG, e.g.
`http://localhost:8888` — just not one Navi itself reads. Without it the server
falls back to DuckDuckGo scraping. See [`mcp.md`](mcp.md#navi-web-settings).

## LLM Timeouts

| Variable | Type | Default | Description |
|---|---|---|---|
| `LLM_COMPLETE_TIMEOUT` | int | `120` | Seconds before a non-streaming `complete()` call times out |
| `PLANNING_LLM_TIMEOUT_SEC` | int | `240` | Seconds before a non-streaming planning phase call times out (`plan` tool / subagent pipeline). Wider than `LLM_COMPLETE_TIMEOUT` — cloud models prefill big planning prompts for up to a couple of minutes, and a timeout here silently kills the plan. |
| `LLM_STREAM_FIRST_CHUNK_TIMEOUT` | int | `90` | Seconds to wait for the first token of a streaming call (prefill phase) |
| `LLM_STREAM_CHUNK_TIMEOUT` | int | `60` | Max seconds between consecutive tokens in a streaming call |
| `LLM_COMPLETE_RETRIES` | int | `2` | Attempts on the **last** remaining route entry after a connection error. Entries with a healthy alternative are tried once, so failover stays fast. |
| `LLM_RETRY_BACKOFF_SEC` | float | `2` | Base sleep between those attempts (multiplied by the attempt number) |

Large contexts can take 60–90 s to prefill; `LLM_STREAM_FIRST_CHUNK_TIMEOUT=90` covers the common case for local Ollama. Increase (e.g. 180) for very large contexts or slow GPUs; reduce for fast cloud endpoints.

## Security / Sandboxing

| Variable | Type | Default | Description |
|---|---|---|---|
| `FS_ALLOWED_PATHS` | str | `"*"` | Comma-separated paths the `filesystem` tool can access. `"*"` = no restriction |
| `TERMINAL_ALLOWED_COMMANDS` | str | `"*"` | Comma-separated allowed executables for `terminal`. `"*"` = allow all |
| `SSH_HOSTS_FILE` | str | `ssh_hosts.json` | Path to JSON file with named SSH connections |

`settings.fs_allowed_paths_list` and `settings.terminal_allowed_commands_list` are computed properties that parse the comma-separated strings into lists.

| Variable | Type | Default | Description |
|---|---|---|---|
| `TERMINAL_USER_ALLOWED_COMMANDS` | str | long allowlist | Comma-separated allowed executables for non-admin users. Admin bypasses this restriction. |

`settings.terminal_user_allowed_commands_list` — computed property parsed from the comma-separated string.

## Database

| Variable | Type | Default | Description |
|---|---|---|---|
| `DATABASE_URL` | str | `""` | PostgreSQL URL (`postgresql://user:pass@host:port/db`). **Required** — Navi requires PostgreSQL 15+ with `pgvector` extension. |

## Server / deployment shape

| Variable | Type | Default | Description |
|---|---|---|---|
| `NAVI_WEBCLIENT_ENABLED` | bool | `true` | Master switch for the web UI. `false` registers none of the web-facing routes (`/`, `/assets`, `/images`, `/content-viewers`, `/content`, `/admin` panel, `/debug*`) and skips the navi_ui MCP server — a pure API/WS server for terminal clients. The admin JSON API (`/admin/*`) stays, still gated by `require_admin`. Toggling requires a server restart. |
| `NAVI_HOST` | str | `127.0.0.1` | Bind address for the `navi-server` launcher (uvicorn). `127.0.0.1` = local-only; remote clients connect via SSH tunnel or reverse proxy. |
| `NAVI_PORT` | int | `8099` | Bind port for the `navi-server` launcher. |

### Swarm / hive registry

Navi instances can form a swarm: each announces itself to a tiny registry
service ("hive", see [`hive/README.md`](../hive/README.md)) running on the main
server; agents ask the book who is alive and where. The registry is never a
blocking dependency — with `HIVE_URL` empty, or with the hive down, everything
else works and the agent is simply told the book is unreachable.

| Variable | Type | Default | Description |
|---|---|---|---|
| `HIVE_URL` | str | `""` | Hive registry address (e.g. `http://192.168.1.168:8087`). Empty = standalone navi, no announcements. |
| `ANNOUNCE_INTERVAL_SEC` | int | `120` | How often this navi re-announces itself to the hive. |
| `SWARM_KEY_FILE` | str | `.swarm-key` | Shared PSK file (gitignored, 0600, generated by `deploy/install.sh`). |
| `INSTANCE_FILE` | str | `instance.json` | This navi's identity for the swarm: generated name (single female name from Japanese/American/Spanish pools) + instance id. Rename = edit the file, restart. |
| `PEER_ASK_PROFILE` | str | `server_admin` | Which profile answers incoming `/peer/ask` questions (one-shot agent run). |
| `PEER_ASK_TIMEOUT_SEC` | int | `120` | Hard ceiling for a single peer ask — the LLM turn inside `/peer/ask`. |

See [`deploy/README.md`](../deploy/README.md) for the one-command server deployment (`bash deploy/install.sh`): dockerized PostgreSQL + systemd + `navi-code` in PATH, web UI and auth off by default.

## Logging

| Variable | Type | Default | Description |
|---|---|---|---|
| `LOG_LEVEL` | str | `INFO` | Python logging level (`DEBUG`, `INFO`, `WARNING`, `ERROR`) |

## Tools

| Variable | Type | Default | Description |
|---|---|---|---|
| `TOOLS_DIR` | str | `tools` | Directory for user-defined tools (auto-discovered at startup) |
| `CONTEXT_PROVIDERS_DIR` | str | `context_providers` | Directory for user-defined context providers (auto-discovered at startup) |

### MCP `user_key` (BYOK)

Per-server JSON config (`mcp_servers.d/<name>.json`) — where a user's personal
credential is injected. Details in [`mcp.md`](mcp.md).

```json
// HTTP server: key goes into an Authorization-style header
{ "transport": "streamable_http", "url": "https://…",
  "user_key": { "header": "Authorization", "prefix": "Bearer " } }

// stdio server: key goes into an env var
{ "transport": "stdio", "command": "uvx", "env": { "API_KEY": "default" },
  "user_key": { "env": "API_KEY" } }
```

Keys are stored Fernet-encrypted (`NAVI_AUTH_ENCRYPTION_KEY`, see
Authentication above). A user without a saved key is served the default credential from
the config file only if their role is `admin`; every other role is refused, and the server
is left out of their tool list entirely. See [`mcp.md`](mcp.md#per-user-keys-byok).

## Session files

| Variable | Type | Default | Description |
|---|---|---|---|
| `SESSION_FILES_DIR` | str | `session_files` | Directory for uploaded session files |
| `SESSION_FILES_MAX_SIZE_MB` | int | `200` | Max upload size per file in megabytes |
| `SHARE_FILE_MAX_SIZE_MB` | int | `1024` | Max file size `share_file` may copy into session files, in megabytes |
| `SESSION_MESSAGES_WINDOW` | int | `1000` | Max hot (non-archived) messages per session |
| `WS_REPLAY_BUFFER_SIZE` | int | `500` | Max events retained per WebSocket turn for reconnect replay |

## Public URL

| Variable | Type | Default | Description |
|---|---|---|---|
| `PUBLIC_URL` | str | `http://localhost:8099` | Base URL used by `share_file` to build download links. Set this when behind a reverse proxy. |

## Context compression

| Variable | Type | Default | Description |
|---|---|---|---|
| `CONTEXT_COMPRESSION_ENABLED` | bool | `true` | Enable/disable automatic context compression |
| `CONTEXT_COMPRESSION_THRESHOLD` | float | `0.90` | Trigger compression at this fraction of `OLLAMA_NUM_CTX` |
| `CONTEXT_KEEP_RECENT` | int | `4` | Number of recent conversation turns to keep verbatim |
| `CONTEXT_SUMMARY_TEMPERATURE` | float | `0.3` | Temperature for the summarization LLM call |
| `CONTEXT_SUMMARY_MAX_TOKENS` | int | `6000` | Max output tokens for the summary LLM call |
| `OUTPUT_RESERVE_TOKENS` | int | `2048` | Headroom reserved for model response in context size checks |
| `CONTEXT_MESSAGE_TOKEN_BUDGET` | int | `0` | Per-message token budget for the LLM context view. A single `tool`/`assistant` message whose estimated size exceeds this is head/tail-truncated in the *built* context only (stored history is never mutated) so one huge tool result cannot alone blow the window. `0` = auto (`OLLAMA_NUM_CTX // 6`). |

## Gmail

| Variable | Type | Default | Description |
|---|---|---|---|
| `GMAIL_ADDRESS` | str | `""` | Gmail address for the `email_manager` tool (IMAP/SMTP with App Password) |
| `GMAIL_APP_PASSWORD` | str | `""` | Gmail App Password (not the account password — generate at myaccount.google.com) |

## Authentication (gnexus-auth OAuth)

| Variable | Type | Default | Description |
|---|---|---|---|
| `NAVI_AUTH_ENABLED` | bool | `true` | Master auth switch. Set `false` to disable OAuth/API-token auth entirely. Every request is treated as the local `anonymous` admin user. Use only for trusted single-user/local deployments. |
| `GNAUTH_BASE_URL` | str | `http://gnexus-auth.local` | gnexus-auth server base URL |
| `GNAUTH_CLIENT_ID` | str | `""` | OAuth client ID |
| `GNAUTH_CLIENT_SECRET` | str | `""` | OAuth client secret |
| `GNAUTH_REDIRECT_URI` | str | `http://localhost:8099/auth/callback` | Must match redirect URI registered in gnexus-auth |
| `GNAUTH_ADMIN_ROLE_SLUG` | str | `navi_admin` | Role slug that maps to Navi `admin` role |
| `GNAUTH_USER_ROLE_SLUG` | str | `navi_user` | Role slug that maps to Navi `user` role |
| `GNAUTH_PROFILE_PATH` | str | `/account/profile` | Path appended to `gnauth_base_url` for profile links |
| `NAVI_AUTH_ENCRYPTION_KEY` | str | `""` | **Fernet key** (base64, 32 bytes). Generate once with `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"`. Never change after first launch. |
| `NAVI_AUTH_COOKIE_NAME` | str | `navi_auth_session` | Session cookie name |
| `NAVI_AUTH_COOKIE_SECURE` | bool | `False` | Set `True` behind HTTPS |
| `NAVI_AUTH_COOKIE_SAMESITE` | str | `lax` | SameSite policy |
| `NAVI_AUTH_COOKIE_MAX_AGE_DAYS` | int | `30` | Cookie lifetime |

Full auth setup guide: [`docs/auth.md`](auth.md).

## Web push (PWA)

| Variable | Type | Default | Description |
|---|---|---|---|
| `NAVI_PUSH_VAPID_PUBLIC_KEY` | str | `""` | VAPID public key for web push. Empty = push fully disabled (endpoints 503, no notifications sent). |
| `NAVI_PUSH_VAPID_PRIVATE_KEY` | str | `""` | VAPID private key. Generate the pair once: `pip install py_vapid && python -m py_vapid gen`. |
| `NAVI_PUSH_VAPID_SUBJECT` | str | `mailto:admin@navi.local` | Contact for the push service (a `mailto:` or `https:` URL). |
| `NAVI_PUSH_COOLDOWN_SEC` | int | `30` | Minimum seconds between two pushes for the same session. |

Push notifications fire when an agent turn (or a scheduled recall) completes and no
browser tab is watching that session. See [`docs/push.md`](push.md).

## Synapse source

| Variable | Type | Default | Description |
|---|---|---|---|
| `SYNAPSE_SOURCE_URL` | str | `""` | GNEXUS Synapse base URL for the outgoing source API. Empty = nothing is ever emitted. |
| `SYNAPSE_SOURCE_API_KEY` | str | `""` | `syn_*` source key registered in the Synapse admin panel (write it into `.env` yourself). |
| `SYNAPSE_SOURCE_NAME` | str | `navi` | Source name on Synapse's side. |

Without both URL and key, Synapse-linked outgoing events are silently skipped and
the UI disables Synapse-linked options (`source_ready: false`).
See [`docs/synapse.md`](synapse.md).

## Persona

| Variable | Type | Default | Description |
|---|---|---|---|
| `NAVI_PERSONA` | str | `""` | Global personality prompt prepended to every profile's system prompt |
| `NAVI_PERSONA_FILE` | str | `""` | Path to a `.txt` file containing the persona (preferred over inline `NAVI_PERSONA`) |

**Recommended:** use `NAVI_PERSONA_FILE=persona.txt` rather than inlining the persona in `.env`, because multi-line values don't parse reliably in `.env` files.

The `_load_persona_from_file` validator reads the file on startup if `NAVI_PERSONA` is empty and `NAVI_PERSONA_FILE` is set.

## Parallelism and background tasks

| Variable | Type | Default | Description |
|---|---|---|---|
| `TASKS_MAX_PER_SESSION` | int | `5` | Concurrent running background tasks per session |
| `TASKS_MAX_GLOBAL` | int | `20` | Concurrent running background tasks server-wide |
| `TASKS_MAX_SPAWN` | int | `2` | Concurrent background `spawn_agent` per session |
| `TASKS_TTL_SEC` | int | `3600` | Finished-task retention before reaping |
| `TASKS_EVENT_BUFFER_SIZE` | int | `50` | Per-task event ring (spawn tasks use 200) |
| `TASKS_RATE_LIMIT` | int | `10` | Max task spawns per 5 min per session |
| `TASK_NOTES_MAX_PENDING` | int | `20` | Pending completion notes per session |
| `TASK_NOTES_PER_TURN` | int | `5` | Max notes coalesced into one turn injection |
| `MESSAGE_QUEUE_MAX` | int | `5` | User messages queued while a run is active |
| `BACKGROUNDABLE_TOOLS` | str | `terminal,ssh_exec,peer,spawn_agent,code_exec` | Tools accepting `"background": true` |
| `PARALLEL_TOOL_CALLS` | bool | `false` | Execute a multi-tool-call batch concurrently (per-profile override: `AgentProfile.parallel_tool_calls`) |

See [`docs/tasks.md`](tasks.md) for the full mechanism.

When a `terminal` (run), `code_exec` or `ssh_exec` call is detached and the agent did not pass `timeout`, the executor lifts it to 300 s for the detached run only — the tools' short foreground defaults (20/30/60 s) would otherwise mark long commands "completed" with partial output while the process is still running.

## Example `.env`

```dotenv
# LLM — Ollama (primary)
OLLAMA_HOST=http://localhost:11434
OLLAMA_API_KEY=
OLLAMA_DEFAULT_MODEL=gemma4:31b-cloud
OLLAMA_NUM_CTX=65536
OLLAMA_THINK=true
OLLAMA_REQUEST_TIMEOUT=30

# LLM routing (hosts + backup chains). Committed; no secrets inside:
# LLM_CONFIG_FILE=llm.json

# LLM — OpenAI (optional)
# OPENAI_API_KEY=sk-...
# OPENAI_MODEL=gpt-4
# OPENAI_BASE_URL=https://api.openai.com/v1

# Database (PostgreSQL 15+ with pgvector)
DATABASE_URL=postgresql://user:pass@localhost:5432/navidb

# Security / sandboxing
FS_ALLOWED_PATHS=*
TERMINAL_ALLOWED_COMMANDS=*

# Misc
LOG_LEVEL=INFO
TOOLS_DIR=tools
SHARE_FILE_MAX_SIZE_MB=1024
SESSION_MESSAGES_WINDOW=1000
WS_REPLAY_BUFFER_SIZE=500

# Context compression
CONTEXT_COMPRESSION_ENABLED=true
CONTEXT_COMPRESSION_THRESHOLD=0.90
CONTEXT_KEEP_RECENT=4

# Persona
NAVI_PERSONA_FILE=persona.txt
NAVI_DEFAULT_PROFILE_ID=navi_code
```
