# Config File

The config file lives at `~/.anteroom/config.yaml`.

To discover settings without scanning this full reference, use:

```bash
aroom config search approval
aroom config help safety.approval_mode
```

The same registry powers `/config help <topic>` in the CLI and `GET /api/config/fields?query=<topic>` in the API.

## Full Reference

```yaml
ai:
  base_url: "https://your-ai-endpoint/v1"    # Required
  api_key: "your-api-key"                     # Required (or use api_key_command)
  api_key_command: "vault read -field=key"    # Alternative: run a command to get the key
  model: "gpt-4"                              # Default model
  system_prompt: "You are a helpful assistant."
  verify_ssl: true                            # SSL cert verification (default: true)
  request_timeout: 120                        # API request timeout in seconds (default: 120, clamped 10–600)
  connect_timeout: 5                           # TCP connect timeout in seconds (default: 5, clamped 1–30)
  write_timeout: 30                            # Time to send request body in seconds (default: 30, clamped 5–120)
  pool_timeout: 10                             # Wait for free connection from pool in seconds (default: 10, clamped 1–60)
  first_token_timeout: 30                      # Max wait for first token after connect (default: 30, clamped 5–120)
  chunk_stall_timeout: 30                      # Max silence between chunks mid-stream in seconds (default: 30, clamped 10–600)
  retry_max_attempts: 3                        # Retries on transient errors; 0 disables (default: 3, clamped 0–10)
  retry_backoff_base: 1.0                      # Exponential backoff base in seconds (default: 1.0, clamped 0.1–30.0)
  temperature: null                             # Model temperature for randomness (0.0–2.0, null = provider default)
  top_p: null                                   # Nucleus sampling parameter (0.0–1.0, null = provider default)
  seed: null                                    # Random seed for deterministic output (null = provider default)
  provider: "openai"                            # AI backend: "openai", "anthropic", or "litellm" (100+ providers)
  max_output_tokens: 4096                       # Max generated tokens per response (default: 4096; required by some providers)
  allowed_domains: []                           # Egress domain allowlist (empty = no restriction)
  allowed_models: []                            # Model allowlist (empty = show all from provider)
  block_localhost_api: false                    # When true, reject localhost/127.0.0.1 as base_url
  litellm_bedrock_tools_confirmed: false        # Opt-in for Bedrock tool calling via LiteLLM
  model_family_aliases: {}                      # Map Claude family names to model IDs (e.g. {haiku: "claude-3-haiku-20240307"})

app:
  host: "127.0.0.1"      # Bind address
  port: 8080              # Server port
  data_dir: "~/.anteroom"   # Where DB + attachments live
  tls: false              # Set true for HTTPS with self-signed cert

project:
  profile: public          # public | neutral | custom profile name (default: public)
  name: Anteroom
  repo_label: github.com/troylar/anteroom
  repo_url: https://github.com/troylar/anteroom
  docs_url: https://troylar.github.io/anteroom
  support_url: ""
  release_url_template: https://github.com/troylar/anteroom/releases/tag/{version}
  issue_tracking:
    enabled: true
    label_template: "#{number}"
    url_template: https://github.com/troylar/anteroom/issues/{number}

rate_limit:
  max_requests: 120       # Max requests per window per IP (default: 120)
  window_seconds: 60      # Sliding window size in seconds (default: 60)
  exempt_paths:           # Paths exempt from rate limiting
    - /api/events         # SSE endpoint for real-time updates
  sse_retry_ms: 5000      # EventSource retry interval in milliseconds (default: 5000)

feedback:
  include_history_default: false   # Conversation history is opt-in by default
  max_history_messages: 10         # Recent messages included when opted in (clamped 1-50)
  retry_attempts: 2                # Total synchronous reporter attempts (clamped 1-5)
  retry_backoff_seconds: 1.0       # Delay between attempts (clamped 0-30)
  max_bundle_bytes: 1000000        # Drop details if bundle grows too large
  reporters:
    - name: jira
      type: command
      command: "/usr/local/bin/submit-feedback"
      timeout: 10
      enabled: true
    - name: ops-webhook
      type: webhook
      url: "https://hooks.example.com/anteroom-feedback"
      timeout: 10
      enabled: false

diagnostics_bundle:
  max_files: 8                    # Optional sources/files considered per bundle (clamped 1-50)
  max_entries: 200                # JSONL entries included per source group (clamped 1-5000)
  max_source_bytes: 512000        # Bytes read from the tail of each source file (clamped 10000-5000000)
  max_bundle_bytes: 2000000       # Target total bundle cap (clamped 50000-50000000)
  max_time_window_hours: 168      # Default/max time window for scoped export (clamped 1-744)

diagnostics:
  error_log_enabled: true              # Persist redacted failed debug turns (default: true)
  log_path: ""                         # Empty = app.data_dir/diagnostics
  log_successful_debug_turns: false    # Keep failure-only by default
  redact_content: true                 # Omit raw prompts/tokens/tool args/output
  retention_days: 14                   # Short support-artifact retention
  rotate_size_bytes: 1048576           # Rotate active JSONL around 1 MB
  max_entry_bytes: 32768               # Truncate oversized entries
  max_log_dir_bytes: 10485760          # Opportunistically cap total diagnostics logs

storage:
  retention_days: 0                    # Days to retain conversations; 0 = disabled (default: 0)
  retention_check_interval: 3600       # Seconds between retention policy checks (default: 3600 = 1 hour, clamped 300–86400)
  purge_attachments: true              # Delete attachment files when conversations are purged (default: true)
  purge_embeddings: true               # Delete vector embeddings when conversations are purged (default: true)
  encrypt_at_rest: false               # Enable database encryption via SQLCipher (default: false, requires sqlcipher3)
  encryption_kdf: "hkdf-sha256"        # Key derivation function for encryption (default: hkdf-sha256)

session:
  store: "memory"                      # "memory" or "sqlite" (default: memory)
  max_concurrent_sessions: 0           # 0 = unlimited (default: 0)
  idle_timeout: 1800                   # Seconds (default: 1800 = 30 minutes)
  absolute_timeout: 43200              # Seconds (default: 43200 = 12 hours)
  allowed_ips: []                      # CIDR or exact addresses; empty = allow all (default: empty)
  log_session_events: false            # Log session lifecycle events (default: false)

cli:
  theme: "midnight"                # CLI color theme: midnight | dawn | high-contrast | accessible (default: midnight)
  builtin_tools: true              # Enable built-in tools (default: true)
  max_tool_iterations: 50          # Max tool calls per response (default: 50)
  max_consecutive_text_only: 3     # Stop after N text-only responses with no tool calls; 0 to disable (default: 3)
  max_line_repeats: 5              # Stop if a single response repeats the same line N+ times; 0 to disable (default: 5)
  # Context thresholds derive from model_context_window minus reserved output.
  # Set context_warn_tokens/context_auto_compact_tokens to pin explicit legacy thresholds.
  context_reserved_output_tokens: 4096       # Output headroom reserved before deriving thresholds (default: ai.max_output_tokens)
  context_warn_buffer_tokens: 43904          # Free-token buffer for derived warnings (128k default resolves to 80000)
  context_auto_compact_buffer_tokens: 23904  # Free-token buffer for derived auto-compaction (128k default resolves to 100000)
  context_warn_tokens: 80000       # Optional explicit full-request warning threshold; overrides derived default
  context_auto_compact_tokens: 100000  # Optional explicit full-request auto-compact threshold; overrides derived default
  retry_delay: 5.0                 # Seconds between CLI auto-retry countdown ticks (default: 5.0, clamped 1–60)
  max_retries: 3                   # Max CLI auto-retry attempts for retryable errors (default: 3, clamped 0–10)
  esc_hint_delay: 8.0              # Seconds before showing "esc to cancel" hint (default: 8.0, clamped 0+)
  stall_display_threshold: 5.0     # Seconds of chunk silence before showing "stalled" indicator (default: 5.0, clamped 1+)
  stall_warning_threshold: 15.0    # Seconds before showing full stall warning (default: 15.0, clamped 1+)
  stall_throughput_threshold: 30.0 # Chars/sec below which "slow" indicator shows during streaming (default: 30.0)
  tool_output_max_chars: 10000     # Max chars per tool result for live agent calls (default: 10000, clamped 100+)
  tool_replay_max_chars: 10000     # Max chars per tool result on conversation resume/replay (default: 10000, clamped 100+)
  show_attribution_footer: true    # Show the "[ctx] N turns · ..." footer after each assistant turn (default: true, #923)
  file_reference_max_chars: 100000 # Max chars from @file references (default: 100000, clamped 1000+)
  model_context_window: 128000     # Model context window size for usage bar (default: 128000, clamped 1000+)
  background_suggest_seconds: 30   # Advisory threshold for background shell tasks (0 = disabled, default: 30)
  detach_suggest_seconds: 120      # Advisory threshold for detached subagents (0 = disabled, default: 120)
  update_check: true               # Check for newer versions on startup (default: true)
  update_check_command: ""         # Custom command returning latest version (replaces pip index)
  update_check_message: "Update available: {current} -> {latest} -- pip install --upgrade anteroom"
  usage:
    week_days: 7                   # Days for "this week" rolling window (default: 7)
    month_days: 30                 # Days for "this month" rolling window (default: 30)
    model_costs: {}                # Per-model costs: {model: {input: rate, output: rate}} (default: empty)
    budgets:
      enabled: false               # Enable token budget enforcement (default: false)
      max_tokens_per_request: 0    # Single request limit; 0 = unlimited (default: 0)
      max_tokens_per_conversation: 0  # Conversation limit; 0 = unlimited (default: 0)
      max_tokens_per_day: 0        # Daily limit; 0 = unlimited (default: 0)
      warn_threshold_percent: 80   # Warn at this % of limit (default: 80)
      action_on_exceed: block      # "block" to deny or "warn" to allow (default: "block")
  hierarchy:                       # Visual hierarchy between message types (#1370)
    show_timestamps: false         # Right-align a subtle timestamp on user/assistant lanes (default: false)
    turn_separator_char: "─"       # Box-drawing character used by render_turn_separator (default: "─")
    code_block_language_label: true  # Emit a small language label above fenced code blocks (default: true)
  streaming:                       # Live markdown streaming (#1365)
    enabled: false                 # Render markdown incrementally via rich.live.Live (default: false, opt-in)
    refresh_hz: 20.0               # Live-region refresh rate, Hz (default: 20.0, clamped [1.0, 60.0])
    live_in_exec_mode: false       # Activate Live in `aroom exec` too (default: false)
    code_fence_container: true     # Show fenced code container as soon as opening fence arrives (default: true)
  density:                         # Smart output density for tool results (#1367)
    mode: "normal"                 # "minimal" | "compact" | "normal" | "detailed" (default: "normal" = byte-identical to pre-#1367)
    collapse_repeats: true         # Collapse ≥3 identical-shape tool results into one line (compact/minimal only)
    diff_context_lines: 3          # Lines of unchanged context kept per diff hunk in compact/minimal modes
    head_lines: 3                  # Leading lines kept when trimming long tool output
    tail_lines: 2                  # Trailing lines kept when trimming long tool output

identity:
  user_id: "auto-generated-uuid"
  display_name: "Your Name"
  public_key: "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
  private_key: "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"

shared_databases:
  - name: "team-shared"
    path: "~/shared/team.db"

mcp_servers:
  - name: "my-tools"
    transport: "stdio"
    command: "npx"
    args: ["-y", "@my-org/mcp-tools"]
    env:
      API_KEY: "${MY_API_KEY}"
    tools_include: []                  # fnmatch allowlist (empty = include all)
    tools_exclude: []                  # fnmatch blocklist

  - name: "remote-tools"
    transport: "sse"
    url: "https://mcp-server.example.com/sse"

mcp_tool_warning_threshold: 40        # Warn when total MCP tools exceed this (0 = disabled)

safety:
  enabled: true
  approval_mode: "ask_for_writes"    # auto | ask_for_dangerous | ask_for_writes | ask
  approval_timeout: 120
  read_only: false                   # If true, only READ-tier tools are available
  bash:
    enabled: true
  write_file:
    enabled: true
  allowed_tools: []                  # Tools that skip approval (always auto-approved)
  denied_tools: []                   # Tools that are hard-blocked (never execute)
  tool_tiers: {}                     # Per-tool tier overrides, e.g. {my_mcp_tool: "read"}
  custom_patterns: []
  sensitive_paths: []
  subagent:
    max_concurrent: 5
    max_total: 10
    max_depth: 3
    max_iterations: 15
    timeout: 120
    max_output_chars: 4000
    max_prompt_chars: 32000
  tool_rate_limit:
    max_calls_per_minute: 0               # Max tool calls per minute (0 = unlimited)
    max_calls_per_conversation: 0         # Max tool calls per conversation (0 = unlimited)
    max_consecutive_failures: 5           # Max consecutive failed tool calls
    action: "block"                       # "block" to deny, "warn" to allow + log
  prompt_injection:
    enabled: false                        # Enable prompt injection detection (default: false)
    action: "warn"                        # "block" | "warn" | "log" (default: warn)
    canary_length: 16                     # Bytes of randomness for canary tokens (default: 16)
    detect_encoding_attacks: true         # Detect base64, hex, unicode escaping (default: true)
    detect_instruction_override: true     # Detect instruction override patterns (default: true)
    heuristic_threshold: 0.7              # Minimum confidence to trigger action (default: 0.7)
    log_detections: true                  # Log detected attacks to security log (default: true)

embeddings:
  enabled: null                        # null = auto-detect, true = force on, false = disable
  provider: "local"                    # "local" (fastembed) or "api" (OpenAI-compatible)
  model: "text-embedding-3-small"
  dimensions: 0                        # 0 = auto-detect from provider/model
  local_model: "BAAI/bge-small-en-v1.5"
  base_url: ""
  api_key: ""
  api_key_command: ""
  cache_dir: ""                        # Custom model cache directory; enables local_files_only when set

rag:
  enabled: true                        # Master toggle for RAG (default: true)
  max_chunks: 10                       # Maximum chunks to retrieve per query (default: 10)
  max_tokens: 2000                     # Token budget for RAG context (default: 2000)
  similarity_threshold: 0.5            # Maximum cosine distance; lower = stricter (default: 0.5)
  include_sources: true                # Search knowledge source chunks (default: true)
  include_conversations: true          # Search past conversation messages (default: true)
  exclude_current: true                # Exclude current conversation from results (default: true)
  retrieval_mode: "dense"              # "dense", "keyword", or "hybrid" (default: "dense")
  show_status: true                    # Show RAG status messages in CLI (default: true)

reranker:
  enabled: null                        # null = auto-detect (use if fastembed available)
  provider: "local"                    # "local" (fastembed TextCrossEncoder)
  model: "cross-encoder/ms-marco-MiniLM-L-6-v2"
  top_k: 5                            # Keep top-K chunks after reranking
  score_threshold: 0.0                 # Minimum relevance score (0 = no threshold)
  candidate_multiplier: 3             # Fetch top_k * multiplier candidates before reranking
  cache_dir: ""                        # Custom model cache directory; enables local_files_only when set

memory:
  promotion:
    default_review_state: "candidate"    # "candidate" or "pending_review" (default: "candidate")
    local_auto_approve: false             # Built-in default: review-first. Solo local setup may write true.
    agent_proposals_enabled: true         # When false, proposer="agent" is rejected with 403 (default: true)
    max_lineage_entries: 50               # FIFO cap on decision-history entries per memory (default: 50)
    max_candidates_per_conversation: 10   # Per-conversation proposal cap; overflow → 429 (default: 10)
    max_reject_reason_chars: 500          # Server-side bound on rejection reason; router also caps at 2000 (default: 500)
  retention:
    enabled: false                        # Master switch; default off (no memory eviction regardless of other fields)
    max_age_days: null                    # Purge memories older than N days (by created_at). null disables the rule
    idle_days: null                       # Purge memories not recalled in N days (by last_recalled_at → created_at fallback). null disables
    min_age_days: 1                        # Grace floor for idle_days; protects freshly-approved memories (default: 1)
    purge_statuses: ["rejected"]          # Statuses eligible for status-based eviction (default: ["rejected"])
    respect_pins: true                    # Skip memories with metadata.pinned == true (default: true)
  auto_propose:
    enabled: false                        # Selective post-turn extraction (#1454); off by default
    max_candidates_per_turn: 1            # Hard cap per assistant turn (default: 1)
    categories:                           # Subset of MEMORY_CATEGORIES the extractor may propose
      - preference
      - project_fact
      - decision
      - workflow_hint
    min_confidence: 0.8                   # 0.0-1.0; LLM-reported confidence floor (default: 0.8)
    notify_inline: true                   # Show inline notice in CLI/web after extraction (default: true)
    cooldown_turns: 5                     # Suppress repeats of the same content for N turns (default: 5)

dlp:
  enabled: false                       # Set true to enable DLP scanning
  scan_output: true                    # Scan AI responses (default: true)
  scan_input: false                    # Scan user input (default: false)
  action: "redact"                     # "redact" | "block" | "warn" (default: redact)
  redaction_string: "[REDACTED]"
  log_detections: true                 # Log matches to security log (default: true)
  # patterns: []                        # Built-in patterns loaded by default
  custom_patterns: []                  # Add custom regex rules

output_filter:
  enabled: false                       # Set true to enable output filtering
  system_prompt_leak_detection: true   # Detect system prompt leaks via n-gram analysis (default: true)
  leak_threshold: 0.4                  # Minimum similarity threshold for leak detection (0.0-1.0, default: 0.4)
  action: "warn"                       # "warn" | "block" | "redact" (default: warn)
  redaction_string: "[FILTERED]"       # String to replace filtered content when action is "redact"
  log_detections: true                 # Log filter detections to security log (default: true)
  custom_patterns: []                  # Add custom forbidden pattern rules

workflow:
  budget:
    max_duration_seconds: 0   # Wall-clock ceiling per run (0 = unlimited)
    max_steps: 0              # Max completed steps per run (0 = unlimited)
    max_tokens: 0             # Max total tokens per run (0 = unlimited)

# Project/team config only — shared references
references:
  instructions:
    - "team/instructions.md"
    - "team/coding-standards.md"
  rules:
    - "team/rules/no-eval.md"
  skills:
    - "team/skills/deploy"            # directory containing SKILL.md
    - "team/skills/review/SKILL.md"   # or explicit path to SKILL.md

# Project/team config only — required keys
required:
  - path: "ai.api_key"
    description: "Your API key"
  - path: "custom.db_password"
    description: "Database password"
```

