# 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` 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 the pinned `ddgs` engines. 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) |
| `SUBAGENT_TIMEOUT_SECONDS` | float | `600` | Whole-run cap for one `spawn_agent` sub-agent. 300 s used to cut long ops work in half. |
| `SUBAGENT_THINKING_STALL_SECONDS` | float | `60` | Stop a sub-agent that has produced only thinking for this long without text or tool calls (local models degenerate into internal loops) |
| `SUBAGENT_THINKING_STALL_CHARS` | int | `48000` | Same guard, by accumulated thinking size. Was 12k — reachable in a healthy iteration of a cloud reasoning model, so good runs were killed mid-thought. |

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`. |
| `HIVE_CONFIG_ENABLED` | bool | `true` | Use the hive's central config when `HIVE_URL` is set. `NAVI_OVERLAY=off` in the environment disables it for one process (tests do this). |
| `HIVE_CONFIG_CACHE_FILE` | str | `.hive-config.cache.json` | Last validated overlay + the boot-loop guard state. Gitignored, 0600; it holds secrets. |
| `HIVE_CONFIG_TIMEOUT_SEC` | float | `2.0` | Timeout for the config fetch at boot and on refresh. |

### Central config (hive overlay)

The hive can store one config document for the whole fleet. Every instance
that knows about the hive fetches it and applies it **over** its own config:
fields present in the document win, fields it does not mention come from the
local files, and an instance the document does not target runs purely on its
local config. Secrets may live in the document — rotating a key across the
fleet without touching a single machine is the main purpose.

**Shape.** A document is a list of layers applied in order. Each layer has a
name, a `config` merge-patch (RFC 7386: objects merge, arrays and scalars
replace, `null` drops the key) and an optional `match` targeting
`names` / `instance_ids` / `tags` (labels from `instance.json`). No `match`
means every instance.

```json
{"layers": [
  {"name": "global", "config": {"settings": {"llm_complete_timeout": 90}}},
  {"name": "lab", "match": {"tags": ["lab"]},
   "config": {"llm": {"targets": {"home": {"host": "http://192.168.1.129:11434"}}}}},
  {"name": "yuki", "match": {"names": ["yuki"]},
   "config": {"profiles": {"assistant": {"temperature": 0.3}}}}
]}
```

Sections: `settings` (any `Settings` field except the deny-list below),
`profiles` (any profile field, including `tools.agent.native`), `llm` (merged
into the local `llm.json`) and `mcp_servers` (add or override a server
definition — not remove one; `null` is consumed by the merge).

**Never overlaid:** `HIVE_URL`, `SWARM_KEY_FILE`, `INSTANCE_FILE`,
`HIVE_CONFIG_*`, `DATABASE_URL`, `NAVI_HOST`, `NAVI_PORT`,
`NAVI_UI_MCP_HOST/PORT`, `NAVI_AUTH_ENABLED`. These are the bootstrap fields
(needed before the overlay exists) and the values that can make a machine
unreachable or point it at the wrong database — a valid but wrong value is the
one failure validation cannot catch.

**Validation is all-or-nothing.** Unknown sections, settings, profile fields
and MCP fields are dropped with a warning (forward compatibility: a newer hive
may know more than this instance). A *known* field with an invalid value
rejects the whole document — half a document is a state nobody wrote — and the
instance keeps running the previous one. A rejected document never overwrites
the cache.

**Delivery.** On publish the hive pings every registered instance
(`POST /config/notify`, swarm key); each instance fetches and validates on its
own. The announce reply carries the same hint, so a machine that was offline
learns about the change on its next heartbeat, and the hive re-pings
still-behind instances periodically. `GET /config/status` on the hive lists
who has it, who staged it, who rejected it and why.

**Boot safety.** The last validated overlay is cached and used when the hive is
unreachable or the fetched document is invalid. A config that differed from the
last confirmed one is written as *pending* and confirmed only once the process
reaches the end of startup; if it prevents startup twice, it is blocked and the
last good one is used (a new publish clears the block).

**Hot vs restart.** Secrets (`GNAUTH_*`, synapse source), timeouts, tool
policies and profiles are applied to the running process. Anything built at
startup (backends, the MCP pool, CORS, the push service) is reported as
`restart_required` in `GET /health` → `config` and in the hive's ack view;
it takes effect on the next start.

**Trust.** The hive is a LAN service. The swarm key is the fleet's read
credential and therefore the ability to read config secrets; publishing,
history and rollback require the admin key (`HIVE_ADMIN_KEY_FILE`) or a
loopback connection. The hive's `hive.db` contains secrets — back it up
accordingly and do not expose port 8087 outside the LAN.


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

## History export

Bulk snapshot of message text for an external consumer (n-gram tables). Off
unless **both** the recipient key and the token are set — with either blank the
builder never starts and the endpoints answer `503`. See
[`export.md`](export.md).

| Variable | Type | Default | Description |
|---|---|---|---|
| `NAVI_EXPORT_AGE_RECIPIENT` | str | `""` | age public key (`age1…`) the snapshot is encrypted to. The private half lives on the consumer only. |
| `NAVI_EXPORT_TOKEN` | str | `""` | Bearer token the consumer presents. Compared as a SHA-256 digest; rotate by changing it and restarting. |
| `NAVI_EXPORT_INTERVAL_HOURS` | float | `4.0` | Rebuild period. The first build runs at startup; the floor is 15 minutes. |
| `NAVI_EXPORT_ROLES` | str | `user,assistant` | Comma-separated roles in the corpus. `tool` output and `system` prompts are excluded by default — they skew an n-gram table toward scraped web text and boilerplate. |
| `NAVI_EXPORT_MAX_BYTES` | int | `157286400` (150 MB) | Rolling window: whole sessions, newest first, until this much raw text. `0` = no cap. |
| `NAVI_EXPORT_DIR` | str | `export_files` | Where the ciphertext and `manifest.json` live. Git-ignored. |

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