# Configuration CAO stores user configuration in a single file, `~/.aws/cli-agent-orchestrator/settings.json`. This consolidates what used to be two separate files (`settings.json` and `config.json`) into one unified schema, resolved through one precedence chain: ``` CLI flag > CAO_* environment variable > settings.json > built-in default ``` `ConfigService` (`services/config_service.py`) is the single reader/writer behind this precedence chain. `agents`, `skills`, `server`, `memory`, `terminal`, and `apps` are fully wired: setting any of their keys in `settings.json` (or the mapped `CAO_*` env var) has a real runtime effect. `network` and `auth` are schema-only for now — see the "env-var only" callouts in those sections below and in the env-var reference. > `.env` file handling (`utils/env.py`, forwarded provider env vars) is a separate, out-of-scope surface — unaffected by this doc. ## Data directory (`CAO_HOME_DIR`) All CAO state lives under a single base directory, `~/.aws/cli-agent-orchestrator` by default: the SQLite DB, logs, FIFOs, memory, the `agent-store` / `agent-context` profile dirs, skills, workflow scratch, and `settings.json` itself. Set the `CAO_HOME_DIR` environment variable to relocate that entire tree: ```bash export CAO_HOME_DIR="$HOME/.cli-agent-orchestrator" ``` Every derived path resolves from this value, so one override moves everything — with two exceptions noted below. `CAO_HOME_DIR` is read once, when CAO's `constants` module is first imported (the same convention as `CAO_AGENTS_DIR`), so export it **before** starting `cao-server`, the MCP servers, or any `cao` command. All CAO processes must resolve the same location. Empty or whitespace-only values are treated as unset, and tilde (`~`) is expanded. **When to use it.** Some environments restrict or sandbox access to `~/.aws` at the OS level to protect AWS credentials. Because CAO otherwise stores its data there, including the agent profiles it reads during a `handoff`, a locked-down `~/.aws` can leave CAO unable to read its own data (a handoff then fails with `Permission denied`). Relocating `CAO_HOME_DIR` outside `~/.aws` keeps CAO working while leaving those credential protections in place. **Security note.** When relocating outside `~/.aws`, choose a dedicated directory that is not world-readable or shared with other users. CAO creates its base directory and log/FIFO subdirectories with owner-only permissions (mode `0700`), and applies a best-effort `chmod` to an existing base directory, but the chosen parent path should also be private since terminal logs can capture secrets and tokens. **Exceptions.** Two categories of provider-specific config directories do **not** follow `CAO_HOME_DIR`: - `~/.aws/opencode` (OpenCode provider config, managed via `OPENCODE_CONFIG_DIR` in `constants.py`) — OpenCode is told its config location at launch via env vars; a follow-up can repoint this. - Provider-native agent directories (`~/.kiro/agents`, `~/.copilot/agents`) — intentionally separate since each provider manages its own agent install path independently of CAO's data tree. ## settings.json schema ```json { "agents": { "dirs": { "kiro_cli": "~/.kiro/agents", "claude_code": "~/.aws/cli-agent-orchestrator/agent-store", "codex": "~/.aws/cli-agent-orchestrator/agent-store", "cao_installed": "~/.aws/cli-agent-orchestrator/agent-context" }, "extra_dirs": [], "disabled_dirs": [], "roles": {} }, "skills": { "extra_dirs": [] }, "server": { "mcp_request_timeout": 30, "event_bus_max_queue_size": 1024, "provider_init_timeout": 60, "startup_prompt_handler_timeout": 20, "state_buffer_max": 32768 }, "memory": { "enabled": true, "compile_mode": "llm", "flush_threshold": 0.85, "compile_timeout_s": 120.0 }, "terminal": { "backend": "tmux", "herdr_session": "cao" }, "apps": { "enabled": false, "static_dir": null }, "network": { "allowed_hosts": [], "cors_origins": [], "ws_allowed_clients": [] }, "auth": { "jwks_uri": "", "audience": "", "issuer": "" }, "logging": { "level": "INFO" } } ``` Every top-level key is optional — omit any section/key you don't want to override; `ConfigService` fills in built-in defaults. ## `cao config` CLI ```bash cao config get terminal.backend # resolved value (env/file/default applied) cao config set terminal.backend herdr # persist to settings.json cao config list # every known key, resolved cao config path # absolute path to settings.json ``` ## Sections ### Agents (`agents`) CAO discovers agent profiles by scanning multiple directories, in this order (first match wins): 1. **Local store** — `~/.aws/cli-agent-orchestrator/agent-store/` 2. **Provider-specific directories** — `agents.dirs`, keyed by provider 3. **Extra custom directories** — `agents.extra_dirs` 4. **Built-in store** — bundled with the CAO package | Key | Provider | Default Path | |-----|----------|-------------| | `kiro_cli` | Kiro CLI | `~/.kiro/agents` | | `claude_code` | Claude Code | `~/.aws/cli-agent-orchestrator/agent-store` | | `codex` | Codex | `~/.aws/cli-agent-orchestrator/agent-store` | | `cao_installed` | CAO Installed | `~/.aws/cli-agent-orchestrator/agent-context` | Override via REST API, Web UI Settings page, `cao config set agents.dirs. `, or editing `settings.json` directly. Only specified providers are updated; others keep their defaults. `agents.disabled_dirs` lists configured directories (defaults or extras) the user has toggled **off**: a disabled directory stays listed in Settings but is skipped when scanning for and loading agent profiles, so its profiles leave the active set without removing the path (GH #280/#281). Only paths that match a configured directory are accepted; entries are validated with the same path normalization the scanner uses (`~`, trailing slashes, and symlinks all match). Manage it via the Web UI Settings toggles, `cao config set agents.disabled_dirs '[...]'`, or `settings.json`. `agents.roles` defines custom [role](../CODEBASE.md) → `allowedTools` bundles, layered on top of the built-in `supervisor` / `reviewer` / `developer` roles. ### Skills (`skills`) Skills (loaded on demand via the `load_skill` MCP tool) are discovered from, in order: 1. **Global skill store** — `~/.aws/cli-agent-orchestrator/skills/` 2. **Extra custom directories** — `skills.extra_dirs` `skills.extra_dirs` lets you keep a project's skills in the project repo (e.g. `/.cao/skills`) and register the directory instead of copying/symlinking each skill into the global store. ### Server (`server`) Timeouts and buffer sizes used by the CAO runtime. All values have safe defaults — only override if you experience timeouts or queue overflows. | Setting | Default | Description | |---------|---------|--------------| | `mcp_request_timeout` | `30` | Seconds to wait for HTTP calls between the MCP server process and the CAO API. | | `event_bus_max_queue_size` | `1024` | Max events buffered per subscriber queue in the internal event bus. | | `provider_init_timeout` | `60` | Seconds to wait for a CLI agent to reach IDLE. Also the hard outer cap on total time a startup-prompt handler (Claude Code, Kimi, Antigravity) may run. Overridable per-profile via `provider_init_timeout` in the agent profile — see [Agent Profile Format](agent-profile.md#optional-fields). | | `startup_prompt_handler_timeout` | `20` | Idle gap, in seconds, between consecutive startup prompts (e.g. workspace trust / bypass dialogs, Kimi's upgrade dialog, Antigravity's trust/survey dialogs). The handler polls and resets this timer each time it answers a prompt; it only starts counting once the FIRST prompt has been handled, so a first dialog arriving later than this value (e.g. a cold/containerized start) is still caught — before any prompt is seen, only `provider_init_timeout` bounds the wait. Once at least one prompt has been handled, the handler exits after this many seconds pass with no further prompt. | | `state_buffer_max` | `32768` | Bytes of raw terminal output `StatusMonitor` keeps per terminal for raw-path status detection and `GET /terminals/{id}/output` (`mode=full`). Not unbounded scrollback — a long, chatty session is truncated to this trailing window; raise it if a still-pending prompt is getting evicted before it's read back. | ### Memory (`memory`) | Setting | Default | Description | |---------|---------|--------------| | `enabled` | `true` | Master switch for the memory subsystem. | | `compile_mode` | `"llm"` | `llm` or `append`. `append` skips the LLM wiki-compiler entirely. | | `flush_threshold` | `0.85` | Context-usage fraction that triggers a memory flush. | | `compile_timeout_s` | `120.0` | Wall-clock timeout for the wiki compile call. | | `learning_enabled` | `false` | Opt-in switch for workflow self-learning (outcome capture via `report_outcome` / `/outcomes`). Requires `enabled=true` — a disabled memory subsystem forces learning off. Env override: `CAO_MEMORY_LEARNING_ENABLED`. See [Self-Learning](self-learning.md). | | `instruction_promotion_enabled` | `false` | Opt-in switch for promoting reinforced lessons into agent profile files (`cao memory promote --apply`). Requires `learning_enabled=true` (promotion ⊂ learning ⊂ memory). Env override: `CAO_MEMORY_INSTRUCTION_PROMOTION_ENABLED`. ⚠️ Promoted lesson text is agent-generated: review every promote diff as an untrusted-instruction change before applying — see [Self-Learning](self-learning.md#phase-2--instruction-promotion). | | `workflow_journal_capture_output` | `false` | ⚠️ **Security-relevant opt-in, but narrower than it sounds — read the note below this table.** Governs exactly two surfaces: the **event log's** output digest, and the **diagnostics bundle's** output excerpts. Turning it ON adds step output text to those two. It does **not** control the `workflow_run_step` projection, which retains output unconditionally either way. Retained text is size-capped (below) and cleaned through the shared `audit_log` sanitizer — transport hygiene (control-character stripping, size limiting), **not** secret redaction: a credential in a step's output is retained verbatim. | | `workflow_journal_output_cap_bytes` | `8192` | Per-output byte cap applied when `workflow_journal_capture_output` is on; anything longer is truncated with the shared `[…truncated]` marker. Deliberately above `audit_log`'s 4 KiB per-field cap because a worker step's output is materially larger than a single audit field. Must be `>= 1`. | | `workflow_journal_retention_days` | `30` | Age bound for the startup retention sweep: a run whose `started_at` is older than this many days is pruned (run row, steps, events, and seq high-water, in one cascade). **`0` DISABLES the age bound** (unlimited) — it does not mean "expire everything". Must be `>= 0`. | | `workflow_journal_retention_count` | `100` | Run-count bound for the same sweep: runs beyond the most-recent N are pruned. Pruning is the **union** of the two bounds — whichever matches a run first removes it. **`0` DISABLES the count bound** (unlimited) — it does not mean "keep zero runs"; both bounds at `0` makes the sweep a no-op. To remove a specific run, use `DELETE /workflows/runs/{id}` instead. Must be `>= 0`. | > #### ⚠️ Step output is retained regardless of `workflow_journal_capture_output` > > Turning that flag **off does not stop the journal from storing step output.** The > `workflow_run_step` projection persists each step's full `output_json` and `error` > text on every state transition, unconditionally, because the resume path and > `{{steps..output.}}` templating read them back — gating them would > break both. The flag governs only the event-log output digest and the diagnostics > bundle's excerpts. > > That retained text is served, in full, by `GET /workflows/runs/{run_id}`. Its only > protection is a scope requirement (`cao:read`/`cao:write`/`cao:admin`), and that > requirement is **inert unless you enable authentication**: with `CAO_AUTH_ENABLED` > unset — the default — the dependency returns the full scope set and enforces > nothing. A default local CAO server therefore serves step output and error text to > anything that can reach its port. > > Practical consequences: > > - Treat a step's output and error text as **retained and readable**, not as gated > by a setting. A credential echoed by a step is stored verbatim and returned > verbatim; the `audit_log` sanitizer caps size and strips control characters, it > does not detect secrets. > - To limit exposure, enable auth (so the scope gate becomes real), keep the > retention bounds tight, and delete runs you no longer need with > `DELETE /workflows/runs/{run_id}`. > - An earlier revision of this table claimed the journal stored "execution metadata > only … never prompt text or step output" when the flag was off. That was wrong in > the security-relevant direction and is corrected above. ### Terminal backend (`terminal`) CAO's default backend is [tmux](tmux.md). [herdr](https://herdr.dev/) is an experimental, opt-in alternative — a terminal-native agent runtime that exposes real-time status events instead of requiring CAO to poll and pattern-match terminal output. ```json { "terminal": { "backend": "herdr", "herdr_session": "my-session" } } ``` - `backend`: `"tmux"` (default) or `"herdr"` [EXPERIMENTAL]. - `herdr_session`: the herdr session name to connect to (default `"cao"`). Select a backend for a single run without touching `settings.json`: ```bash cao-server --terminal herdr ``` `--terminal` (CLI flag) beats `CAO_TERMINAL_BACKEND` (env var) beats `terminal.backend` (file) beats the `"tmux"` default — the standard precedence chain. See [herdr.md](herdr.md) for herdr-specific setup, viewing/attaching, and troubleshooting. ### MCP Apps (`apps`) Default-off. See [../src/cli_agent_orchestrator/ext_apps/apps.py](../src/cli_agent_orchestrator/ext_apps/apps.py) for the `ui://cao/*` MCP App resource surface this gates. | Setting | Default | Description | |---------|---------|--------------| | `enabled` | `false` | Enables the MCP Apps surface (dashboard/agent/event-stream views + app tools + topology widget). | | `static_dir` | `null` | Override for the built `apps_static/` directory (packaged/dev-tree locations are tried automatically otherwise). | ### Network (`network`) — env-var only > **`network.*` keys in `settings.json` are schema-only and have no runtime effect yet.** `constants.py` builds `CORS_ORIGINS` / `ALLOWED_HOSTS` / `WS_ALLOWED_CLIENTS` as module-level lists at import time, and Starlette's CORS/TrustedHost middleware are instantiated once at server startup holding a reference to those exact list objects (`add_local_cors_origins` depends on this reference semantics). Only the `CAO_ALLOWED_HOSTS` / `CAO_CORS_ORIGINS` / `CAO_WS_ALLOWED_CLIENTS` / `CAO_WS_ALLOWED_ORIGINS` / `CAO_FORWARDED_ALLOW_IPS` env vars are read — directly in `constants.py`, not through `ConfigService`. Routing these through the unified config would require either a live-invalidation path for the middleware's list references or restructuring how the middleware is wired; both are out of scope for this PR. `cao-server` is a local-only service by default. These env vars **extend** (not replace) the loopback-only built-in defaults, so loopback access is preserved even when set. | Env var | Extends | Use case | |---|---|---| | `CAO_ALLOWED_HOSTS` | `ALLOWED_HOSTS` (Host header allowlist for `TrustedHostMiddleware`) | Fronting cao-server with a reverse proxy at a non-localhost hostname. | | `CAO_CORS_ORIGINS` | `CORS_ORIGINS` (browser origins permitted by CORS) | Serving the web UI from a non-default port or origin. | | `CAO_WS_ALLOWED_CLIENTS` | `WS_ALLOWED_CLIENTS` (client IPs permitted to attach to the PTY WebSocket) | Running `cao-server` inside Docker (host browser arrives via a bridge IP). | | `CAO_WS_ALLOWED_ORIGINS` | `WS_ALLOWED_ORIGINS` (extra browser `Origin`s permitted to attach to the PTY WebSocket) | Serving the terminal viewer from a page whose origin **differs** from the cao-server host (a separate reverse-proxy hostname or dashboard). | > **Security note:** the WebSocket PTY endpoint is unauthenticated. Only add client IPs you actually trust to `CAO_WS_ALLOWED_CLIENTS` — anyone reaching the listener at one of those IPs gets full PTY access to running agent terminals. > > The endpoint also enforces an **Origin** check to block cross-site WebSocket hijacking (CWE-1385): a browser page that is not same-origin with the cao-server host (and not in `CAO_WS_ALLOWED_ORIGINS`) is refused. Same-origin viewers — including the bundled UI, imported-app deployments (`uvicorn cli_agent_orchestrator.api.main:app`), and dynamic reverse-proxy / Codespaces hostnames — work with no configuration, because the check accepts any `Origin` whose authority equals the request `Host` (itself validated by `TrustedHostMiddleware`). `CAO_WS_ALLOWED_ORIGINS` is only for *genuinely cross-origin* viewers; a literal `*` disables the Origin check. Note that `CAO_CORS_ORIGINS="*"` does **not** disable it — PTY access is more sensitive than ordinary CORS reads, so the escape hatch is the dedicated `CAO_WS_ALLOWED_ORIGINS="*"`. ### Auth (`auth`) — env-var only > **`auth.*` keys in `settings.json` are schema-only and have no runtime effect yet.** `security/auth.py` is the actual authentication *enforcement* boundary (not a UX gate) and is deliberately kept on direct `os.getenv` reads in this PR, to avoid changing security-critical resolution behavior. Only the env vars below are honored. Default-off OAuth 2.1 auth core; see [security/auth.py](../src/cli_agent_orchestrator/security/auth.py). Auth activates only when `CAO_AUTH_JWKS_URI` (or `AUTH0_DOMAIN`) is set. | Env var | Description | |---------|--------------| | `CAO_AUTH_JWKS_URI` | Generic IdP JWKS endpoint. | | `CAO_AUTH_AUDIENCE` | Expected token audience. | | `CAO_AUTH_ISSUER` | Issuer advertised by the RFC 9728 PRM endpoint. | ### Logging (`logging`) | Setting | Default | Description | |---------|---------|--------------| | `level` | `"INFO"` | Log level for the CAO server log file. | ## Environment variable reference ### Wired through ConfigService Every `CAO_*` variable below maps 1:1 to a `settings.json` key and is resolved through the standard precedence chain — the env var beats the file, and both lose to an explicit CLI flag where one exists. Setting either the env var or the `settings.json` key has a real runtime effect. | Env var | Config path | Type | |---|---|---| | `CAO_TERMINAL_BACKEND` | `terminal.backend` | str | | `CAO_HERDR_SESSION` | `terminal.herdr_session` | str | | `CAO_MCP_APPS_ENABLED` | `apps.enabled` | bool | | `CAO_MCP_APPS_STATIC_DIR` | `apps.static_dir` | str | | `CAO_LOG_LEVEL` | `logging.level` | str | | `CAO_MEMORY_ENABLED` | `memory.enabled` | bool | | `CAO_MEMORY_COMPILE_MODE` | `memory.compile_mode` | str (`llm`/`append`) | | `CAO_MEMORY_FLUSH_THRESHOLD` | `memory.flush_threshold` | float | | `CAO_MCP_REQUEST_TIMEOUT` | `server.mcp_request_timeout` | int | | `CAO_EVENT_BUS_MAX_QUEUE_SIZE` | `server.event_bus_max_queue_size` | int | | `CAO_PROVIDER_INIT_TIMEOUT` | `server.provider_init_timeout` | int | | `CAO_STARTUP_PROMPT_HANDLER_TIMEOUT` | `server.startup_prompt_handler_timeout` | int | The full table lives in `ConfigService.ENV_REGISTRY` (`services/config_service.py`) — the source of truth this doc mirrors. ### Env-var only (settings.json value not yet honored) These map to `network.*` / `auth.*` schema paths for documentation purposes, but only the env var is actually read — see the "env-var only" notes in the [Network](#network-network--env-var-only) and [Auth](#auth-auth--env-var-only) sections above for why. | Env var | Config path | Type | |---|---|---| | `CAO_AUTH_JWKS_URI` | `auth.jwks_uri` | str | | `CAO_AUTH_AUDIENCE` | `auth.audience` | str | | `CAO_AUTH_ISSUER` | `auth.issuer` | str | | `CAO_ALLOWED_HOSTS` | `network.allowed_hosts` | comma-separated list | | `CAO_CORS_ORIGINS` | `network.cors_origins` | comma-separated list | | `CAO_WS_ALLOWED_CLIENTS` | `network.ws_allowed_clients` | comma-separated list | | `CAO_WS_ALLOWED_ORIGINS` | `network.ws_allowed_origins` | comma-separated list | ### Not yet routed through ConfigService A number of other `CAO_*` variables (runtime/process-identity vars like `CAO_TERMINAL_ID`, `CAO_SESSION_NAME`, `CAO_WORKFLOW_RUN_ID`; provider-tuning vars like `CAO_HERMES_*`, `CAO_AGENTS_DIR`, `CAO_API_HOST`/`CAO_API_PORT`, `CAO_PYTE_STATUS`, `CAO_EAGER_INBOX_DELIVERY`; and `CAO_AUTH_LOCAL_TOKEN`) are still read ad hoc via `os.getenv` at their call sites, mostly in `constants.py`, `mcp_server/server.py`, `security/auth.py`, and the `providers/*` modules. These were deliberately left out of this pass to keep the diff scoped to the two surfaces issue #357 named explicitly (`settings.json` + `config.json`); folding them into the registry is a natural follow-up but not required for config unification. | Env var | Default | Type | Purpose | |---|---|---|---| | `CAO_HOME_DIR` | `~/.aws/cli-agent-orchestrator` | str (path) | Base directory for all CAO state. See [Data directory](#data-directory-cao_home_dir) above. | The pipe-pane liveness watchdog (issue #388, `services/fifo_reader.py`) adds six more of these ad-hoc vars, read directly via `_env_int`/`_env_float` in `constants.py` rather than through `ConfigService` — they have no `settings.json` mapping like the rows in the table above: | Env var | Default | Type | Purpose | |---|---|---|---| | `CAO_PIPE_LIVENESS_CHECK_INTERVAL_S` | `4.0` | float | How often the watchdog compares live pane content against FIFO delivery, per enrolled terminal. | | `CAO_PIPE_LIVENESS_TAIL_LINES` | `80` | int | Lines of live pane content compared each check (`capture-pane` tail size). | | `CAO_PIPE_LIVENESS_STALL_CHECKS` | `2` | int | Consecutive diverging checks required before re-arming a stalled forwarder. | | `CAO_PIPE_LIVENESS_MAX_REARM_FAILURES` | `5` | int | Consecutive failed re-arm attempts before the watchdog gives up on a terminal. | | `CAO_PIPE_LIVENESS_COLD_START_GRACE_S` | `3.0` | float | Grace period after a terminal is registered before a FIFO that has never delivered a single byte is treated as a cold-start stall (harness-control#93) instead of "still booting". | | `CAO_PIPE_LIVENESS_MAX_COLD_START_ATTEMPTS` | `5` | int | Consecutive cold-start re-arm attempts (rearm() succeeded but the pipe still never delivered) before the watchdog gives up on a terminal — a separate failure class and counter from `CAO_PIPE_LIVENESS_MAX_REARM_FAILURES`, which only counts rearm() raising. | ## API Endpoints | Method | Endpoint | Description | |--------|----------|-------------| | `GET` | `/settings/agent-dirs` | Get current agent directories (merged with defaults) | | `POST` | `/settings/agent-dirs` | Update agent directories | | `GET` | `/settings/skill-dirs` | Get the global skill store path and extra skill directories | | `POST` | `/settings/skill-dirs` | Set extra custom skill directories | | `GET` | `/settings/memory` | Whether the memory subsystem is enabled | See [api.md](api.md) for the full API reference. ## Migrating from the old two-file setup Previously, `terminal_backend` / `herdr_session` lived in a separate `~/.aws/cli-agent-orchestrator/config.json`, read inline by `backends/factory.py`. On first read after upgrading, if `config.json` exists and `settings.json` has no `terminal` section yet, `ConfigService` copies `terminal_backend` → `terminal.backend` and `herdr_session` → `terminal.herdr_session` into `settings.json` and logs the move once. `config.json` is left on disk untouched but is no longer read afterward — it is deprecated.