## Sections

### ai

| Field | Type | Default | Description |
|---|---|---|---|
| `base_url` | string | --- | OpenAI-compatible API endpoint (required) |
| `api_key` | string | --- | API key (required unless using `api_key_command`) |
| `api_key_command` | string | --- | External command to obtain API key dynamically |
| `model` | string | `gpt-4` | Default model name |
| `system_prompt` | string | `You are a helpful assistant.` | System prompt for all conversations |
| `verify_ssl` | boolean | `true` | Verify SSL certificates when connecting to the API |
| `request_timeout` | integer | `120` | Overall stream timeout in seconds (clamped 10–600); env: `AI_CHAT_REQUEST_TIMEOUT` |
| `connect_timeout` | integer | `5` | TCP connect timeout in seconds (clamped 1–30); env: `AI_CHAT_CONNECT_TIMEOUT` |
| `write_timeout` | integer | `30` | Time to send request body in seconds (clamped 5–120); env: `AI_CHAT_WRITE_TIMEOUT` |
| `pool_timeout` | integer | `10` | Wait for free connection from pool in seconds (clamped 1–60); env: `AI_CHAT_POOL_TIMEOUT` |
| `first_token_timeout` | integer | `30` | Max seconds to wait for first token after connect (clamped 5–120); env: `AI_CHAT_FIRST_TOKEN_TIMEOUT` |
| `chunk_stall_timeout` | integer | `30` | Max silence between chunks mid-stream in seconds (clamped 10–600); env: `AI_CHAT_CHUNK_STALL_TIMEOUT` |
| `retry_max_attempts` | integer | `3` | Retries on transient errors (timeout, connection); 0 disables (clamped 0–10); env: `AI_CHAT_RETRY_MAX_ATTEMPTS` |
| `retry_backoff_base` | float | `1.0` | Exponential backoff base delay in seconds (clamped 0.1–30.0); env: `AI_CHAT_RETRY_BACKOFF_BASE` |
| `temperature` | float or null | `null` | Model temperature for response randomness (0.0–2.0; null = provider default); env: `AI_CHAT_TEMPERATURE` |
| `top_p` | float or null | `null` | Nucleus sampling parameter (0.0–1.0; null = provider default); env: `AI_CHAT_TOP_P` |
| `seed` | integer or null | `null` | Random seed for deterministic output (null = provider default); env: `AI_CHAT_SEED` |
| `provider` | string | `openai` | AI backend to use: `openai` (default, works with any OpenAI-compatible API), `anthropic` (native Anthropic Messages API, requires `pip install anteroom[anthropic]`), or `litellm` (100+ providers via LiteLLM — OpenRouter, Replicate, Together, Cohere, etc., requires `pip install anteroom[providers]`); env: `AI_CHAT_PROVIDER` |
| `max_output_tokens` | integer | `4096` | Maximum tokens to generate per response. Required by some providers (e.g. Anthropic). Ignored when the provider does not support the parameter; env: `AI_CHAT_MAX_OUTPUT_TOKENS` |
| `allowed_domains` | list[string] | `[]` | Egress domain allowlist for API requests (empty list = no restriction). Domains are matched case-insensitively as exact matches. Fails closed: unparseable URLs are rejected; env: `AI_CHAT_ALLOWED_DOMAINS` (comma-separated) |
| `allowed_models` | list[string] | `[]` | Model allowlist for the model selector and `/model` command (empty list = show all models from provider). When set, only listed models appear in the CLI picker and web UI dropdown. Enforced on model switch, conversation PATCH, and at chat runtime (defense in depth); env: `AI_CHAT_ALLOWED_MODELS` (comma-separated) |
| `block_localhost_api` | boolean | `false` | When true, reject localhost/127.0.0.1/[::1] as the API base_url. Useful in enterprise environments to prevent accidental connections to local services; env: `AI_CHAT_BLOCK_LOCALHOST_API` |
| `litellm_bedrock_tools_confirmed` | boolean | `false` | Opt-in for Bedrock tool calling via LiteLLM. When false (default), Anteroom blocks tool-calling requests routed through Bedrock because LiteLLM's internal toolConfig translation is known to fail on some Bedrock model versions. Set to true after verifying the route works end-to-end; env: `AI_CHAT_LITELLM_BEDROCK_TOOLS_CONFIRMED` |
| `model_family_aliases` | dict[string, string] | `{}` | Map model-family names to full model IDs for `run_agent` calls. Example: `{haiku: "claude-3-haiku-20240307", sonnet: "claude-sonnet-4-20250514"}`. Known Claude family names (`haiku`, `sonnet`, `opus`) that are not in the alias table are rejected locally with an actionable error. All other model values pass through unchanged; env: `AI_CHAT_MODEL_FAMILY_ALIASES` (JSON string, e.g. `'{"haiku":"claude-3-haiku-20240307"}'`) |

