# Settings API

Read and live-update workspace configuration via HTTP. No server restart required.

---

## `GET /settings`

Returns settings for the workspace. Secret-bearing fields are **redacted** (shown as `sk-…xxxx`). Redaction covers any field named like a credential: `api_key`, `apikey`, `secret`, `token`, `password`, `access_key`.

> **Round-trip safe:** if you `GET` settings, edit them, and `PUT` them back, any field still holding its redaction placeholder is restored to the real stored value server-side — saving an edited settings payload never destroys stored credentials.

**Query parameters**

| Param | Type | Description |
|-------|------|-------------|
| `level` | string | Which layer to read. Default: `merged` |

**Level values**

| Value | Source file | Description |
|-------|-------------|-------------|
| `merged` | *(all layers)* | Full effective settings — defaults → global → project → local merged. **(default)** |
| `project` | `.veil/settings.json` | Project-level overrides only |
| `global` | `~/.veil/settings.json` | User-level global overrides only |
| `local` | `.veil/settings.local.json` | Local machine overrides only (gitignored) |

**Response**
```json
{
  "level": "merged",
  "exists": true,
  "path": null,
  "settings": {
    "port": 5050,
    "models": {
      "main": {
        "base_url": "https://openrouter.ai/api/v1",
        "api_key": "sk-…1234",
        "model": "anthropic/claude-sonnet-4-5"
      }
    },
    "providers": {
      "openrouter": {
        "type": "openai",
        "base_url": "https://openrouter.ai/api/v1",
        "api_key": "sk-…1234"
      },
      "claude-local": {
        "type": "claude-cli",
        "path": "claude",
        "permission_mode": "acceptEdits"
      }
    },
    "routing": {
      "default": "openrouter",
      "fallback": [],
      "per_model": {},
      "per_agent": {}
    },
    "permissions": { "allow": [], "deny": [], "ask": [] },
    "memory": { "enabled": true, "maxLines": 500 },
    "maxIterations": 20,
    "maxDurationSeconds": 300
  }
}
```

| Field | Description |
|-------|-------------|
| `level` | The level that was read |
| `exists` | Whether the settings file exists on disk (`false` for `merged` is never returned) |
| `path` | Absolute path to the file (`null` for `merged`) |
| `settings` | The settings object (raw file contents for non-merged levels; `{}` if file absent) |

**Examples**
```bash
# Full merged settings (default)
curl http://localhost:5050/settings

# Only what's in .veil/settings.json
curl http://localhost:5050/settings?level=project

# Only what's in ~/.veil/settings.json
curl http://localhost:5050/settings?level=global
```

**Error responses**

| Code | Condition |
|------|-----------|
| `400 INVALID_LEVEL` | `level` is not one of the valid values |

---

## `PUT /settings`

Validate and write settings to the specified level file. Immediately live-reloads merged settings — all subsequent requests use the new effective values.

**Query parameters**

| Param | Type | Description |
|-------|------|-------------|
| `level` | string | Which file to write. Default: `project`. `merged` is not writable. |

| Value | Writes to |
|-------|----------|
| `project` | `.veil/settings.json` **(default)** |
| `global` | `~/.veil/settings.json` |
| `local` | `.veil/settings.local.json` |

**Request body:** A valid settings object (see [Configuration guide](../guide/03-configuration.md)). Validated against the settings schema before writing.

**Response**
```json
{
  "status": "updated",
  "level": "project",
  "path": "/home/user/workspace/.veil/settings.json",
  "settings": { ... }
}
```

`settings` in the response is the full **merged** effective settings after the write.

**Examples**
```bash
# Write to project settings (default)
curl -X PUT http://localhost:5050/settings \
  -H "Content-Type: application/json" \
  -d '{"maxIterations": 30, "memory": {"enabled": true}}'

# Write to global settings
curl -X PUT "http://localhost:5050/settings?level=global" \
  -H "Content-Type: application/json" \
  -d '{"models": {"main": {"model": "anthropic/claude-sonnet-4-5"}}}'
```

**Error responses**

