# Profiles

Profiles define the agent's identity, tools, and behaviour for a specific domain.

## Profile definition (`navi/profiles/base.py`)

Each profile is loaded from a directory under `navi/profiles/<id>/`:
- `config.json` — all fields below
- `system_prompt.txt` — domain-specific instructions
- `subagent_system_prompt.txt` — injected into subagents spawned from this profile (optional)

---

## All `config.json` fields

### Identity

| Key | Type | Default | Description |
|---|---|---|---|
| `id` | str | **required** | Unique profile identifier (matches directory name) |
| `name` | str | **required** | Human-readable name shown in UI |
| `description` | str | **required** | Longer description shown in profile picker |
| `short_description` | str | `""` | One-line summary injected into every profile's system prompt (cross-profile awareness) |
| `full_description` | dict | `{}` | Structured dict: `specialization`, `when_to_use`, `key_tools` keys |

### LLM

| Key | Type | Default | Description |
|---|---|---|---|
| `llm_backend` | str | `"ollama"` | Backend key: `"ollama"`, `"openai"` |
| `model` | str or list[str] | `["gemma4:31b-cloud"]` | Model priority list — first available wins. String is accepted and auto-wrapped. |
| `temperature` | float | `0.7` | Sampling temperature for main loop calls |
| `max_iterations` | int | `10` | Hard cap on tool-calling iterations per turn |
| `top_k` | int \| None | `None` | Ollama sampling top_k |
| `top_p` | float \| None | `None` | Ollama sampling top_p |
| `num_thread` | int \| None | `None` | CPU threads for local inference. `None` = Ollama default |

### Tools

| Key | Type | Default | Description |
|---|---|---|---|
| `tools` | `ToolConfig` | `{}` | **Required.** Explicit tool configuration with two scopes: `agent` and `subagent`. Each scope has `native: list[str]` (built-in and user tool names) and `mcp: dict[str, list[str]]` (MCP server groups). |

`tools.agent` controls what the main loop sees. `tools.subagent` controls what sub-agents spawned from this profile see. If `tools.subagent` is empty, it falls back to `tools.agent`.

`spawn_agent` may receive an optional `profile_id`. If omitted, the subagent uses the parent session's current profile. If provided, the subagent uses the selected profile's model, prompt, planning flags, and `tools.subagent` fallback.

### MCP tool groups

Inside `tools.{agent,subagent}.mcp`, each key is an MCP server name and each value is a list of group names (or `"*"` for all groups). Named groups resolve to concrete tools via the server's config in `mcp_servers.d/`. Example:

```json
{
  "mcp": {
    "navi-web": ["search", "browse", "request"]
  }
}
```

This exposes the tools `mcp__navi-web__web_search`, `mcp__navi-web__web_view`, and `mcp__navi-web__http_request`. `*` expands to every tool advertised by that server.

### Deprecated tool fields

Older configs used `enabled_tools`, `subagent_tools`, and `mcp_servers` as flat top-level fields. The loader still auto-migrates them into `tools.agent` / `tools.subagent` for backward compatibility, but new profiles should use the explicit `tools` structure.

When `tools.subagent` is non-empty, only MCP groups listed there are exposed to the sub-agent. This prevents a profile's main MCP servers from leaking into restricted sub-agent contexts.

### Thinking mechanics

| Key | Type | Default | Description |
|---|---|---|---|
| `think_enabled` | bool | `true` | Pass `think=True` to LLM on every call (extended reasoning). Disable for latency-sensitive profiles. |
| `iteration_budget_enabled` | bool | `true` | Inject remaining iteration count into context so the model knows when to wrap up. |
| `goal_anchoring_enabled` | bool | `true` | Inject a goal-reminder system message every N iterations to prevent drift. |
| `goal_anchoring_interval` | int | `5` | N for goal anchoring. |
| `anti_stall_enabled` | bool | `true` | Detect looping without todo progress and inject a hard warning. |
| `anti_stall_threshold` | int | `8` | Consecutive iterations without progress before stall warning fires. |
| `step_validation_enabled` | bool | `false` | Reserved flag — todo validation is unconditional in the current implementation. |

### Bounded autonomy