### app

| Field | Type | Default | Description |
|---|---|---|---|
| `host` | string | `127.0.0.1` | Bind address for the web server |
| `port` | integer | `8080` | Port for the web server; env: `AI_CHAT_PORT` |
| `data_dir` | string | `~/.anteroom` | Directory for database, attachments, config |
| `tls` | boolean | `false` | Enable HTTPS with self-signed certificate |

### project

Project-link metadata used by the CLI banner, documentation rendering, unpacked docs, issue links, and release links. The built-in `public` profile loads Anteroom's public GitHub URLs from `project-links.public.yaml`; code defaults stay provider-neutral when `profile: neutral` is used or a custom profile omits a URL.

| Field | Type | Default | Description |
|---|---|---|---|
| `profile` | string | `public` | Named profile loaded before local overrides. Use `neutral` to disable built-in public URLs, or provide `project-links.<profile>.yaml` / `ANTEROOM_PROJECT_PROFILE_PATH` for an internal profile |
| `name` | string | `Anteroom` | Human project name |
| `repo_label` | string | profile value | Repository label shown in the CLI banner and docs |
| `repo_url` | string | profile value | Repository URL. Can point at GitHub, GitLab, Bitbucket, or an internal forge |
| `docs_url` | string | profile value | Published documentation URL |
| `support_url` | string | `""` | Support/contact URL when a deployment has one |
| `release_url_template` | string | profile value | Release URL template using `{version}` |
| `issue_tracking.enabled` | boolean | profile value | When false, docs render issue references as plain labels instead of dead issue links |
| `issue_tracking.label_template` | string | `#{number}` | Issue label template using `{number}` |
| `issue_tracking.url_template` | string | profile value | Issue URL template using `{number}` |

### server

HTTP server settings (request body limits).

| Field | Type | Default | Description |
|---|---|---|---|
| `max_upload_mb` | integer | `50` | Maximum request body size in MB. Applies to file uploads, document ingestion, and all POST/PUT requests. Clamped to `[1, 1000]` MB; env: `AI_CHAT_SERVER_MAX_UPLOAD_MB` |

Example:

```yaml
server:
  max_upload_mb: 100      # allow up to 100 MB uploads
```

### rate_limit

HTTP request rate limiting to prevent abuse and SSE storms. Protects against EventSource reconnection floods.

| Field | Type | Default | Description |
|---|---|---|---|
| `max_requests` | integer | `120` | Maximum requests per IP address per time window (sliding window); env: `AI_CHAT_RATE_LIMIT_MAX_REQUESTS` |
| `window_seconds` | integer | `60` | Sliding window size in seconds; env: `AI_CHAT_RATE_LIMIT_WINDOW_SECONDS` |
| `exempt_paths` | list[string] | `["/api/events"]` | URL paths exempt from rate limiting (e.g., SSE endpoints); env: `AI_CHAT_RATE_LIMIT_EXEMPT_PATHS` (comma-separated) |
| `sse_retry_ms` | integer | `5000` | EventSource `retry:` field sent to browser clients (milliseconds). Controls reconnection backoff on 429 responses; env: `AI_CHAT_RATE_LIMIT_SSE_RETRY_MS` |

Example:

```yaml
rate_limit:
  max_requests: 120
  window_seconds: 60
  exempt_paths:
    - /api/events
    - /health
  sse_retry_ms: 5000
```

When a client exceeds the rate limit, the server returns HTTP 429 (Too Many Requests) with the `Retry-After` header. For SSE connections, the server sends a `retry:` field directing the EventSource to wait before reconnecting. Paths in `exempt_paths` are not rate-limited, allowing critical endpoints like `/api/events` to handle high-frequency client reconnections.

### storage

Controls data retention policies and encryption at rest for the SQLite database.

| Field | Type | Default | Description |
|---|---|---|---|
| `retention_days` | integer | `0` | Number of days to retain conversations before automatic purge; `0` = disabled (retention disabled); env: `AI_CHAT_RETENTION_DAYS` |
| `retention_check_interval` | integer | `3600` | Seconds between retention policy checks (clamped 300–86400); env: `AI_CHAT_RETENTION_CHECK_INTERVAL` |
| `purge_attachments` | boolean | `true` | Delete attachment files from disk when conversations are purged (default: true); env: `AI_CHAT_PURGE_ATTACHMENTS` |
| `purge_embeddings` | boolean | `true` | Delete vector embeddings from the database when conversations are purged (default: true); env: `AI_CHAT_PURGE_EMBEDDINGS` |
| `encrypt_at_rest` | boolean | `false` | Enable database encryption via SQLCipher. Requires sqlcipher3: `pip install anteroom[sqlcipher]` (default: false); env: `AI_CHAT_ENCRYPT_AT_REST` |
| `encryption_kdf` | string | `hkdf-sha256` | Key derivation function for encryption (default: `hkdf-sha256`); env: `AI_CHAT_ENCRYPTION_KDF` |

#### Data Retention

When `retention_days` is set to a positive value, the retention worker automatically purges conversations (and all associated messages, tool_calls, and embeddings) older than the configured period. The retention check runs at the interval specified by `retention_check_interval`.

To manually purge conversations, use:

```bash
aroom db purge --older-than 30d      # purge conversations older than 30 days
aroom db purge --before 2025-01-01   # purge conversations before a specific date
aroom db purge --dry-run             # show what would be deleted
```

#### Encryption at Rest

When `encrypt_at_rest` is enabled:

- The database is encrypted using SQLCipher with a 256-bit key
- The key is derived from your Ed25519 identity key via HKDF-SHA256
- All database queries go through the encrypted connection transparently
- Attachments are NOT encrypted (only the database is)
- Requires `sqlcipher3` package: `pip install anteroom[sqlcipher]`

To initialize encryption on an existing database:

```bash
aroom db encrypt            # interactive setup
aroom db encrypt --key-from identity  # use identity key as encryption key
```

!!! note "One-way operation"
    Encryption is not reversible. Once enabled on a database, it cannot be decrypted without the original identity key. Back up your config before enabling.

### feedback

Configures `/feedback`, the web UI feedback modal, and `POST /api/feedback`.

| Field | Type | Default | Description |
|---|---|---|---|
| `reporters` | list | `[]` | Named reporter destinations. Empty list enables local JSON fallback |
| `include_history_default` | boolean | `false` | Default conversation-history inclusion. Keep false for privacy; CLI/web still require explicit user opt-in; env: `AI_CHAT_FEEDBACK_INCLUDE_HISTORY` |
| `max_history_messages` | integer | `10` | Most recent messages included when history is opted in; clamped to `[1, 50]`; env: `AI_CHAT_FEEDBACK_MAX_HISTORY_MESSAGES` |
| `retry_attempts` | integer | `2` | Total synchronous attempts before marking a reporter failed; clamped to `[1, 5]`; env: `AI_CHAT_FEEDBACK_RETRY_ATTEMPTS` |
| `retry_backoff_seconds` | float | `1.0` | Delay between attempts; clamped to `[0.0, 30.0]`; env: `AI_CHAT_FEEDBACK_RETRY_BACKOFF_SECONDS` |
| `max_bundle_bytes` | integer | `1000000` | Serialized bundle cap before details/history are dropped; clamped to `[10000, 5000000]`; env: `AI_CHAT_FEEDBACK_MAX_BUNDLE_BYTES` |

Reporter fields:

| Field | Type | Notes |
|---|---|---|
| `name` | string | Stored with the `feedback_reports` row and returned on success |
| `type` | string | `command` or `webhook` |
| `command` | string | Required for `command`; bundle JSON is delivered on stdin |
| `url` | string | Required for `webhook`; checked against the AI egress allowlist before POST |
| `timeout` | integer | Seconds, clamped to `[1, 30]` |
| `enabled` | boolean | Disabled reporters are ignored |

With no enabled reporters, Anteroom writes the redacted bundle to `app.data_dir` as `feedback-<timestamp>-<report-id>.json` and records the report as sent with `reporter_name='local'`.

The bundle includes package, Python/OS, runtime model/provider context, redacted config, safety, active space, pack/artifact/attachment manifests, tool metadata, a bundle manifest, and latest-turn diagnostics when available. Conversation history is excluded unless the user explicitly opts in with `/feedback ... --include-history` or the web modal checkbox. Raw prompts, tool inputs/outputs, artifact content, config overlay values, attachment filenames/paths/bytes/text, and pack source paths are omitted.

### diagnostics_bundle

Configures `aroom diagnostics bundle`, the local-only redacted support export.

| Field | Type | Default | Description |
|---|---|---|---|
| `max_files` | integer | `8` | Optional source files considered per bundle; clamped to `[1, 50]`; env: `AI_CHAT_DIAGNOSTICS_BUNDLE_MAX_FILES` |
| `max_entries` | integer | `200` | JSONL entries included per source group; clamped to `[1, 5000]`; env: `AI_CHAT_DIAGNOSTICS_BUNDLE_MAX_ENTRIES` |
| `max_source_bytes` | integer | `512000` | Bytes read from the tail of each source file; clamped to `[10000, 5000000]`; env: `AI_CHAT_DIAGNOSTICS_BUNDLE_MAX_SOURCE_BYTES` |
| `max_bundle_bytes` | integer | `2000000` | Target total bundle size cap; clamped to `[50000, 50000000]`; env: `AI_CHAT_DIAGNOSTICS_BUNDLE_MAX_BUNDLE_BYTES` |
| `max_time_window_hours` | integer | `168` | Default/max time window for unscoped export; clamped to `[1, 744]`; env: `AI_CHAT_DIAGNOSTICS_BUNDLE_MAX_TIME_WINDOW_HOURS` |

The export is explicit and local-only. It writes under `app.data_dir/diagnostics-bundles/` by default, or to `--output`, and never calls feedback reporters, webhooks, telemetry, or upload paths. The manifest records included files, skipped sources, redaction status, limits, and warnings.

