# Configuration Reference

VeilCLI uses two config files per workspace: `auth.json` for credentials and `settings.json` for everything else.

---

## Settings Layers

Settings are loaded and merged in this order (each layer overrides the previous):

```
1. Built-in defaults
2. ~/.veil/settings.json     (global settings)
3. ~/.veil/auth.json          (global credentials)
4. .veil/settings.json        (project settings)
5. .veil/auth.json            (project credentials)
6. .veil/settings.local.json  (local overrides, gitignored)
7. CLI flags                    (--port, --secret, etc.)
```

This means you can set a default model in `~/.veil/auth.json` and override it per-project in `.veil/auth.json`.

> **Hot reload:** the server watches all of these files (global `~/.veil/` and project `.veil/`) and re-merges them on change — editing `settings.json`, `auth.json`, or `settings.local.json` on disk applies without a restart. The one exception is `port`: since the HTTP listener is already bound, a port change still needs a restart.

---

## `auth.json`

Stores API credentials. Keep this out of version control.

```json
{
  "models": {
    "main": {
      "base_url": "https://openrouter.ai/api/v1",
      "api_key": "sk-or-v1-...",
      "model": "moonshotai/kimi-k2.6"
    },
    "compact": {
      "base_url": "https://openrouter.ai/api/v1",
      "api_key": "sk-or-v1-...",
      "model": "google/gemini-flash-1.5-8b"
    },
    "title": null
  }
}
```

### Model roles

| Role | Purpose | Fallback |
|------|---------|----------|
| `main` | All agent LLM calls | — (required) |
| `compact` | Context compaction summaries (auto + default) | Falls back to `main` |
| `title` | Generate session titles | Falls back to `main` |

### Model config fields