One flag keeps an autonomous agent inside the user's literally requested scope. Defaults `false` (legacy "free flight" behavior — explore broadly, finish discovered work). Enabled on `navi_code`.

| Key | Type | Default | Description |
|---|---|---|---|
| `scope_boundary_enabled` | bool | `false` | Inject a standing `[Scope boundary]` system message telling the agent to act strictly within the requested scope — do not expand to sibling/parent directories or projects, and do not execute discovered TODO/roadmap/milestone/backlog docs unless explicitly asked. Also gates the memory-facts scope filter (see below). |

The memory-facts scope filter: when `scope_boundary_enabled` is on, `_memory_facts_msg` drops memory facts whose value is an absolute path outside the session `cwd` (e.g. a stale `project_root` pointing at another project). Facts inside the session cwd and non-path facts are kept. No facts are deleted — only injection is filtered. See [`docs/mechanics.md`](mechanics.md).

### Planning

Top-level planning is **agent-invoked**: the agent calls the `plan` tool when a task warrants it (add it to `tools.agent.native`). There is no pre-turn gate — free conversation and trivial tasks execute immediately. The pipeline has two phases:

- **Phase 1 — Analysis**: reformulates the task, identifies subtasks and unknowns, classifies `COMPLEXITY: simple | medium | complex`. Sub-agents can output `DIRECT` to skip planning for trivial subtasks (the shortcut is never offered at top level).
- **Phase 3 — Execution plan**: assigns each subtask to `TOOL / AGENT / SELF` and uses adaptive plan depth.

When the plan is ready, the tool result tells the agent what to do next based on COMPLEXITY: `complex` — present the plan to the user and wait for confirmation; otherwise — proceed with execution. A re-plan is the same tool called with a `reason` (and optional `updated_goal`); it packs the current todo and scratchpad findings into the planning context and replaces the todo.

| Key | Type | Default | Description |
|---|---|---|---|
| `planning_phase1_enabled` | bool | `true` | Enable Phase 1 (task analysis). When disabled, Phase 3 runs without analysis context. |
| `planning_phase3_enabled` | bool | `true` | Enable Phase 3 (structured execution plan). When disabled, only Phase 1 (analysis) runs. |

### Sub-agent planning

| Key | Type | Default | Description |
|---|---|---|---|
| `subagent_think_enabled` | bool \| None | `None` | Extended reasoning for sub-agents. `None` = inherit `think_enabled` from parent profile. |
| `subagent_planning_enabled` | bool | `false` | Sub-agents spawned from this profile also run the planning pipeline before their tool loop. |
| `context_providers` | list[str] | `[]` | Extra context providers to inject for this profile (by name). Global providers are always injected. |
| `is_admin_only` | bool | `false` | If `true`, the profile may only be used by a user whose role is `admin`: it is absent from every profile list and from the injected "Available profiles" block, cannot be selected when a session is created, cannot be switched to, and cannot be spawned. Enforced by `admin_only_blocked()` in `navi/profiles/base.py` — the one place the flag/role rule lives. Set in `config.json`; a `profile_overrides` row written by `PATCH /admin/profiles/{id}/availability` is applied on top at startup and wins. |
| `is_subagent_only` | bool | `false` | If `true`, profile can only be used via `spawn_agent`; `switch_profile` is blocked. Useful for narrow specialist agents that should never become the main session profile. |

### Compression

Per-profile overrides for context compression. When set, they take precedence over the global `settings` (`CONTEXT_KEEP_RECENT`, `CONTEXT_SUMMARY_MAX_TOKENS`) inside `compress_context` / `compress_session` and the summary system prompt. The active profile is propagated to the compressor via `Agent.set_profile()` and to the post-turn `CompressionWorker` via `WorkerContext.profile`.

| Key | Type | Default | Description |
|---|---|---|---|
| `compression_keep_recent` | int \| None | `None` | Override `CONTEXT_KEEP_RECENT` — number of recent turns kept verbatim. `navi_code` uses `12`. |
| `compression_max_tokens` | int \| None | `None` | Override `CONTEXT_SUMMARY_MAX_TOKENS` — max output tokens for the summary LLM call. |
| `compression_prompt_file` | str \| None | `None` | Extra instructions appended to the summary system prompt (loaded from the profile dir). |