### diagnostics

Configures the redacted diagnostics error log used for support/debugging failed or suspicious debug-enabled turns. This log is separate from the audit log: it has no HMAC chain, no compliance semantics, no fsync-per-entry, and is safe to delete.

| Field | Type | Default | Description |
|---|---|---|---|
| `error_log_enabled` | boolean | `true` | Enable the diagnostics error log; env: `AI_CHAT_DIAGNOSTICS_ERROR_LOG_ENABLED` |
| `log_path` | string | `""` | Directory for diagnostics JSONL files. Empty uses `app.data_dir/diagnostics`; env: `AI_CHAT_DIAGNOSTICS_LOG_PATH` |
| `log_successful_debug_turns` | boolean | `false` | Also persist successful debug summaries. Default logs only terminal error-like outcomes; env: `AI_CHAT_DIAGNOSTICS_LOG_SUCCESSFUL_DEBUG_TURNS` |
| `redact_content` | boolean | `true` | Redact raw prompts, streamed token text, tool arguments, tool output, API keys, bearer tokens, and secrets; env: `AI_CHAT_DIAGNOSTICS_REDACT_CONTENT` |
| `retention_days` | integer | `14` | Delete old diagnostics files opportunistically; `0` keeps until size cleanup; clamped `[0, 365]`; env: `AI_CHAT_DIAGNOSTICS_RETENTION_DAYS` |
| `rotate_size_bytes` | integer | `1048576` | Rotate the active daily JSONL file when it reaches this size; clamped `[65536, 104857600]`; env: `AI_CHAT_DIAGNOSTICS_ROTATE_SIZE_BYTES` |
| `max_entry_bytes` | integer | `32768` | Maximum serialized entry size; oversized summaries are structurally truncated; clamped `[1024, 1048576]`; env: `AI_CHAT_DIAGNOSTICS_MAX_ENTRY_BYTES` |
| `max_log_dir_bytes` | integer | `10485760` | Opportunistic total directory cap; oldest diagnostics files are removed first; clamped `[65536, 1073741824]`; env: `AI_CHAT_DIAGNOSTICS_MAX_LOG_DIR_BYTES` |

By default, Anteroom persists only terminal failure-like debug summaries: provider/API errors, stream/internal errors, timeouts, cancellations, failed compaction recovery, DLP or output-filter blocks, injection blocks, and failed tool turns. Entries are JSONL files named `diagnostics-YYYY-MM-DD.jsonl` and include timestamp, interface, conversation/request ids when available, stop reason, final phase, model/provider, usage, retries, active tools, runtime events, and sanitized errors.

### session

| Field | Type | Default | Description |
|---|---|---|---|
| `store` | string | `memory` | Session storage backend: `memory` (volatile, in-process) or `sqlite` (persistent); env: `AI_CHAT_SESSION_STORE` |
| `max_concurrent_sessions` | integer | `0` | Max concurrent sessions per user; `0` = unlimited. Enforced at middleware. When exceeded, returns 429 Too Many Sessions; env: `AI_CHAT_SESSION_MAX_CONCURRENT` |
| `idle_timeout` | integer | `1800` | Session idle timeout in seconds (default: 1800 = 30 minutes). Automatically cleaned up on next request; env: `AI_CHAT_SESSION_IDLE_TIMEOUT` |
| `absolute_timeout` | integer | `43200` | Absolute session lifetime in seconds (default: 43200 = 12 hours). After this period, session is invalidated regardless of activity; env: `AI_CHAT_SESSION_ABSOLUTE_TIMEOUT` |
| `allowed_ips` | list[string] | `[]` | IP allowlist for access control. Supports exact addresses (`192.168.1.5`) and CIDR ranges (`10.0.0.0/8`). Both IPv4 and IPv6. Empty list allows all IPs; env: `AI_CHAT_SESSION_ALLOWED_IPS` (comma-separated) |
| `log_session_events` | boolean | `false` | Log session lifecycle events (create, touch, delete) to audit log; env: `AI_CHAT_SESSION_LOG_EVENTS` |

### cli