| Code | Condition |
|------|-----------|
| `400 INVALID_LEVEL` | `level` is `merged` (read-only) or not a valid value |
| `400 VALIDATION_ERROR` | Body fails schema validation (field-level error messages included) |

---

## Settings keys reference

The settings object accepts (among other keys documented in [Configuration](../guide/03-configuration.md)) the following Phase 3 / Meeting 010 surfaces:

### `budget`

Chat-side governor for `agent_spawn` / `agent_message` / per-call `overrides`. All axes default to `null` = unlimited.

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

| Field | Type | Description |
|-------|------|-------------|
| `max_tokens` | integer \| null | Per-call cap on input + output tokens combined. Breach emits `session.budget_exceeded` and stops the loop. |
| `max_wall_seconds` | integer \| null | Per-call wall-clock cap with the same breach event. |
| `max_spawn_depth` | integer \| null | Cap on nested `agent_spawn` depth. Breach returns `400 BUDGET_EXCEEDED`. |

Resolution order: per-call `budget_override` > per-agent `agent.json`'s `budget` > harness `settings.budget`. `null` = unlimited; `0` = immediate breach (intentional, not coerced).

### `summarizerModel`

```json
{ "summarizerModel": "google/gemini-3-flash-preview" }
```

Override the model used by `agent_control` `get-summary`. When unset, falls back to `config/config.json`'s `defaultSummarizerModel`. Routed through the same provider system as any other model string.

### `summarizerMaxCallsPerMinute`

```json
{ "summarizerMaxCallsPerMinute": 6 }
```

Opt-in rolling-minute cap on `agent_control` `get-summary` calls per workspace. Default `null` = unlimited. When the cap is set and exceeded, `get-summary` returns the same envelope plus `<rate-limited>true</rate-limited>` and `<retry-after-ms>60000</retry-after-ms>` instead of running the LLM call.

### `compaction.observationMasking`

```json
{ "compaction": { "observationMasking": false } }
```

Boolean, **default `false`**. When `true`, tool results older than `compaction.observationMaskingTurns` (default 10) are replaced with `[output hidden]` on every iteration. It is **opt-in** because rewriting history every turn invalidates provider prompt caches and strips tool results agents may still need. See [Configuration → compaction](../guide/03-configuration.md#compaction).

### `claudeCli`

Engine controls 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* image files — how temp-saved image attachments are seen; MCP tool results can't carry images) — 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, so every engine runs the same tool implementations, permissions, and event mapping. Set `false` to keep the known-safe native file/shell/web tools (`Bash`, `BashOutput`, `KillShell`, `Read`, `Write`, `Edit`, `Glob`, `Grep`, `WebFetch`, `WebSearch`, `NotebookEdit`). |
| `nativeTools` | string[] | *(unset)* | Explicit native base set, used **verbatim** — overrides `redirectBasicTools` for the base set. This is the whole native allowlist. |

The native tool exposure is an **allowlist**: any tool a new Claude Code SDK release ships (e.g. new agentic tools) is excluded unless it appears in the resolved base set, so SDK updates can't silently leak new tools into agents. `TodoWrite`, `TodoRead`, `Agent`, and `AskUserQuestion` are always disabled (Veil provides its own).

---

## Notes

- The settings layer stack is: **defaults → global → project → local**. Each layer overrides the previous.
- Model credentials (`api_key`) can be set in `auth.json` or directly inside `providers` entries in `settings.json`. When both exist, the provider-level key takes precedence.
- The `providers` object defines named LLM providers (keyed by name). Each has a `type` (`"openai"` or `"claude-cli"`), `base_url`, and `api_key`. The `routing` object controls provider selection: `default`, `fallback`, `per_model`, and `per_agent`.
- Live reload only affects in-memory settings. Currently running turns see the old settings until they complete.
- **File hot-reload:** editing `settings.json`, `auth.json`, or `settings.local.json` directly on disk (global `~/.veil/` **or** project `.veil/`) is picked up automatically — the server re-merges and applies the new values without a restart, the same as a `PUT`. **Exception:** a changed `port` still requires a restart, since the HTTP listener is already bound.
- `PUT` with `?level=local` is useful for machine-specific overrides that should not be committed to version control.