| Field | Type | Description |
|-------|------|-------------|
| `base_url` | string | API base URL (default: `https://openrouter.ai/api/v1`) |
| `api_key` | string | API key |
| `model` | string | Model identifier string, e.g. `moonshotai/kimi-k2.6` |
| `temperature` | number | Override temperature (0–2) |
| `reasoning` | object | Engine-blind reasoning config: `{ effort, max_tokens? }`. See [API reference 05-sessions.md](../api/05-sessions.md#reasoning-unification). |
| `maxTokens` | integer | Override max output tokens |

### `providers` and `routing` in `auth.json`

`auth.json` can also carry `providers` and `routing` blocks (the same shapes used in `settings.json`), so credentials and provider selection live together out of version control. These are merged into the effective config on top of the global layer.

Routing is merged with **"empty means unset"** semantics: `null`, `[]`, and `{}` values are skipped rather than applied. A scaffolded project `auth.json` — `{"providers":{},"routing":{"default":null,"fallback":[]}}` — therefore **does not erase** routing inherited from global config. Only non-empty values override.

### Shortcut: `veil login`

```bash
veil login --key sk-or-v1-...          # writes to .veil/auth.json
veil login --key sk-or-v1-... --global # writes to ~/.veil/auth.json
```

---

## `settings.json`

Full runtime configuration. All fields are optional — omitting any field uses the default.

### Top-level fields

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `port` | integer | `5050` | HTTP server port |
| `secret` | string \| null | `null` | If set, all API requests must include `X-Veil-Secret: <secret>` |
| `maxIterations` | integer | `50` | Default max LLM loop iterations per chat turn |
| `maxDurationSeconds` | integer | `300` | Default max wall-clock seconds per turn |
| `summarizerModel` | string \| null | `null` | Override the default summarizer model used by `agent_control` `get-summary` (falls back to `config/config.json`'s `defaultSummarizerModel`, currently `google/gemini-3-flash-preview`). |
| `summarizerMaxCallsPerMinute` | integer \| null | `null` | Opt-in rate cap for `agent_control` `get-summary` calls per workspace. Default `null` = unlimited. When set and exceeded, the call returns the same envelope plus `<rate-limited>true</rate-limited>` and `<retry-after-ms>60000</retry-after-ms>` instead of running the LLM call. |

### `models`

Can also be specified in `settings.json` (alongside or instead of `auth.json`):

```json
{
  "models": {
    "main": { "base_url": "...", "api_key": "...", "model": "..." }
  }
}
```

### `budget`

Phase 3 chat-side governance for `agent_spawn` / `agent_message` / per-call `overrides`. All three axes default to `null` = **unlimited** unless explicitly set. Resolution order: per-call `budget_override` > per-agent `budget` block > harness-level `settings.budget`.

```json
{
  "budget": {
    "max_tokens": 200000,
    "max_wall_seconds": 600,
    "max_spawn_depth": 4
  }
}
```

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `max_tokens` | integer \| null | `null` | Per-call cap on combined input + output tokens. When breached the loop stops and emits a `session.budget_exceeded` event. |
| `max_wall_seconds` | integer \| null | `null` | Per-call wall-clock cap. Same breach event. |
| `max_spawn_depth` | integer \| null | `null` | Max depth of nested `agent_spawn` calls. When a child would exceed the cap, `agent_spawn` returns `BUDGET_EXCEEDED` with a self-explanatory message instructing the subagent to ask its spawner to raise the limit. |

**`null` vs `0`:** `null` means unlimited; `0` means immediate breach on every call. The resolver uses `??` semantics — `0` is honored as a real limit, not coerced to "unlimited." See [Multi-Agent](09-multi-agent.md#depth-and-budget) for how the budget interacts with `agent_spawn`'s `budget_override`.

### `permissions`

Global tool permission policy applied to all agents (unless overridden per-agent or per-mode).

```json
{
  "permissions": {
    "allow": ["*"],
    "deny": ["bash"],
    "ask": ["write_file"]
  }
}
```

| Field | Type | Description |
|-------|------|-------------|
| `allow` | string[] | Tools explicitly allowed. Use `["*"]` to allow all. |
| `deny` | string[] | Tools explicitly denied. Takes priority over `allow`. |
| `ask` | string[] | Tools that require human approval before executing. Settings-level only. |

See [Permissions](07-permissions.md) for the full layering explanation.

### `hooks`

Shell commands to run before/after every tool execution:

```json
{
  "hooks": {
    "PreToolUse": "/path/to/pre-hook.sh",
    "PostToolUse": "/path/to/post-hook.sh"
  }
}
```

The hook receives tool name and input as environment variables. Set to `null` to disable.

### `compaction`

Controls automatic context window management:

```json
{
  "compaction": {
    "threshold": 0.92,
    "observationMasking": false,
    "observationMaskingTurns": 10
  }
}
```

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `threshold` | number | `0.92` | Fraction of context window (0.1–1.0) that triggers compaction. Usage is measured against the model's real context window using the **provider-reported** token count from the previous call (the higher of that and the chars/4 estimate), not a fixed 100k default. |
| `observationMasking` | boolean | `false` | When `true`, tool results older than `observationMaskingTurns` are replaced with `[output hidden]` on every iteration. **Opt-in** — it rewrites history each turn, which breaks provider prompt caches and strips tool outputs agents may still need, so it never runs by default. |
| `observationMaskingTurns` | integer | `10` | Number of recent turns to keep tool results visible; older ones are masked (only when `observationMasking` is `true`) |

See [Memory & Compaction](08-memory.md).

### `memory`

```json
{
  "memory": {
    "enabled": true,
    "maxLines": 500
  }
}
```

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabled` | boolean | `true` | Whether memory is active — with `false`, `memory_write` and the pre-compaction extractor are disabled |
| `maxLines` | integer | `200` | Max lines kept in `MEMORY.md`; overflow is archived to `archive-YYYY-MM.md` (whole entries only) |

### `storage.retention`

Controls how long sessions are kept in SQLite:

```json
{
  "storage": {
    "retention": {
      "sessions": { "maxAgeDays": 90, "maxCount": 10000 }
    }
  }
}
```

### `mcpServers`

Configuration for Model Context Protocol (MCP) servers:

```json
{
  "mcpServers": {
    "my-mcp": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
    }
  }
}
```

MCP tools are available to agents that list the server name in their `modes.<mode>.mcpServers` array (whitelist-only).

### `ably`

Enable Ably for remote API access (e.g. from external clients over the internet):

```json
{
  "ably": {
    "enabled": true,
    "key": "xVLya.AblyKey"
  }
}
```

### `claudeCli`

Controls native-tool exposure for agents that route through the **claude-cli** engine (a `cc/…` model backed by the Claude Agent SDK). Ignored by openai-engine agents.

```json
{
  "claudeCli": {
    "redirectBasicTools": true,
    "nativeTools": ["Read", "Grep", "Glob"]
  }
}
```

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `redirectBasicTools` | boolean | `true` | When on, claude-cli agents keep only native `Read` (it can *view* images — how temp-saved image attachments are seen); Veil's own `write_file` / `edit_file` / `bash` / `bash_output` / `kill_shell` / `glob` / `grep` / `list_dir` / `web_fetch` / `web_search` / `sleep` are exposed via MCP for everything else. Set `false` to keep the known-safe native file/shell/web tools. |
| `nativeTools` | string[] | *(unset)* | Explicit native base set, used verbatim — overrides `redirectBasicTools` for the base set. Acts as the full native allowlist. |

Native-tool exposure is an **allowlist**: any tool a newer Claude Code SDK ships is excluded unless it's in the resolved base set, so SDK updates can't leak new tools into agents. See also [Tools → engine parity](06-tools.md#engine-parity--claude-cli).

---

## Complete Example

```json
{
  "port": 5051,
  "secret": "my-dev-secret",
  "maxIterations": 30,
  "maxDurationSeconds": 180,
  "summarizerMaxCallsPerMinute": 6,
  "budget": {
    "max_tokens": 200000,
    "max_wall_seconds": 600,
    "max_spawn_depth": 4
  },
  "permissions": {
    "allow": ["read_file", "list_dir", "bash", "web_search", "web_fetch"],
    "deny": [],
    "ask": ["write_file", "edit_file"]
  },
  "compaction": {
    "threshold": 0.75,
    "observationMaskingTurns": 8
  },
  "memory": {
    "enabled": true,
    "maxLines": 300
  },
  "storage": {
    "retention": {
      "sessions": { "maxAgeDays": 30, "maxCount": 5000 }
    }
  }
}
```