| Field | Type | Default | Description |
|---|---|---|---|
| `builtin_tools` | boolean | `true` | Enable the 6 built-in tools |
| `max_tool_iterations` | integer | `50` | Max agentic loop iterations per turn |
| `max_consecutive_text_only` | integer | `3` | Stop after N consecutive text-only responses with no tool calls; 0 to disable |
| `max_line_repeats` | integer | `5` | Stop if a single response repeats the same line N+ times; 0 to disable |
| `context_reserved_output_tokens` | integer | `ai.max_output_tokens` | Output headroom reserved before deriving context thresholds. Clamped to leave at least 1000 effective context tokens; env: `AI_CHAT_CONTEXT_RESERVED_OUTPUT_TOKENS` |
| `context_warn_buffer_tokens` | integer | `43904` | Free-token buffer for the derived warning threshold. With the default 128k window and 4096 reserved output tokens, this resolves to `80000`; env: `AI_CHAT_CONTEXT_WARN_BUFFER_TOKENS` |
| `context_auto_compact_buffer_tokens` | integer | `23904` | Free-token buffer for the derived auto-compact threshold. With the default 128k window and 4096 reserved output tokens, this resolves to `100000`; env: `AI_CHAT_CONTEXT_AUTO_COMPACT_BUFFER_TOKENS` |
| `context_warn_tokens` | integer | derived (`80000` at 128k) | Optional explicit full-request token estimate at which a context warning is shown in the CLI and web metadata. Includes messages, system prompts, and tool schemas. When set, it overrides the model-aware derived threshold; env: `AI_CHAT_CONTEXT_WARN_TOKENS` |
| `context_auto_compact_tokens` | integer | derived (`100000` at 128k) | Optional explicit full-request token estimate at which the CLI preflight automatically compacts before storing the next prompt. Includes messages, system prompts, and tool schemas. When set, it overrides the model-aware derived threshold; env: `AI_CHAT_CONTEXT_AUTO_COMPACT_TOKENS` |
| `retry_delay` | float | `5.0` | Seconds between CLI auto-retry countdown ticks (clamped 1–60) |
| `max_retries` | integer | `3` | Max CLI auto-retry attempts for retryable errors (clamped 0–10) |
| `esc_hint_delay` | float | `8.0` | Seconds before showing "esc to cancel" hint |
| `stall_display_threshold` | float | `5.0` | Seconds of chunk silence before showing "stalled" indicator (clamped 1+) |
| `stall_warning_threshold` | float | `15.0` | Seconds before showing full stall warning (clamped 1+) |
| `stall_throughput_threshold` | float | `30.0` | Chars/sec below which "slow" indicator shows during streaming |
| `tool_output_max_chars` | integer | `10000` | Max chars per tool result for live agent calls (applied as the tool result is appended to the conversation); clamped 100+; env: `AI_CHAT_TOOL_OUTPUT_MAX_CHARS` |
| `tool_replay_max_chars` | integer | `10000` | Max chars per tool result when replaying historical tool results on conversation resume; clamped 100+; env: `AI_CHAT_TOOL_REPLAY_MAX_CHARS` |
| `show_attribution_footer` | boolean | `true` | Show the `[ctx] N turns · N memories · …` footer after each assistant turn. The full snapshot is always persisted to `messages.metadata["attribution"]` and stays reachable via `/attribution`; this setting only controls the footer print. Env override: `AI_CHAT_SHOW_ATTRIBUTION_FOOTER` (`false`/`0`/`no` disables). See [Context Attribution](../knowledge/attribution.md) ([#923](https://github.com/troylar/anteroom/issues/923)) |
| `file_reference_max_chars` | integer | `100000` | Max chars from @file references (clamped 1000+) |
| `model_context_window` | integer | `128000` | Model context window size for usage bar and derived thresholds (clamped 1000+); env: `AI_CHAT_MODEL_CONTEXT_WINDOW` |
| `background_suggest_seconds` | integer | `30` | Advisory threshold: suggest `background: true` for shell commands above this duration (0 = disabled) |
| `detach_suggest_seconds` | integer | `120` | Advisory threshold: suggest `detach: true` for AI work above this duration (0 = disabled) |
| `update_check` | boolean | `true` | Check PyPI for newer versions on CLI startup. Set to `false` to disable entirely (no subprocess spawned); env: `AI_CHAT_UPDATE_CHECK` |
| `update_check_command` | string | `""` | Custom shell command that prints the latest version to stdout (e.g., `curl -s https://internal.corp/anteroom/latest`). Replaces the default `pip index versions` check. Runs with a 5s timeout; env: `AI_CHAT_UPDATE_CHECK_COMMAND` |
| `update_check_message` | string | `"Update available: {current} -> {latest} -- pip install --upgrade anteroom"` | Startup notification template shown only when a newer version is available. Supports `{current}`, `{latest}`, and `{upgrade_command}`. Set to `""` to suppress the startup notification while keeping manual checks available. Malformed templates fall back to the default; env: `AI_CHAT_UPDATE_CHECK_MESSAGE` |

Run `aroom version check` to check manually. Manual checks use `update_check_command` when configured, but still run when `update_check: false` because the command is explicit. If the checker fails, times out, or returns invalid output, the command reports that the check was unavailable and exits non-zero rather than saying the installed version is current.

Context warning, CLI auto-compaction, and proactive summary token thresholds are model-aware when their explicit legacy fields are absent or invalid. Anteroom first computes `effective_context_window = model_context_window - context_reserved_output_tokens`, where `context_reserved_output_tokens` defaults to `ai.max_output_tokens`. It then derives thresholds by keeping the configured free-token buffers available, with percentage floors for small-window models.

Examples with default buffers and `ai.max_output_tokens: 4096`:

| `model_context_window` | Effective window | Warn | Summary trigger | Auto-compact |
|---|---:|---:|---:|---:|
| `32000` | `27904` | `17440` | `19532` | `21765` |
| `128000` | `123904` | `80000` | `90000` | `100000` |
| `200000` | `195904` | `152000` | `162000` | `172000` |

Explicit `cli.context_warn_tokens`, `cli.context_auto_compact_tokens`, and `compaction.summary_trigger_token_count` take precedence over derived values. Use those fields when you need to pin historical behavior for a deployment.

#### cli.hierarchy

Nested under `cli` — visual-hierarchy knobs that control how the CLI frames different message types (user input, assistant prose, tool results, system messages, code blocks). See [CLI Themes](../cli/themes.md) for the matching semantic color slots.

| Field | Type | Default | Description |
|---|---|---|---|
| `show_timestamps` | boolean | `false` | Right-align a subtle timestamp on each user/assistant turn. Off by default to preserve current layout; env: `AI_CHAT_CLI_HIERARCHY_SHOW_TIMESTAMPS` |
| `turn_separator_char` | string | `"─"` | Box-drawing character used by `render_turn_separator()` between turns. Must be a single visible character; env: `AI_CHAT_CLI_HIERARCHY_TURN_SEPARATOR_CHAR` |
| `code_block_language_label` | boolean | `true` | Emit a small language label above fenced code blocks (e.g. `python` above a syntax-highlighted snippet). Set to `false` for a quieter layout; env: `AI_CHAT_CLI_HIERARCHY_CODE_BLOCK_LANGUAGE_LABEL` |

Example:

```yaml
cli:
  theme: "midnight"
  hierarchy:
    show_timestamps: true
    turn_separator_char: "="
    code_block_language_label: false
```

#### cli.streaming

Nested under `cli` — live markdown streaming knobs (#1365). Drives the `rich.live.Live`-backed incremental renderer that shows bold/italic/inline-code/headers/fenced code as tokens arrive, instead of buffering silently and rendering once at the end of the turn.

The streaming path is automatically disabled in non-TTY output (`|` pipes, redirected files), when the `NO_COLOR` environment variable is set, and in `aroom exec` mode unless `live_in_exec_mode` is `true` — regardless of the `enabled` setting. In those contexts the renderer falls back to the legacy buffer-then-render path, so observable output in scripts and CI stays unchanged.

| Field | Type | Default | Description |
|---|---|---|---|
| `enabled` | boolean | `false` | Master toggle for live markdown streaming. Set to `true` to enable incremental markdown rendering as tokens arrive; env: `AI_CHAT_CLI_STREAMING_ENABLED` |
| `refresh_hz` | float | `20.0` | Live-region refresh rate in Hz (one redraw every `1 / refresh_hz` seconds). Clamped to `[1.0, 60.0]` at load time with a logger warning on out-of-range values. `20.0` = 50 ms per tick, the Claude Code reference cadence; env: `AI_CHAT_CLI_STREAMING_REFRESH_HZ` |
| `live_in_exec_mode` | boolean | `false` | When `true`, activate Live rendering in `aroom exec` (non-interactive) mode. Default `false` because exec mode is designed for scripting/capture and a static render at the end is the correct contract; env: `AI_CHAT_CLI_STREAMING_LIVE_IN_EXEC_MODE` |
| `code_fence_container` | boolean | `true` | When `true`, show a visible fenced-code container as soon as the opening ``` arrives, filling content incrementally. When `false`, unclosed fences render as plain accumulating text until the closing fence arrives; env: `AI_CHAT_CLI_STREAMING_CODE_FENCE_CONTAINER` |

Example:

```yaml
cli:
  streaming:
    enabled: true
    refresh_hz: 30.0              # faster redraws on capable terminals
    live_in_exec_mode: false
    code_fence_container: true
```

#### cli.live_tools

Nested under `cli` — compact live tool-call lifecycle knobs (#1364). Controls the two-phase tool-call surface in the REPL: a running phase carried by the unified thinking/footer ticker, and a completion phase rendered as a single concise line via `render_tool_call_completion()` (gutter glyph, success/failure icon, past-tense verb, target, optional metric suffix, elapsed).

| Field | Type | Default | Description |
|---|---|---|---|
| `show_args_in_verbose` | boolean | `true` | In `VERBOSE` verbosity mode, emit a muted `args: {...}` footline under the completion line so the exact tool arguments are still visible. Set to `false` to suppress; env: `AI_CHAT_CLI_LIVE_TOOLS_SHOW_ARGS_IN_VERBOSE` |
| `show_metric_suffix` | boolean | `true` | Append a compact metric (`42 lines`, `exit 0`, `1.2 KB`, `17 matches`, …) between the target and elapsed parts of the completion line. Set to `false` for a quieter line with just verb + target + elapsed; env: `AI_CHAT_CLI_LIVE_TOOLS_SHOW_METRIC_SUFFIX` |
| `metric_max_chars` | integer | `40` | Upper bound on the metric suffix before it is truncated with an ellipsis. Clamped to `[1, 200]` at load time with a logger warning on out-of-range values; env: `AI_CHAT_CLI_LIVE_TOOLS_METRIC_MAX_CHARS` |

Example:

```yaml
cli:
  live_tools:
    show_args_in_verbose: true
    show_metric_suffix: true
    metric_max_chars: 40
```

#### cli.density

Nested under `cli` — tool-result density knobs (#1367). Controls how tool-call *results* are rendered at the end of each call: whether the body is shown, how long-running outputs are trimmed, whether diff hunks collapse unchanged context, and whether identical-shape tool results are deduplicated further.

The default `mode: "normal"` is byte-identical to the pre-#1367 tool-end rendering — zero-config users see no change. Smart summaries, compact diffs, and repeat collapsing are **opt-in** by setting `mode: "compact"` (or `minimal` for even terser output). The `/expand` and `/density` slash commands ship always-on regardless of mode.

| Field | Type | Default | Description |
|---|---|---|---|
| `mode` | string | `"normal"` | One of `minimal`, `compact`, `normal`, `detailed`. `normal` = byte-identical legacy rendering. `compact` = head/tail body slice + `[+N lines]` hint + compact diffs. `minimal` = one-line tool summary only, body hidden. `detailed` = inline body expanded (capped to avoid terminal floods); env: `AI_CHAT_CLI_DENSITY_MODE` |
| `collapse_repeats` | boolean | `true` | When `mode` is `compact` or `minimal`, collapse ≥3 identical-shape tool results (e.g. "file written successfully ×5") into a single line. No effect in `normal` mode; env: `AI_CHAT_CLI_DENSITY_COLLAPSE_REPEATS` |
| `diff_context_lines` | integer | `3` | Lines of unchanged context to keep around each diff change in compact/minimal modes. Longer context runs collapse to a `[N unchanged lines]` marker; env: `AI_CHAT_CLI_DENSITY_DIFF_CONTEXT_LINES` |
| `head_lines` | integer | `3` | Leading lines preserved when trimming long tool output in compact/detailed modes; env: `AI_CHAT_CLI_DENSITY_HEAD_LINES` |
| `tail_lines` | integer | `2` | Trailing lines preserved when trimming long tool output in compact/detailed modes; env: `AI_CHAT_CLI_DENSITY_TAIL_LINES` |

Example:

```yaml
cli:
  density:
    mode: "compact"         # opt into smart summaries
    head_lines: 3
    tail_lines: 2
    diff_context_lines: 3
    collapse_repeats: true
```

Runtime overrides: `/density compact` (current session only) and `/expand` (re-render the most recent tool result at `detailed`) always work regardless of config.

#### cli.input

Nested under `cli` — prompt-toolkit input-surface controls (#1369). These knobs control REPL editing mode, low-noise toolbar hints, large-paste review behavior, and whether vi/emacs mode state is surfaced in the toolbar.

| Field | Type | Default | Description |
|---|---|---|---|
| `editing_mode` | string | `"emacs"` | Input mode for the REPL. Accepted values: `emacs` and `vi`. In `vi`, the toolbar shows `VI INSERT` / `VI NAV` state when `show_mode_badge` is enabled. |
| `show_hints` | boolean | `true` | Show context-aware toolbar hints such as `Tab complete Alt+Enter newline Ctrl+D exit` while idle and `N pasted lines review before Enter` after a large paste. Set to `false` for a quieter prompt. |
| `hint_max_displays` | integer | `1` | Maximum times to show each hint context per REPL session. Clamped to `>= 0`; `0` disables hints without changing `show_hints`. |
| `large_paste_lines` | integer | `5` | Number of pasted lines that triggers review mode instead of immediate submit. When the threshold is hit, the paste stays in the multiline buffer and only sends on a later explicit `Enter`. Clamped to `>= 2`. |
| `show_mode_badge` | boolean | `false` | Show the current editing-mode badge in the bottom toolbar (`EMACS`, `VI INSERT`, `VI NAV`, `VI REPLACE`). Set to `true` to opt into the mode badge when you want the extra state visible. |

Example:

```yaml
cli:
  input:
    editing_mode: "vi"
    show_hints: true
    hint_max_displays: 2
    large_paste_lines: 8
    show_mode_badge: true
```

### identity

User identity for message attribution in shared databases. Auto-generated on first run via `aroom init` or on startup if missing.

| Field | Type | Description |
|---|---|---|
| `user_id` | string | UUID identifying this user (auto-generated, immutable) |
| `display_name` | string | Human-readable name shown on messages |
| `public_key` | string | Ed25519 public key in PEM format (auto-generated) |
| `private_key` | string | Ed25519 private key in PEM format (auto-generated) |

!!! warning "Back up your identity"
    The `identity` section contains a private key that proves ownership of your user ID. If you lose it, your messages in shared databases become unverifiable. Back up your `config.yaml` to preserve your identity across reinstalls.

### shared_databases

A list of additional SQLite databases. See [Shared Databases](../web-ui/shared-databases.md).

| Field | Type | Description |
|---|---|---|
| `name` | string | Display name (alphanumeric, hyphens, underscores) |
| `path` | string | Path to `.db`/`.sqlite`/`.sqlite3` file |

### mcp_servers

A list of MCP tool servers. See [MCP Servers](mcp-servers.md).

Per-server configuration:

| Field | Type | Default | Description |
|---|---|---|---|
| `name` | string | --- | Display name for the server |
| `transport` | string | --- | Transport type: `"stdio"` (local process) or `"sse"` (remote HTTP) |
| `command` | string | --- | (stdio only) Command to launch the MCP server process |
| `args` | list | `[]` | (stdio only) Command arguments |
| `url` | string | --- | (SSE only) Server-Sent Events endpoint URL |
| `env` | map | `{}` | (stdio only) Environment variables for the process; supports `${VAR}` expansion |
| `timeout` | float | `30.0` | Connection timeout in seconds |
| `tools_include` | list | `[]` | Fnmatch patterns for tools to include (empty = include all) |
| `tools_exclude` | list | `[]` | Fnmatch patterns for tools to exclude |

### mcp_tool_warning_threshold

| Field | Type | Default | Description |
|---|---|---|---|
| `mcp_tool_warning_threshold` | integer | `40` | Emit a warning when total MCP tools across all servers exceed this threshold (0 = disabled) |

### trusted_proxy

Controls client IP resolution when Anteroom runs behind a reverse proxy (nginx, Caddy, HAProxy, etc.). Disabled by default — the TCP socket peer address is used as-is. When enabled, the configured forwarded header is parsed right-to-left; the first IP not in `trusted_cidrs` is the real client. If every IP in the chain is trusted (or the chain is empty or malformed), the resolver falls back to the socket peer (fail closed). See [Client IP Resolution](../security/authentication.md#client-ip-resolution-trusted-proxy) for the full algorithm.

| Field | Type | Default | Description | Env var |
|---|---|---|---|---|
| `enabled` | boolean | `false` | Enable trusted-proxy header parsing. When `false`, socket peer IP is always used | `AI_CHAT_TRUSTED_PROXY_ENABLED` |
| `trusted_cidrs` | list[string] | `[]` | CIDR ranges (IPv4 or IPv6) of trusted proxies, e.g. `["10.0.0.0/8", "172.16.0.0/12"]`. Both the socket peer and any intermediate hops must fall within these ranges to be considered trusted | `AI_CHAT_TRUSTED_PROXY_CIDRS` (comma-separated) |
| `header` | string | `X-Forwarded-For` | Header to parse for the forwarded client IP. Common alternatives: `CF-Connecting-IP` (Cloudflare), `X-Real-IP` (nginx single-hop) | `AI_CHAT_TRUSTED_PROXY_HEADER` |

Example — nginx or Caddy deployment on an internal network:

```yaml
trusted_proxy:
  enabled: true
  trusted_cidrs:
    - "10.0.0.0/8"        # internal network
    - "172.16.0.0/12"     # Docker bridge networks
    - "127.0.0.1/32"      # localhost (same-host proxy)
  header: "X-Forwarded-For"
```

### safety

Controls the tool safety approval gate. Tools are assigned risk tiers (read, write, execute, destructive) and the approval mode determines which tiers require user confirmation before execution.

| Field | Type | Default | Description |
|---|---|---|---|
| `enabled` | boolean | `true` | Enable the safety gate globally |
| `approval_mode` | string | `ask_for_writes` | Which tiers require approval: `auto` (none), `ask_for_dangerous` (destructive only), `ask_for_writes` (write+execute+destructive), `ask` (same as ask_for_writes) |
| `approval_timeout` | integer | `120` | Seconds to wait for approval before blocking the operation (clamped 10–600) |
| `read_only` | boolean | `false` | If true, only READ-tier tools are available; all WRITE, EXECUTE, and DESTRUCTIVE tools are blocked; env: `AI_CHAT_READ_ONLY` |
| `bash.enabled` | boolean | `true` | Enable `bash` tool (set false to hard-block) |
| `write_file.enabled` | boolean | `true` | Enable `write_file` tool (set false to hard-block) |
| `allowed_tools` | list | `[]` | Tools that always skip approval regardless of tier |
| `denied_tools` | list | `[]` | Tools that are hard-blocked and never execute |
| `tool_tiers` | dict | `{}` | Per-tool tier overrides, e.g. `{my_mcp_tool: "read"}`. Valid tiers: `read`, `write`, `execute`, `destructive` |
| `custom_patterns` | list | `[]` | Additional regex patterns that trigger confirmation for bash commands |
| `sensitive_paths` | list | `[]` | Additional path prefixes that trigger confirmation for file writes |
| `subagent.*` | object | see below | Sub-agent execution limits (nested under `safety.subagent`) |

For bash-specific controls (`safety.bash.*`) and OS-level resource limits (`safety.bash.sandbox.*`), see [Sandboxing Overview](../security/sandboxing.md) and [Bash Sandboxing](../security/bash-sandboxing.md).

#### safety.subagent

Controls limits for the `run_agent` sub-agent tool. All fields are optional — sensible defaults apply when omitted.

| Field | Type | Default | Description |
|---|---|---|---|
| `max_concurrent` | integer | `5` | Maximum sub-agents running simultaneously |
| `max_total` | integer | `10` | Maximum sub-agents spawned per root request |
| `max_depth` | integer | `3` | Maximum nesting depth (sub-agents spawning sub-agents) |
| `max_iterations` | integer | `15` | Maximum agentic loop iterations per sub-agent |
| `timeout` | integer | `120` | Wall-clock timeout in seconds per sub-agent (clamped 10–600) |
| `max_output_chars` | integer | `4000` | Maximum output characters returned to parent |
| `max_prompt_chars` | integer | `32000` | Maximum prompt characters accepted |

#### safety.tool_rate_limit

Controls tool call rate limiting to prevent tool abuse and excessive iterations. All limits default to 0 (unlimited). Set a positive value to enable.

| Field | Type | Default | Description |
|---|---|---|---|
| `max_calls_per_minute` | integer | `0` | Maximum tool calls allowed per minute (0 = unlimited); applies globally across all conversations |
| `max_calls_per_conversation` | integer | `0` | Maximum tool calls allowed per conversation thread (0 = unlimited). Counts accumulated calls from root and all sub-agents |
| `max_consecutive_failures` | integer | `5` | Maximum consecutive tool failures before rate limit triggers. Useful to break infinite error loops |
| `action` | string | `block` | Action when rate limit is exceeded: `block` (deny request with error) or `warn` (log warning, allow execution) |

Example configuration to prevent runaway agents:

```yaml
safety:
  tool_rate_limit:
    max_calls_per_minute: 30              # max 30 tool calls per minute
    max_calls_per_conversation: 100       # max 100 total calls per conversation
    max_consecutive_failures: 3           # block after 3 consecutive failures
    action: "block"                       # hard block when limits exceeded
```

See [Tool Safety](../security/tool-safety.md) for the full list of built-in patterns and the approval flow.

#### safety.prompt_injection

Controls prompt injection detection and defense. Detects when untrusted content (tool outputs, RAG results) attempts to override system instructions through canary token leakage, encoding attacks, and heuristic pattern matching.

| Field | Type | Default | Description |
|---|---|---|---|
| `enabled` | boolean | `false` | Enable prompt injection detection |
| `action` | string | `warn` | Action when injection is detected: `block` (reject content), `warn` (allow + log warning), `log` (silent audit) |
| `canary_length` | integer | `16` | Length in bytes of random canary tokens embedded in system prompt for leakage detection |
| `detect_encoding_attacks` | boolean | `true` | Detect obfuscated injection attempts (base64, hex, unicode escaping) |
| `detect_instruction_override` | boolean | `true` | Detect patterns that attempt to override or ignore system instructions |
| `heuristic_threshold` | float | `0.7` | Confidence threshold (0.0–1.0) for heuristic pattern matching; higher = fewer false positives |
| `log_detections` | boolean | `true` | Log all detected injections to the security logger |

Example to enable strict injection defense:

```yaml
safety:
  prompt_injection:
    enabled: true
    action: "block"                       # hard-block injections
    detect_encoding_attacks: true
    detect_instruction_override: true
    heuristic_threshold: 0.6              # lower threshold catches more attempts
    log_detections: true
```

See [Prompt Injection Defense](../security/prompt-injection-defense.md) for technical details and threat model.

### embeddings

Controls vector embeddings for semantic search. The default local provider uses fastembed and usearch, both included in the base install; API embeddings can use any OpenAI-compatible embedding endpoint.

| Field | Type | Default | Description |
|---|---|---|---|
| `enabled` | boolean/null | `null` (auto-detect) | Enable embeddings; `null` auto-detects provider availability, `true` forces on, `false` disables |
| `provider` | string | `local` | `local` for fastembed or `api` for an OpenAI-compatible embedding endpoint |
| `model` | string | `text-embedding-3-small` | API embedding model name |
| `dimensions` | integer | `0` | Vector dimensions; `0` auto-detects from provider/model |
| `local_model` | string | `BAAI/bge-small-en-v1.5` | Fastembed model name for the local provider |
| `base_url` | string | `""` | API embedding endpoint (falls back to `ai.base_url` if empty) |
| `api_key` | string | `""` | API key for the API embedding endpoint |
| `api_key_command` | string | `""` | External command to obtain the API embedding key dynamically |
| `cache_dir` | string | `""` | Custom fastembed model cache directory; when set, enables `local_files_only` mode to prevent network requests; env: `AI_CHAT_EMBEDDINGS_CACHE_DIR` |

### compaction

Shared configuration for conversation history compaction.  Owned by the compaction service (`services/compaction.py`).  Both the shared agent loop's auto-compact path and the CLI `/compact` command read from this section.

| Field | Type | Default | Description |
|---|---|---|---|
| `preserve_tail` | integer | `6` | Recent messages preserved intact during boundary-based compaction; `0` reverts to full-summary behaviour.  Clamped to `[0, 200]` to guard against misconfiguration silently defeating compaction.  Env: `AI_CHAT_COMPACT_PRESERVE_TAIL` |
| `compact_rehydrate` | boolean | `true` | Append a bounded `<session_state>` block to compaction summaries (#1414); env: `AI_CHAT_COMPACT_REHYDRATE` |
| `compact_rehydrate_max_files` | integer | `20` | Max file paths preserved per category (read/written/edited). Clamped to `[0, 200]`; env: `AI_CHAT_COMPACT_REHYDRATE_MAX_FILES` |
| `compact_rehydrate_max_errors` | integer | `5` | Max unresolved tool errors preserved. Clamped to `[0, 50]`; env: `AI_CHAT_COMPACT_REHYDRATE_MAX_ERRORS` |
| `microcompact_enabled` | boolean | `true` | Enable the proactive microcompact stage (#1266). When true, the agent loop runs cheap, deterministic, in-memory strategies before the LLM call. Reads `cli.tool_output_max_chars` for oversized-output trimming; env: `AI_CHAT_MICROCOMPACT_ENABLED` |
| `historical_tool_collapse_enabled` | boolean | `true` | Enable proactive collapse of older tool results under token pressure. In-memory only; never writes collapsed results to SQLite. Env: `AI_CHAT_HISTORICAL_TOOL_COLLAPSE_ENABLED` |
| `historical_tool_collapse_trigger_token_count` | integer | `80000` | Estimated-token threshold for proactive historical tool-result collapse. Clamped to `[5000, 500000]`; env: `AI_CHAT_HISTORICAL_TOOL_COLLAPSE_TRIGGER_TOKEN_COUNT` |
| `historical_tool_collapse_keep_recent_groups` | integer | `6` | Recent turn groups preserved verbatim during proactive historical collapse. Clamped to `[0, 200]`; env: `AI_CHAT_HISTORICAL_TOOL_COLLAPSE_KEEP_RECENT_GROUPS` |
| `historical_tool_collapse_compact_chars` | integer | `1000` | Character budget for each collapsed historical tool result. Preserves structured fields such as status, path, exit code, and error when they fit. Clamped to `[50, 50000]`; env: `AI_CHAT_HISTORICAL_TOOL_COLLAPSE_COMPACT_CHARS` |
| `summary_trigger_msg_count` | integer | `80` | Message-count threshold for proactive summary compaction. Fires independently from the token threshold and is shown in compacting status metadata. Clamped to `[10, 1000]`; env: `AI_CHAT_SUMMARY_TRIGGER_MSG_COUNT` |
| `summary_trigger_token_count` | integer | derived (`90000` at 128k) | Optional explicit full-request estimated-token threshold for proactive summary compaction, including messages, system prompts, and tool schemas. The status label reports the estimate and threshold when this trigger fires. Clamped to `[5000, 500000]`; env: `AI_CHAT_SUMMARY_TRIGGER_TOKEN_COUNT` |
| `summary_trigger_buffer_tokens` | integer | `33904` | Free-token buffer for the derived proactive summary threshold. With the default 128k window and 4096 reserved output tokens, this resolves to `90000`; env: `AI_CHAT_SUMMARY_TRIGGER_BUFFER_TOKENS` |
| `reactive_max_attempts` | integer | `4` | Max bounded retries after a `context_length_exceeded` error. Clamped to `[0, 10]` (`0` disables reactive recovery); env: `AI_CHAT_REACTIVE_MAX_ATTEMPTS` |
| `summary_max_completion_tokens` | integer | `1000` | Maximum output budget for LLM compaction summaries. The service scales with conversation size up to this cap. Clamped to `[256, 16000]`; env: `AI_CHAT_COMPACT_SUMMARY_MAX_TOKENS` |
| `summary_retry_max_attempts` | integer | `3` | Total attempts for compaction-summary prompt-too-long recovery. Retries only on structured `context_length_exceeded` summary failures. Clamped to `[1, 10]`; env: `AI_CHAT_COMPACT_SUMMARY_RETRY_MAX_ATTEMPTS` |
| `summary_retry_drop_groups` | integer | `2` | Oldest safe turn groups dropped before each compaction-summary retry. Tool calls and tool results are kept together. Clamped to `[1, 50]`; env: `AI_CHAT_COMPACT_SUMMARY_RETRY_DROP_GROUPS` |

The legacy `cli.compact_preserve_tail` key is accepted as a deprecated alias when `compaction.preserve_tail` is not set.

Example:

```yaml
compaction:
  microcompact_enabled: true
  historical_tool_collapse_enabled: true
  historical_tool_collapse_trigger_token_count: 80000
  historical_tool_collapse_keep_recent_groups: 6
  historical_tool_collapse_compact_chars: 1000
  summary_trigger_token_count: 90000
  summary_max_completion_tokens: 4096
  summary_retry_max_attempts: 3
  summary_retry_drop_groups: 2
```

### rag

Controls the RAG retrieval pipeline --- what gets searched, how many results, filtering thresholds, and retrieval strategy.

| Field | Type | Default | Description |
|---|---|---|---|
| `enabled` | boolean | `true` | Master toggle; `false` disables RAG entirely; env: `AI_CHAT_RAG_ENABLED` |
| `max_chunks` | integer | `10` | Maximum chunks to retrieve per query; env: `AI_CHAT_RAG_MAX_CHUNKS` |
| `max_tokens` | integer | `2000` | Token budget for injected RAG context (estimated as chars / 4); env: `AI_CHAT_RAG_MAX_TOKENS` |
| `similarity_threshold` | float | `0.5` | Maximum cosine distance; only applies in `dense` mode; env: `AI_CHAT_RAG_SIMILARITY_THRESHOLD` |
| `include_sources` | boolean | `true` | Search knowledge source chunks |
| `include_conversations` | boolean | `true` | Search past conversation messages |
| `exclude_current` | boolean | `true` | Exclude current conversation from message search |
| `retrieval_mode` | string | `dense` | Retrieval strategy: `dense` (vector similarity), `keyword` (FTS5), or `hybrid` (both via RRF); env: `AI_CHAT_RAG_RETRIEVAL_MODE` |
| `show_status` | boolean | `true` | Show RAG status messages (knowledge search notices) in CLI output; env: `AI_CHAT_RAG_SHOW_STATUS` |

### reranker

Controls cross-encoder reranking of RAG results. When enabled, retrieved chunks are re-scored by a cross-encoder model for improved relevance before being injected into the prompt. Uses fastembed `TextCrossEncoder` locally by default (no external API needed).

| Field | Type | Default | Description |
|---|---|---|---|
| `enabled` | boolean/null | `null` (auto-detect) | Enable reranking; `null` creates the local reranker and loads it lazily when chunks need reranking; env: `AI_CHAT_RERANKER_ENABLED` |
| `provider` | string | `local` | Provider: `local` (fastembed TextCrossEncoder); only local is supported; env: `AI_CHAT_RERANKER_PROVIDER` |
| `model` | string | `cross-encoder/ms-marco-MiniLM-L-6-v2` | Cross-encoder model name; env: `AI_CHAT_RERANKER_MODEL` |
| `top_k` | integer | `5` | Keep top-K chunks after reranking (capped to `rag.max_chunks` at runtime); env: `AI_CHAT_RERANKER_TOP_K` |
| `score_threshold` | float | `0.0` | Minimum relevance score; cross-encoder logits can be negative; env: `AI_CHAT_RERANKER_SCORE_THRESHOLD` |
| `candidate_multiplier` | integer | `3` | Fetch `top_k * candidate_multiplier` candidates before reranking; env: `AI_CHAT_RERANKER_CANDIDATE_MULTIPLIER` |
| `cache_dir` | string | `""` | Custom fastembed model cache directory; when set, enables `local_files_only` mode to prevent network requests; env: `AI_CHAT_RERANKER_CACHE_DIR` |

### dlp

Controls Data Loss Prevention scanning for sensitive patterns (SSN, credit card, email, phone, IBAN). See [DLP documentation](../security/dlp.md) for detailed configuration and use cases.

| Field | Type | Default | Description |
|---|---|---|---|
| `enabled` | boolean | `false` | Enable DLP scanning |
| `scan_output` | boolean | `true` | Scan AI responses for sensitive data |
| `scan_input` | boolean | `false` | Scan user input for sensitive data |
| `action` | string | `redact` | Action on match: `redact` (replace with `redaction_string`), `block` (reject), or `warn` (allow + log) |
| `redaction_string` | string | `[REDACTED]` | String to replace matched patterns when action is `redact` |
| `log_detections` | boolean | `true` | Log all DLP detections to the security logger |
| `patterns` | list | `[]` | Built-in patterns are loaded automatically (SSN, credit card, email, phone, IBAN) |
| `custom_patterns` | list | `[]` | Custom regex patterns. Each is a dict with `name`, `pattern` (regex), and `description` |

### output_filter

Controls output content filtering to detect and prevent system prompt leaks and forbidden content in LLM responses. See [Output Filter documentation](../security/output-filter.md) for detailed configuration and use cases.

| Field | Type | Default | Description |
|---|---|---|---|
| `enabled` | boolean | `false` | Enable output content filtering |
| `system_prompt_leak_detection` | boolean | `true` | Detect system prompt leaks via n-gram analysis of LLM output against the system prompt |
| `leak_threshold` | float | `0.4` | Minimum n-gram similarity threshold (0.0–1.0) for flagging a potential leak |
| `action` | string | `warn` | Action on filter match: `warn` (allow + log), `redact` (replace with `redaction_string`), or `block` (reject) |
| `redaction_string` | string | `[FILTERED]` | String to replace filtered content when action is `redact` |
| `log_detections` | boolean | `true` | Log all filter detections to the security logger |
| `custom_patterns` | list | `[]` | Custom regex patterns for forbidden content. Each is a dict with `name`, `pattern` (regex), and `description` |

### usage

Controls token usage tracking and cost estimation for the CLI. All fields are optional with sensible defaults.

| Field | Type | Default | Description |
|---|---|---|---|
| `week_days` | integer | `7` | Number of days to include in "this week" rolling window |
| `month_days` | integer | `30` | Number of days to include in "this month" rolling window |
| `model_costs` | dict | `{}` | Model pricing for cost estimation, keyed by model name with `input` and `output` rates (per-million-token) |

Example with cost estimation:

```yaml
cli:
  usage:
    week_days: 7
    month_days: 30
    model_costs:
      gpt-4o: { input: 0.003, output: 0.006 }
      gpt-4-turbo: { input: 0.01, output: 0.03 }
      claude-3-sonnet: { input: 0.003, output: 0.015 }
```

The `/usage` command displays token counts and estimated costs across multiple time periods. Without `model_costs` configured, only token counts are shown.

#### usage.budgets

Nested under `usage` — controls token budget enforcement for denial-of-wallet prevention.

| Field | Type | Default | Description |
|---|---|---|---|
| `enabled` | boolean | `false` | Enable token budget enforcement |
| `max_tokens_per_request` | integer | `0` | Maximum tokens for a single request (0 = unlimited) |
| `max_tokens_per_conversation` | integer | `0` | Maximum tokens accumulated in one conversation (0 = unlimited) |
| `max_tokens_per_day` | integer | `0` | Maximum tokens consumed in a calendar day (0 = unlimited) |
| `warn_threshold_percent` | integer | `80` | Warn when usage exceeds this percentage of any limit (0-100) |
| `action_on_exceed` | string | `block` | What to do when a limit is exceeded: `block` (deny request) or `warn` (allow but notify) |

Example:

```yaml
cli:
  usage:
    model_costs:
      gpt-4o: { input: 0.003, output: 0.006 }
    budgets:
      enabled: true
      max_tokens_per_request: 100000     # single request cap
      max_tokens_per_conversation: 500000 # conversation cap
      max_tokens_per_day: 2000000         # daily cap
      warn_threshold_percent: 80          # warn at 80%
      action_on_exceed: block             # hard block when exceeded
```

Budgets are checked at the start of each request. When a limit is exceeded with `action_on_exceed: block`, the request is rejected with a `budget_exceeded` error. With `action_on_exceed: warn`, the request proceeds but a `budget_warning` event is emitted.

### workflow

Controls resource limits, credential definitions, and behavior for the workflow engine.

```yaml
workflow:
  budget:
    max_duration_seconds: 0
    max_steps: 0
    max_tokens: 0
  credentials: []
  watch_buffer_lines: 50
```

#### workflow general

| Field | Type | Default | Env Var | Description |
|---|---|---|---|---|
| `workflow.watch_buffer_lines` | int | `50` | — | Maximum number of transcript lines to display in `workflow watch`. Older lines are dropped when the buffer is full |
| `workflow.lock_reclaim_threshold` | int | `120` | — | Seconds after which a run with a stale heartbeat has its lock reclaimed. Minimum 30 |

#### workflow.budget

| Field | Type | Default | Env Var | Description |
|---|---|---|---|---|
| `workflow.budget.max_duration_seconds` | int | `0` | — | Wall-clock ceiling in seconds. 0 = unlimited |
| `workflow.budget.max_steps` | int | `0` | — | Maximum step completions per run. 0 = unlimited |
| `workflow.budget.max_tokens` | int | `0` | — | Maximum total tokens (prompt + completion) per run. 0 = unlimited |

!!! note "Config budgets are ceilings"
    Config-level budgets act as ceilings — if a workflow definition also declares a budget, the effective limit is the minimum of non-zero values per dimension.

#### workflow.transcript

Controls durable transcript recording for workflow steps. Transcript events capture agent turns, tool calls, and runner output inside each step and are stored in the `workflow_events` table.

Transcript recording is **enabled by default**. Disable it by setting `enabled: false`.

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `workflow.transcript.enabled` | bool | `true` | Enable durable transcript recording for workflow steps |
| `workflow.transcript.max_assistant_chars` | int | `4000` | Max characters per assistant turn transcript entry |
| `workflow.transcript.max_tool_output_chars` | int | `2000` | Max characters per tool result transcript entry |
| `workflow.transcript.max_stdout_chars` | int | `4000` | Max characters for stdout transcript entries |
| `workflow.transcript.max_stderr_chars` | int | `4000` | Max characters for stderr transcript entries |

Content is truncated to the configured limit before storage. Truncation is applied per entry.

Example:

```yaml
workflow:
  transcript:
    enabled: true
    max_assistant_chars: 4000
    max_tool_output_chars: 2000
    max_stdout_chars: 4000
    max_stderr_chars: 4000
```

#### workflow.credentials (#970)

Define default credentials available to all workflow runs in this config layer. Workflow definitions can override with their own `credentials` section.

```yaml
workflow:
  credentials:
    - name: github_token
      env_var: GITHUB_TOKEN
    - name: deploy_key
      command: "op read op://vault/keys/deploy"
```

| Field | Type | Required | Description |
|---|---|---|---|
| `name` | string | yes | Credential identifier |
| `env_var` | string | no | Environment variable to look up (must be non-empty) |
| `command` | string | no | Shell command to execute (must exit 0 and produce output) |
| `allowed_runners` | list | no | Limit to specific runner types (e.g., `["shell"]`); `null` = all |

Rules:

- Exactly one of `env_var` or `command` must be provided per credential
- Resolved values are never logged, stored, or included in error messages
- Resolution happens at step runtime when a step's `credentials` field references a name
- Resolution failures block the step with a redacted error

### references

Paths to shared instruction, rule, and skill files. Typically used in team or project configs to share development standards. See [Project Configuration](project-config.md).

| Field | Type | Default | Description |
|---|---|---|---|
| `instructions` | list | `[]` | Paths to instruction files (markdown) loaded as context |
| `rules` | list | `[]` | Paths to rule files (markdown) loaded as behavioral constraints |
| `skills` | list | `[]` | Paths to skill files (markdown) loaded as reusable prompts |

Paths are resolved relative to the config file that declares them. Non-string entries are silently filtered out.

### required

A list of config keys that must be present in the user's personal config. Typically used in team or project configs. See [Project Configuration](project-config.md#required-keys).

Each entry is a dict with:

| Field | Type | Required | Description |
|---|---|---|---|
| `path` | string | yes | Dot-separated config path (e.g., `ai.api_key`) |
| `description` | string | no | Human-readable description shown during prompting |

In interactive mode, missing required keys trigger a prompt. Sensitive fields (containing `key`, `secret`, `password`, `token`, `passphrase`) use masked input. Values are saved to the personal config with 0600 permissions.

In non-interactive mode, missing keys produce an error message listing each missing path and its equivalent `AI_CHAT_*` environment variable.

### pack_sources

Configure git repositories containing packs for automatic cloning and refresh.

```yaml
pack_sources:
  - url: https://github.com/acme/anteroom-packs.git
    branch: main
    refresh_interval: 30  # minutes; 0 = manual only
    auto_attach: true     # automatically attach packs from this source
    priority: 50          # 1-100, lower wins for conflict resolution
```

| Field | Type | Default | Description |
|---|---|---|---|
| `url` | string | (required) | Git remote URL. Accepts `https://`, `ssh://`, `git://`, and SSH shorthand (`git@host:path`). Rejects `http://`, `ext::`, and `file://` |
| `branch` | string | `"main"` | Git branch to clone and track |
| `refresh_interval` | integer | `30` | Minutes between auto-refresh. `0` = manual only. Minimum: 5 (values below 5 are clamped) |
| `auto_attach` | boolean | `true` | When `true`, new packs from this source are automatically attached at the global scope on install. When `false`, packs must be manually attached via `aroom pack attach` |
| `priority` | integer | `50` | Conflict resolution priority (1-100). Lower number wins when multiple sources provide conflicting packs. Must be in range 1-100 |

The background worker clones sources on first encounter, then pulls periodically. After 10 consecutive background refresh loop failures, the worker stops all source refreshes until restart. See [Pack Sources](../packs/pack-sources.md) for the full lifecycle.

## Config History

Anteroom automatically snapshots `~/.anteroom/config.yaml` before every managed write (via `aroom config set`, `/config` TUI, or the web UI config editor) and on startup whenever an external edit is detected (file hash differs from the last recorded snapshot). Snapshots are stored as plain YAML files under `~/.anteroom/backups/config/` and indexed by an append-only JSONL manifest.

### Commands

```bash
aroom config history                  # list saved snapshots, newest first
aroom config history --limit 10       # limit to 10 entries
aroom config diff <version-id>        # unified diff between a snapshot and current config
aroom config restore <version-id>     # restore config from a snapshot (interactive confirmation)
aroom config restore <version-id> -y  # restore without confirmation prompt
```

Example output of `aroom config history`:

```
  #   Version ID               Saved                  Source
──────────────────────────────────────────────────────────────
  1   20250420T143012Z-a1b2c3d4  2025-04-20 14:30:12 UTC  config_editor
  2   20250420T130000Z-deadbeef  2025-04-20 13:00:00 UTC  external
  3   20250420T090000Z-cafebabe  2025-04-20 09:00:00 UTC  initial
```

Source values:

| Source | Meaning |
|---|---|
| `config_editor` | Written by `aroom config set`, `/config` TUI, or web UI |
| `external` | File changed while Anteroom was not running (detected at startup) |
| `initial` | First time Anteroom saw the config file |
| `pre-restore` | Auto-snapshot created before a `restore` (makes restores undoable) |

### Durability

Config writes use a durable atomic-write pattern: the content is flushed and `fsync`'d to a temporary sibling file before being atomically renamed into place, and the parent directory is `fsync`'d after the rename. This prevents config corruption on process crash or power loss mid-write.

Backup snapshots themselves are plain file copies and do not use `fsync` — they are redundant backups, not primary data. The primary config file is always written durably first.

### Storage

Snapshots are stored at `~/.anteroom/backups/config/config-<version-id>.yaml` with 0600 permissions. The manifest is at `~/.anteroom/backups/config/history.jsonl`. Neither path is configurable in V1. Duplicate content is deduplicated by SHA-256 hash — snapshotting the same unchanged file twice records only one entry.

## API Key Command

The `api_key_command` field runs an external command to obtain API keys with automatic transparent refresh:

- Command is executed via `subprocess.run()` with `shlex.split()` --- no `shell=True`, preventing shell injection
- 30-second execution timeout prevents hanging commands
- Token is cached in memory only, never written to disk or logged
- On HTTP 401, the command is re-run automatically and the request is retried

```yaml
ai:
  api_key_command: "aws secretsmanager get-secret-value --secret-id anteroom-key --query SecretString --output text"
```

## Hooks

Tool lifecycle hooks let you intercept every tool call before it runs (`pre_tool`) or after it completes (`post_tool`).  Hooks are defined in your personal config (`~/.anteroom/config.yaml`) or team config and are snapshotted at session start — changes to hook config are detected but **not applied to a running session** (restart required).

### Trust boundary (phase 1)

Only hooks from `personal` and `team` config sources are executed.  Pack-provided hooks are parsed and stored with `trust_source: "pack"` but are **not executed** until trust verification is wired in (#1272).

See [Behavior-Bearing Packs](../packs/behavior-bearing-packs.md) for the approved design boundary and rollout plan for any future executable pack behavior.

### Entry schema

```yaml
hooks:
  pre_tool:
    - id: guard-bash          # required; stable key for dedup across config layers
      event: pre_tool         # pre_tool | post_tool
      matcher:
        tool_name: "bash"     # fnmatch pattern; "*" matches all tools
        arguments: {}         # optional key/value pairs that must match tool args
      runner:
        type: command         # command (shell) | webhook (HTTP POST)
        command: "/usr/local/bin/hook-guard"   # for type: command
        url: ""               # for type: webhook
        timeout: 5            # seconds; clamped to [1, 30]
      message: "Script guard triggered"   # shown to user on deny/ask
  post_tool: []
```

### Runtime ordering and approval interplay

Hook outcomes follow a strict precedence chain that cannot be overridden:

```
static hard deny  →  pre-tool hooks  →  tier-based approval  →  tool dispatch  →  post-tool hooks
```

**What each outcome means:**

| Hook outcome | Effect |
|---|---|
| `"allow"` | Hook passes; continue to tier-based approval (if required by safety config) |
| `"deny"` | Tool is blocked immediately; tier-based approval is **not** prompted |
| `"ask"` | Route to the user approval flow; tool runs only if user approves |

**Invariants that cannot be changed by hook config:**

- A static `rule_enforcer` hard deny always wins — hooks never run after it.
- A hook `allow` never bypasses tier-based approval that safety config requires.
- A hook `deny` is always unconditional — it cannot be overridden by approval mode.

**Audit trail:** When a hook returns `ask` and the user resolves the prompt, Anteroom emits a `hook.approval_resolved` event (in addition to the earlier `hook.completed` event) so the audit trail distinguishes hook-driven escalations from tier-based approval prompts.  The `_approval_decision` field on the tool result is:

| Scenario | `_approval_decision` value |
|---|---|
| No hooks configured | `"auto"` |
| Hook allow, no tier approval needed | `"auto"` |
| Hook deny | `"hook_denied"` |
| Hook ask → user approves | `"hook_escalated_approved"` |
| Hook ask → user denies | `"hook_escalated_denied"` |
| Hook ask, no approval channel | `"denied"` (fail closed) |

### Deduplication

When the same `id` appears in multiple config layers (personal, team, pack), the **first-seen wins** (personal > team > pack).  This lets teams provide default hooks that personal config can override.

### Audit snapshot contract

When `audit.enabled: true`, Anteroom captures a **hook config snapshot** at startup. In the CLI this happens once per REPL session; in the web/server runtime it happens once per process boot. The snapshot is written as a single `hook.snapshot` audit event and includes, for each entry: `id`, `event`, `trust_source`, `is_executable`, `runner_type`, and a SHA-256 hash of the full resolved entry payload. This provides:

- **Integrity**: the exact hook config in effect for a session is on the audit trail; post-hoc changes do not alter past records.
- **Traceability**: `trust_source` in the snapshot reflects the declaring layer (`"personal"`, `"team"`, or `"pack"`), not just executability.
- **Tamper detection**: the per-entry hash lets you verify that the running config matched a known-good baseline.

Hook config changes between sessions or server restarts appear as a new snapshot event; changes during a live session are detected by the config watcher but ignored until restart (session-scoped contract).

### Phase-2 lifecycle events (coming later)

The following hook events are **reserved** and will be supported in a later release.  Configuring them today produces a clear validation error rather than silent failure.

| Event | Description |
|-------|-------------|
| `session_start` | Fires once when a new session is created. |
| `stop` | Fires when the agent loop exits normally. |
| `approval_requested` | Fires when a tool call enters the approval flow. |
| `approval_resolved` | Fires when an approval gate closes (approved or rejected). |
| `notification` | Fires for informational narration events from the agent loop. |

See the [Hook Lifecycle Roadmap](../hooks/lifecycle-roadmap.md) for sequencing details and a diagram of where each event fits in the agent loop.

### Reference

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `id` | string | — | **Required.** Stable key for deduplication. |
| `event` | `pre_tool` \| `post_tool` | `pre_tool` | Lifecycle event. Phase-2 events (`session_start`, `stop`, `approval_requested`, `approval_resolved`, `notification`) are reserved and not yet active. |
| `matcher.tool_name` | string | `"*"` | fnmatch pattern against tool name. |
| `matcher.arguments` | dict | `{}` | Key/value pairs that must all be present in tool arguments. |
| `runner.type` | `command` \| `webhook` | `command` | Execution type. |
| `runner.command` | string | `""` | Shell command string (for `command` type). |
| `runner.url` | string | `""` | HTTP/HTTPS endpoint URL (for `webhook` type). Must use `http://` or `https://` scheme. |
| `runner.timeout` | integer | `5` | Timeout in seconds; clamped to [1, 30]. |
| `message` | string | `""` | Human-readable note shown in deny/ask decisions. |