See [`sessions.md`](sessions.md) for the full compression algorithm and [`mechanics.md`](mechanics.md) for the catalog.

### Sub-agent system prompt

| Key | Type | Default | Description |
|---|---|---|---|
| `subagent_system_prompt` | str | `""` | Inline sub-agent system prompt. If empty, the loader reads `subagent_system_prompt.txt` from the profile directory. Prepended on top of the parent's `system_prompt` when `inherit_system_prompt=True`. |

### Unknown keys

`AgentProfile` uses `model_config = {"extra": "allow"}`, so unknown keys in `config.json` are accepted and preserved (round-tripped through the admin panel's GET/PUT) rather than rejected. This lets profiles carry custom metadata without code changes. |

---

## Active profiles

Seven of the ten registered profiles are **admin-only** (`is_admin_only: true`): the
owner's own working set. A user whose role is `user` neither sees nor can reach them.

| ID | Name | Audience | Models (priority order) | Temp | Planning |
|---|---|---|---|---|---|
| `assistant` | Assistant | user | `glm-5.3-flash:cloud` → `gemma4:31b-cloud` → `qwen3.5:397b-cloud` → `kimi-k2.6:cloud` → `gemma4:26b-a4b-it-q4_K_M` → `qwen3.6:27b` | 0.45 | Yes |
| `designer_3d` | 3D Designer | user | `glm-5.3-flash:cloud` → `gemma4:12b-it-qat-128k` → `gemma4:31b-cloud` → `qwen3.5:397b-cloud` → `qwen3.6:35b` → `gemma4:26b-a4b-it-q4_K_M` | 0.35 | Yes |
| `coder` | Coder | user | `glm-5.3-flash:cloud` → `gemma4:31b-cloud` → `qwen3.5:397b-cloud` → `kimi-k2.6:cloud` → `gemma4:26b-a4b-it-q4_K_M` → `qwen3.6:27b` | 0.30 | Yes |
| `secretary` | Personal Secretary | admin | `glm-5.3-flash:cloud` → `gemma4:31b-cloud` → `gemma4:12b-it-qat-128k` → `qwen3.5:397b-cloud` → `kimi-k2.6:cloud` → `gemma4:26b-a4b-it-q4_K_M` → `qwen3.6:27b` | 0.45 | Yes |
| `server_admin` | Server Administrator | admin | `glm-5.3-flash:cloud` → `gemma4:31b-cloud` → `gemma4:12b-it-qat-128k` → `qwen3.5:397b-cloud` → `gemma4:26b-a4b-it-q4_K_M` → `kimi-k2.6:cloud` → `qwen3.6:27b` | 0.25 | Yes |
| `developer` | Developer | admin | `glm-5.3-flash:cloud` → `gemma4:31b-cloud` → `gemma4:12b-it-qat-128k` → `qwen3.5:397b-cloud` → `gemma4:26b-a4b-it-q4_K_M` → `kimi-k2.6:cloud` → `qwen3.6:27b` | 0.35 | Yes |
| `discuss` | Discussion | admin | `glm-5.3-flash:cloud` → `gemma4:12b-it-qat-128k` → `gemma4:31b-cloud` → `qwen3.5:397b-cloud` → `kimi-k2.6:cloud` → `qwen3.6:27b` | 0.65 | Phase 1 only |
| `modeler_3d` | 3D Modeler | admin | `glm-5.3-flash:cloud` → `gemma4:12b-it-qat-128k` → `gemma4:31b-cloud` → `qwen3.5:397b-cloud` → `kimi-k2.6:cloud` → `gemma4:26b-a4b-it-q4_K_M` → `qwen3.6:35b` | 0.35 | Yes |
| `navi_code` | Navi Code | admin | `glm-5.3-flash:cloud` → `gemma4:31b-it-qat` → `gemma4:26b-a4b-it-qat` → `gemma4:12b-it-qat-128k` → `gemma4:31b-cloud` | 0.35 | Yes |
| `dispatcher` | Synapse Reaction Dispatcher | hidden | `glm-5.3-flash:cloud` → `gemma4:31b-cloud` → `qwen3.6:35b` → `gemma4:26b-a4b-it-q4_K_M` | 0.20 | No |

Model chains are swapped as models come and go — `navi/profiles/<id>/config.json` is the source of truth. The `dispatcher` profile is additionally `is_hidden` and never appears in the picker.

All profiles share a base tool set. User tools from `tools/enabled.json` are merged in at
runtime for **every** profile, admin or not — see "Restricted profiles" below.

### Restricted profiles — `assistant`, `designer_3d`, `coder`

These three are the profiles an ordinary user gets. They exist to keep a household user
inside the Navi server's own working directory and out of the rest of the owner's
infrastructure, while still being useful for everyday work. All three carry the **same
native tool set** — they differ in system prompt, model and MCP groups.

Deliberate omissions, none of which appears in any of the three (or in their
`tools.subagent`, or in their MCP groups):

| Tool | Why it is withheld |
|---|---|
| `ssh_exec` | Reaches any host, takes a password inline, host-key checking off. |
| `peer` | Runs an agent on a neighbouring machine, where `PEER_ASK_PROFILE=server_admin`. |
| `reload_tools`, `create_mcp_server` | Execute arbitrary code on the Navi host. |
| `test_mcp_tool` | Calls any tool of any server, bypassing the profile's MCP groups — it would undo both the group limits and the BYOK rule. |
| `image_view` | Reads any absolute path; the working-directory convention does not apply to it. |

MCP groups are all read-only or local:

- `assistant` — `navi-web` (`search`, `browse`), `navi_ui` (`ui`), and read-only use of the owner's knowledge and tracking servers: `gnexus-book` (`read`), `gntodo` (`read`), `synapse` (`read`), `hard-panel` (`read`).
- `designer_3d` — `navi-3d` (`modeling`, `analysis`), `navi-web` (`search`, `browse`), `navi_ui` (`ui`).
- `coder` — `navi-web` (`search`, `browse`), `gnexus-book` (`read`), `gntodo` (`read`).

The `navi-web` `request` group (`http_request` — arbitrary method, headers and body,
issued from the Navi host) is given to none of them, and `gnexus-creds` and `tgclient` are
not given at all, not even read-only.

Because MCP servers with a `user_key` slot require the user's own credential, those groups
only take effect once the user has saved a key in **Settings → MCP keys**; without one the
server is not offered to them at all. `navi-3d`, `navi-web` and `navi_ui` have no slot and
are always available.

Two consequences worth knowing:

- `switch_profile` and `spawn_agent` are in all three profiles, and none of the three is
  admin-only — so within a user session the agent can move between them, and the native
  tool surface a user actually has is the **union** of the three. The native sets are kept
  identical for exactly that reason.
- The reaction dispatcher routes a Synapse event into one of the profiles its triggering
  user may use, so a regular user's reactions run in one of these three.

#### Accepted residual risk

Known and accepted by the owner (2026-10-09). These are recorded so that nobody reads the
profile list above as a stronger guarantee than it is:

- **The working directory is not a sandbox.** `terminal` and `code_exec` run as the Navi
  process; `python3 script.py` from a restricted profile can read
  `/home/ubuntu/navi-1/.env`. The only real defence is the model obeying the prompt.
- **Six dangerous tools stay unguarded in code.** `ssh_exec`, `peer`, `reload_tools`,
  `create_mcp_server`, `test_mcp_tool` and `image_view` still check no role; they are
  withheld from the restricted profiles by *composition* alone. A model that talks itself
  into "remembering" one, or finds its description through `tool_manual`, still cannot call
  it — it is not in the list.
- **`gmail` is the exception, and is not covered by composition.** The global file
  `tools/enabled.json` is merged into *every* profile's tool list by `build_tool_list`
  (`navi/core/tool_utils.py`), so `gmail` — Navi's own mailbox, not a personal one — is
  reachable from all three restricted profiles regardless of their `tools.agent.native`.
  The owner accepted this as no risk.
- **`switch_profile` gives the union of the three sets**, as described above.
- **`test_mcp_tool` is why BYOK and the MCP groups hold only for profiles that lack it.**
  Its absence from the restricted profiles is load-bearing.
- **`user_data/` is tracked in git** and holds files that were not meant to be
  distributed; the owner deferred cleanup.

### `navi_code`

Terminal-first local coding assistant. Designed for the Navi Code CLI and single-user local deployments:

- **Native tools:** `todo`, `scratchpad`, `reflect`, `plan`, `switch_profile`, `list_profiles`, `filesystem`, `code_exec`, `terminal`, `image_view`, `ssh_exec`, `memory`, `list_tools`, `tool_manual`, `spawn_agent`, `schedule_recall`, `manage_recall`.
- **MCP tools:** `navi-web` (`search`, `browse`, `request`) — web lookup and page browsing.
- **Excluded:** `share_file`, `content_publish`, `gmail`.
- **Planning:** Phase 1 and Phase 3 enabled; the `plan` tool is in the native tool set, so the agent plans deliberately when a task warrants it. Phase 1 classifies `COMPLEXITY: simple | medium | complex` — complex tasks get "present the plan and wait for confirmation".
- **Bounded autonomy:** `scope_boundary_enabled` is `true` — the agent stays within the requested scope (does not climb to sibling projects or execute discovered milestone/TODO docs). Flip it off to reproduce the legacy "free flight" behavior.
- **Safety:** an authoritative backend permission gate for destructive tool calls is specified in [`permissions.md`](permissions.md) (designed, not yet implemented). The old prompt-level "confirm before destructive ops" nudge and the client-side permission dialog were removed.

Use it with `NAVI_DEFAULT_PROFILE_ID=navi_code` so `POST /sessions` without a `profile_id` creates a `navi_code` session automatically. See [`docs/navi_code.md`](navi_code.md) for the full local-terminal setup.

---

## System prompt construction

The LLM sees (injected fresh on every call, never stored in session):

```
{persona.txt content}

---

{profile system_prompt.txt content}
```

`persona.txt` — global layer: personality, CONTEXT FIRST principle, self-extension rules, scratchpad/todo/memory instructions, delegation rules.

`system_prompt.txt` — domain layer: tool priorities, workflow, safety rules for this profile.

---

## Adding a profile

1. Create directory `navi/profiles/my_profile/`
2. Add `config.json` (minimal example):
```json
{
  "id": "my_profile",
  "name": "My Profile",
  "description": "...",
  "short_description": "...",
  "model": ["gemma4:31b-cloud", "gemma4:26b-a4b-it-q4_K_M"],
  "temperature": 0.5,
  "max_iterations": 20,
  "tools": {
    "agent": {
      "native": ["todo", "scratchpad", "plan", "filesystem", "terminal"],
      "mcp": {
        "navi-web": ["search"]
      }
    },
    "subagent": {
      "native": ["todo", "filesystem", "terminal"],
      "mcp": {}
    }
  },
  "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,
  "scope_boundary_enabled": false,
  "subagent_planning_enabled": false
}
```
3. Add `system_prompt.txt` with domain-specific instructions.
4. Optionally add `subagent_system_prompt.txt`.
5. The profile is auto-discovered at startup — no registration needed.
6. Decide who may use it. Anything that touches the host or the owner's infrastructure
   beyond the working directory gets `"is_admin_only": true` (see the field table above);
   a profile without it is offered to every account with role `user`.

A prompt that documents a tool with `tool_manual("<name>")` or a `manuals/<name>.md` path
must name something that exists — `tests/unit/tools/test_manual_drift.py` walks
`navi/profiles/**/*.txt` and fails on a citation that resolves to nothing.

---

## Profile switching

`switch_profile` tool repoints the session with one narrow `UPDATE sessions SET profile_id` (`PgSessionStore.set_profile()` — never a full `save()`, which would hand out sequence numbers the running turn is still claiming). After each tool execution batch, `run_stream()` compares the stored profile against the profile the run is **bound** to — not against `session.profile_id`, since the store's write leaves those two equal again — and reloads profile + tools when they differ. Takes effect on the next LLM call. For the same reason `save()` leaves the `profile_id` column alone on conflict: a mid-run save carries the profile the run started with and must not write a switch back.

Rules (in persona): don't switch for a single off-topic question; switch when the domain clearly changes; never switch back and forth repeatedly.
