--- sidebar_position: 2 title: "Hermes Agent Configuration" description: "Configure Hermes Agent — config.yaml, providers, models, API keys, and more" --- # Hermes Agent Configuration All settings are stored in the `~/.hermes/` directory for easy access. :::tip Easiest path to a working `config.yaml` Run `hermes setup --portal` — one OAuth gets you a model provider and all four Tool Gateway tools without hand-editing YAML. Portal subscribers also get 10% off token-billed providers. See [Nous Portal](/integrations/nous-portal). ::: ## Directory Structure ```text ~/.hermes/ ├── config.yaml # Settings (model, terminal, TTS, compression, etc.) ├── .env # API keys and secrets ├── auth.json # OAuth provider credentials (Nous Portal, etc.) ├── SOUL.md # Primary agent identity (slot #1 in system prompt) ├── memories/ # Persistent memory (MEMORY.md, USER.md) ├── skills/ # Agent-created skills (managed via skill_manage tool) ├── cron/ # Scheduled jobs ├── sessions/ # Gateway sessions └── logs/ # Logs (errors.log, gateway.log — secrets auto-redacted) ``` ## Managing Configuration ```bash hermes config # View current configuration hermes config edit # Open config.yaml in your editor hermes config get KEY # Print a resolved value hermes config set KEY VAL # Set a specific value hermes config unset KEY # Remove a user-set value hermes config check # Check for missing options (after updates) hermes config migrate # Interactively add missing options # Examples: hermes config get model hermes config set model anthropic/claude-opus-4 hermes config set terminal.backend docker hermes config unset terminal.backend hermes config set OPENROUTER_API_KEY sk-or-... # Saves to .env ``` :::tip The `hermes config set` command automatically routes values to the right file — API keys are saved to `.env`, everything else to `config.yaml`. ::: ## Configuration Precedence Settings are resolved in this order (highest priority first): 1. **CLI arguments** — e.g., `hermes chat --model anthropic/claude-sonnet-4` (per-invocation override) 2. **`~/.hermes/config.yaml`** — the primary config file for all non-secret settings 3. **`~/.hermes/.env`** — fallback for env vars; **required** for secrets (API keys, tokens, passwords) 4. **Built-in defaults** — hardcoded safe defaults when nothing else is set :::info Rule of Thumb Secrets (API keys, bot tokens, passwords) go in `.env`. Everything else (model, terminal backend, compression settings, memory limits, toolsets) goes in `config.yaml`. When both are set, `config.yaml` wins for non-secret settings. ::: :::tip Org deployments An administrator can pin specific config and secret values that a standard user cannot override, via a system-level managed directory. See [Managed Scope](/user-guide/managed-scope). ::: ## Runtime Limits Long-running Hermes server surfaces (including the gateway and `hermes serve --isolated`) apply the configured `RLIMIT_NOFILE` soft limit during startup when the operating system supports it: ```yaml runtime: nofile_soft_limit: 4096 ``` The default is `4096`. Hermes clamps the target to the operating system's hard limit and never lowers a process that already has a higher soft limit. Set the value to `0`, `false`, or `null` to disable the adjustment. On Windows and in sandboxes where the limit cannot be changed, startup continues without changing the limit. ## Database Settings The `database:` section controls how Hermes opens its SQLite state database (`state.db`), which stores sessions, messages, and gateway routing: ```yaml database: # Journal mode for state.db: wal (default) or delete. # Use delete on filesystems where WAL is unsafe (network mounts, some # virtiofs setups). Note: an existing on-disk WAL database is never # live-downgraded — Hermes keeps WAL and logs an error telling you the # configured delete did not apply. To convert an existing database, stop # every process using it and run a one-time offline # `PRAGMA journal_mode=DELETE` on the file. journal_mode: wal # Durability level for every state.db connection: OFF, NORMAL, FULL, # EXTRA (or 0-3). Unset leaves SQLite's compile-time default, which # differs between interpreter builds. On macOS this is a floor, not a # pin: values below FULL are refused to protect against Darwin fsync # reordering; EXTRA is honored. # synchronous: FULL # Optional WAL sizing pragmas (integers). Unset = SQLite defaults. # wal_autocheckpoint: 1000 # pages between automatic checkpoints # journal_size_limit: 67108864 # cap the WAL/journal size in bytes ``` Hermes also warns (once per process per database) when an existing database's on-disk journal mode is silently flipped to WAL on open — for example a database an operator had manually converted to `delete` — and names `database.journal_mode` as the setting that makes the choice stick. ## Environment Variable Substitution You can reference environment variables in `config.yaml` using `${VAR_NAME}` syntax: ```yaml auxiliary: vision: api_key: ${GOOGLE_API_KEY} base_url: ${CUSTOM_VISION_URL} delegation: api_key: ${DELEGATION_KEY} ``` Multiple references in a single value work: `url: "${HOST}:${PORT}"`. If a referenced variable is not set, the placeholder is kept verbatim (`${UNDEFINED_VAR}` stays as-is) and a warning is logged. Bare `$VAR` is not expanded. Under a [multiplexed multi-profile gateway](/user-guide/multi-profile-gateways), references in a profile's `config.yaml` resolve against **that profile's** `.env` (its secret scope), not the shared process environment — a `${MATRIX_ACCESS_TOKEN}` in profile B stays unresolved unless B defines the variable itself. Single-profile runs are unchanged. Cursor-style SecretRef syntax is also accepted: `${env:VAR_NAME}` resolves exactly like `${VAR_NAME}` (the `env:` prefix is stripped), so MCP or provider snippets copied from Cursor / Claude configs work unchanged in both `config.yaml` and the `mcp_servers` block. Other SecretRef sources (`${file:...}`, `${vault:...}`, `${bitwarden:...}`) are **not** resolved inline — external secret backends inject their values into the environment at startup via the `secrets:` block, so reference them as `${env:NAME}` instead; unknown prefixes warn once and stay verbatim. For AI provider setup (OpenRouter, Anthropic, Copilot, custom endpoints, self-hosted LLMs, fallback models, etc.), see [AI Providers](/integrations/providers). ### Provider Timeouts You can set `providers..request_timeout_seconds` for a provider-wide request timeout, plus `providers..models..timeout_seconds` for a model-specific override. Applies to the primary turn client on every transport (OpenAI-wire, native Anthropic, Anthropic-compatible), the fallback chain, rebuilds after credential rotation, and (for OpenAI-wire) the per-request timeout kwarg — so the configured value wins over the legacy `HERMES_API_TIMEOUT` env var. You can also set `providers..stale_timeout_seconds` for the non-streaming stale-call detector, plus `providers..models..stale_timeout_seconds` for a model-specific override. This wins over the legacy `HERMES_API_CALL_STALE_TIMEOUT` env var. Leaving these unset keeps the legacy defaults (`HERMES_API_TIMEOUT=1800`s, `HERMES_API_CALL_STALE_TIMEOUT=90`s, native Anthropic 900s). The non-streaming stale detector is auto-disabled for local endpoints when left implicit and can scale upward for very large contexts. Not currently wired for AWS Bedrock (both `bedrock_converse` and AnthropicBedrock SDK paths use boto3 with its own timeout configuration). See the commented example in [`cli-config.yaml.example`](https://github.com/NousResearch/hermes-agent/blob/main/cli-config.yaml.example). ## Update Behavior `hermes update` settings live under `updates` in `config.yaml`: ```yaml updates: pre_update_backup: quick # quick (state snapshot, default) | full (snapshot + HERMES_HOME zip) | off backup_keep: 5 # Keep this many full pre-update backup zips non_interactive_local_changes: stash # stash | discard auto_switch_parked_branch: true # auto-switch a clean, fully merged parked branch back to main ``` `pre_update_backup` is the single pre-update safety knob: `quick` (default) snapshots critical state files (pairing data, cron jobs, config, auth; files over 1 GiB are skipped) into `state-snapshots/`; `full` additionally zips all of `HERMES_HOME` into `backups/` and can add minutes on large homes; `off` disables both. Legacy booleans are honored (`true` → `full`, `false` → `off`). For git installs, Hermes auto-stashes dirty tracked files and untracked files before checking out the update branch or pulling. Interactive terminal updates prompt before restoring that stash. Non-interactive updates (desktop/chat app, gateway, or `--yes`) use `updates.non_interactive_local_changes`: `stash` restores local source edits after a successful pull, while `discard` drops the update-created stash after a successful pull. Use `discard` only on managed installs where local source edits are never meant to persist. Before that stash step, Hermes also restores tracked `package-lock.json` diffs left by npm install/build churn. Commit or manually stash intentional lockfile edits before updating. ## Terminal Backend Configuration Hermes supports seven terminal backends. Each determines where the agent's shell commands actually execute — your local machine, a Docker container, a remote server via SSH, a Modal cloud sandbox (direct or via the Nous-managed gateway), a Daytona workspace, a Vercel Sandbox, or a Singularity/Apptainer container. ```yaml terminal: backend: local # local | docker | ssh | modal | daytona | vercel_sandbox | singularity cwd: "." # Gateway/cron working directory (CLI always uses launch dir) temp_dir: "" # Session temp root; empty = TMPDIR, else ~/.hermes/cache/terminal font_family: "" # Desktop terminal font; e.g. "MesloLGS NF" timeout: 180 # Per-command timeout in seconds home_mode: auto # auto | real | profile — subprocess HOME policy env_passthrough: [] # Env var names to forward to sandboxed execution (terminal + execute_code) singularity_image: "docker://nikolaik/python-nodejs:python3.11-nodejs20" # Container image for Singularity backend modal_image: "nikolaik/python-nodejs:python3.11-nodejs20" # Container image for Modal backend daytona_image: "nikolaik/python-nodejs:python3.11-nodejs20" # Container image for Daytona backend ``` `terminal.temp_dir` controls where Hermes puts session temp artifacts on the local backend — background-process logs/pid/exit files, code-execution sandboxes, and spilled tool results. When it's empty (the default), Hermes honors an explicit `TMPDIR`/`TMP`/`TEMP` from the environment and otherwise uses a managed directory on real storage at `~/.hermes/cache/terminal` instead of `/tmp` — on many distros (Arch-based setups in particular) `/tmp` is a small RAM-backed tmpfs that Hermes session artifacts can fill under load. The managed directory is auto-pruned: artifacts older than 72 hours are swept hourly by gateway housekeeping and once per process on CLI-only installs. Set `temp_dir` to an existing absolute path to redirect session temp anywhere else; user-set paths are never auto-pruned. `terminal.font_family` controls the embedded terminal in Hermes Desktop. It accepts either one locally installed family name (for example, `MesloLGS NF`) or a CSS font stack. Hermes appends its bundled JetBrains Mono stack as a fallback, and an empty value keeps the default. You can edit the same profile-scoped setting in **Settings → Appearance → Terminal Font**; no Google Fonts download or system-font permission is required. For cloud sandboxes such as Modal, Daytona, and Vercel Sandbox, `container_persistent: true` means Hermes will try to preserve filesystem state across sandbox recreation. It does not promise that the same live sandbox, PID space, or background processes will still be running later. ### Backend Overview | Backend | Where commands run | Isolation | Best for | |---------|-------------------|-----------|----------| | **local** | Your machine directly | None | Development, personal use | | **docker** | Single persistent Docker container (shared across session, `/new`, subagents) | Full (namespaces, cap-drop) | Safe sandboxing, CI/CD | | **ssh** | Remote server via SSH | Network boundary | Remote dev, powerful hardware | | **modal** | Modal cloud sandbox | Full (cloud VM) | Ephemeral cloud compute, evals | | **daytona** | Daytona workspace | Full (cloud container) | Managed cloud dev environments | | **vercel_sandbox** | Vercel Sandbox | Full (cloud microVM) | Cloud execution with snapshot-backed filesystem persistence | | **singularity** | Singularity/Apptainer container | Namespaces (--containall) | HPC clusters, shared machines | ### Local Backend The default. Commands run directly on your machine with no isolation. No special setup required. ```yaml terminal: backend: local ``` By default, local tool subprocesses keep your real OS-user `HOME`. This lets external CLIs such as `git`, `ssh`, `gh`, `az`, `npm`, Claude Code, and Codex find the credentials and config they already use in your normal shell. Hermes state is still profile-scoped through `HERMES_HOME`; `HOME` is not how profiles select config, memory, sessions, or skills. Hermes does **not** change your system-wide `HOME`, your shell startup files, or the operating system account home. This setting only controls the environment passed to subprocesses that Hermes launches through tools such as `terminal`, background terminal processes, `execute_code`, and ACP helper processes. #### `terminal.home_mode` | Mode | Host installs | Containers | Tradeoff | |---|---|---|---| | `auto` | Keep the real OS-user `HOME` | Use `{HERMES_HOME}/home` | Recommended default. Host CLIs keep working; container state persists. | | `real` | Force the real OS-user `HOME` | Force the real OS-user `HOME` if visible | Useful if a parent process accidentally started with `HOME` pointed at a profile home. | | `profile` | Use `{HERMES_HOME}/home` when it exists | Use `{HERMES_HOME}/home` when it exists | Strict per-profile CLI config isolation, but normal `~/.ssh`, `~/.gitconfig`, `~/.azure`, `~/.config/gh`, Claude/Codex auth, npm state, etc. will not be visible unless you initialize or link them inside the profile home. | The downside of the default is that host profiles share the same normal user-level CLI credentials/config under `~`. If you need a profile with a separate git identity, SSH keys, GitHub CLI login, npm config, or cloud CLI login, use `home_mode: profile` and initialize those tools inside that profile home deliberately. If you intentionally want strict per-profile tool-config isolation, set: ```yaml terminal: home_mode: profile ``` In that mode tool subprocesses use `{HERMES_HOME}/home` as `HOME`. Hermes also sets `HERMES_REAL_HOME` so scripts can still locate the actual user home when they need it. Container backends keep using `{HERMES_HOME}/home` in `auto` mode because that directory lives on the persistent Hermes data volume. Scripts that need to distinguish profile state from the real user home should prefer `HERMES_HOME` for Hermes data and `HERMES_REAL_HOME` for the account home: ```python from pathlib import Path import os hermes_home = Path(os.environ["HERMES_HOME"]) real_home = Path(os.environ.get("HERMES_REAL_HOME", os.environ["HOME"])) ``` :::warning The agent has the same filesystem access as your user account. Use `hermes tools` to disable tools you don't want, or switch to Docker for sandboxing. ::: ### Docker Backend Runs commands inside a Docker container with security hardening (all capabilities dropped, no privilege escalation, PID limits). **Single persistent container, shared across Hermes processes.** Hermes starts ONE long-lived container on first use and routes every terminal, file, and `execute_code` call through `docker exec` into that same container — across sessions, `/new`, `/reset`, and `delegate_task` subagents. Working-directory changes, installed packages, files in `/workspace`, and **background processes** all carry over from one tool call to the next, and from one Hermes process to the next. When you close a TUI session, run `/quit`, or start a new `hermes` invocation, the container keeps running and the next Hermes process reuses it via a labeled lookup. See **Container lifecycle** below for the exact teardown rules. **Per-session isolation mode (`container_persistent: false`).** Setting `container_persistent: false` on the Docker backend switches to one container **per session**: every chat (desktop app session, gateway conversation, TUI session) gets its own fresh sandbox, created on its first terminal/file call and removed when the session closes or goes idle past `lifetime_seconds`. Nothing carries over between sessions — no filesystem state, no mounts, no background processes. With `docker_mount_cwd_to_workspace: true`, only the workspace **attached to that session** is mounted at `/workspace`; a fresh session with no attached directory gets an empty workspace instead of inheriting the previous session's mount. `delegate_task` subagents still share their parent session's container. Use this mode when the sandbox is a security boundary between conversations; keep the default `true` when you want the long-lived shared container described above. ```yaml terminal: backend: docker docker_image: "nikolaik/python-nodejs:python3.11-nodejs20" docker_mount_cwd_to_workspace: false # Mount launch dir into /workspace docker_run_as_host_user: false # See "Running container as host user" below docker_forward_env: # Host env vars to forward into container - "GITHUB_TOKEN" docker_env: # Literal env vars to inject (KEY=value) DEBUG: "1" PYTHONUNBUFFERED: "1" docker_volumes: # Host directory mounts - "/home/user/projects:/workspace/projects" - "/home/user/data:/data:ro" # :ro for read-only docker_extra_args: # Extra flags appended verbatim to `docker run` - "--gpus=all" - "--network=host" docker_network: true # false = air-gap the container (--network=none) # Resource limits container_cpu: 1 # CPU cores (0 = unlimited) container_memory: 5120 # MB (0 = unlimited) container_disk: 51200 # MB (requires overlay2 on XFS+pquota) container_persistent: true # true = persist /workspace + /root, shared container; false = fresh container per session (see below) # Cross-process container reuse (defaults match the "one long-lived # container shared across sessions" contract — see Container lifecycle). docker_persist_across_processes: true # Reuse container across Hermes restarts docker_shared_container_key: "" # Opt in trusted profiles to one identity docker_orphan_reaper: true # Sweep abandoned Exited containers at startup # Cross-backend lifecycle settings (apply to docker as well) timeout: 180 # Per-command timeout in seconds lifetime_seconds: 300 # Idle-reaper window; also feeds 2× orphan-reaper threshold ``` **`docker_env`** vs **`docker_forward_env`**: the former injects literal `KEY=value` pairs you specify in the config (the values live in your `config.yaml` or are passed as a JSON dict via `TERMINAL_DOCKER_ENV='{"DEBUG":"1"}'`). The latter forwards values from your shell or `~/.hermes/.env`, so the actual secret never appears in the config file. Use `docker_forward_env` for tokens and `docker_env` for static knobs the container needs. **`terminal.docker_extra_args`** (also overridable via `TERMINAL_DOCKER_EXTRA_ARGS='["--gpus=all"]'`) lets you pass arbitrary `docker run` flags that Hermes doesn't surface as first-class keys — `--gpus`, `--network`, `--add-host`, alternative `--security-opt` overrides, etc. Each entry must be a string; the list is appended last to the assembled `docker run` invocation so it can override Hermes' defaults if needed. Use sparingly — flags that conflict with the sandbox hardening (capability drops, `--user`, the workspace bind mount) will silently weaken isolation. **`terminal.docker_network`** (default `true`; env: `TERMINAL_DOCKER_NETWORK`) — set to `false` to run the sandbox container with `--network=none`, cutting off all network egress from agent commands. This applies to the execution container used by `terminal`, `execute_code`, and the file tools. Because containers persist across Hermes processes, flipping this to `false` while an older networked container exists will remove that container and start a fresh air-gapped one (a warning is logged); background processes running inside it are lost. Prefer this key over passing `--network=none` through `docker_extra_args`. **Requirements:** Docker Desktop or Docker Engine installed and running. Hermes probes `$PATH` plus common macOS install locations (`/usr/local/bin/docker`, `/opt/homebrew/bin/docker`, Docker Desktop app bundle). Podman is supported out of the box: set `HERMES_DOCKER_BINARY=podman` (or the full path) to force it when both are installed. #### Container lifecycle Every Hermes-managed container is tagged with three labels so subsequent processes (and the orphan reaper) can identify it: - `hermes-agent=1` — marks it as Hermes-managed - `hermes-task-id=` — keys the per-task reuse probe - `hermes-profile=` — scopes reuse and reaping to the active Hermes profile by default; when `docker_shared_container_key` is set, its sanitized value is used instead On startup, Hermes runs `docker ps --filter label=hermes-task-id= --filter label=hermes-profile=` and **attaches to the existing container** when it finds one. The identity is the active profile unless `docker_shared_container_key` explicitly opts trusted profiles into a common value. If the container is `exited` (e.g. after a Docker daemon restart), it's `docker start`'d and reused — filesystem state and any installed packages survive, but in-container background processes do not. When a Hermes process exits — `/quit`, closing a TUI session, gateway shutdown, even SIGKILL — the cleanup path is a **no-op for the container in default mode**. The container keeps running. The next Hermes process attaches to it in milliseconds via the label probe. This is the behavior the "one long-lived container shared across sessions" contract requires: it's the only way background processes (npm watchers, dev servers, long-running pytest) survive across sessions. **The container is only torn down (stopped and `docker rm -f`'d) in these cases:** | Trigger | When it fires | |---|---| | `docker_persist_across_processes: false` | Explicit per-process isolation. Every `cleanup()` does `stop` + `rm -f`. Matches pre-issue-#20561 behavior. | | Idle reaper (`lifetime_seconds`, default 300s) | Only when the env is `persist_across_processes=false`. Persist-mode envs are no-op'd; container survives the idle sweep. | | Orphan reaper at next startup | Sweeps **Exited** hermes-labeled containers older than `2 × lifetime_seconds` (default 600s = 10 min), scoped to the current profile. **Running containers are never touched** — sibling-process safety. Set `docker_orphan_reaper: false` to disable. | | Direct user action | `docker rm -f`, `docker system prune`, Docker Desktop restart. We don't set `--restart=always`, so a host reboot leaves the container `Exited` (its CoW layer survives and gets reused on next startup, but bg processes are gone). | Edge cases worth knowing: - **OOM kill of in-container PID 1** transitions the container to `Exited`. Next reuse will `docker start` it; filesystem state survives, bg processes do not. - **Switching profiles** isolates containers from each other — a container labeled `hermes-profile=work` is invisible to a Hermes process running under `hermes-profile=research`. The orphan reaper is profile-scoped too, so cross-profile containers don't get reaped accidentally, but they also won't get cleaned up automatically until you start Hermes again under their original profile. - **Explicit cross-profile sharing** — set the same non-empty `docker_shared_container_key` under `terminal:` for profiles that intentionally collaborate in one trusted workspace. This replaces only their container identity label; task, egress, and network compatibility checks still apply. Profiles without the key remain isolated. The identity label is derived from the key with a short digest suffix, so similar-looking keys (`team/workspace` vs `team_workspace`) never collide into one container. **Important: a shared container is created once, by whichever profile starts it first** — that profile's `docker_image`, volumes, shm size, and other immutable Docker settings win, and later profiles attach to it as-is; differing settings in their configs are ignored until the container is removed and recreated. Profiles sharing a key should agree on image and mounts. Parallel subagents spawned via `delegate_task(tasks=[...])` share this one container — concurrent `cd`, env mutations, and writes to the same path will collide. If a subagent needs an isolated sandbox, it must register a per-task image override via `register_task_env_overrides()`, which RL and benchmark environments (TerminalBench2, HermesSweEnv, etc.) do automatically for their per-task Docker images. **Security hardening:** - `--cap-drop ALL` with only `DAC_OVERRIDE`, `CHOWN`, `FOWNER` added back - `--security-opt no-new-privileges` - `--pids-limit 256` - Size-limited tmpfs for `/tmp` (512MB), `/var/tmp` (256MB), `/run` (64MB) **Credential forwarding:** Env vars listed in `docker_forward_env` are resolved from your shell environment first, then `~/.hermes/.env`. Skills can also declare `required_environment_variables` which are merged automatically. #### Environment variable overrides Every key under `terminal:` has an env-var override of the form `TERMINAL_`. The most useful ones for the Docker backend: | Env var | Maps to | Notes | |---|---|---| | `TERMINAL_DOCKER_IMAGE` | `docker_image` | Base image | | `TERMINAL_DOCKER_FORWARD_ENV` | `docker_forward_env` | JSON array: `'["GITHUB_TOKEN","OPENAI_API_KEY"]'` | | `TERMINAL_DOCKER_ENV` | `docker_env` | JSON dict: `'{"DEBUG":"1"}'` | | `TERMINAL_DOCKER_VOLUMES` | `docker_volumes` | JSON array of `"host:container[:ro]"` strings | | `TERMINAL_DOCKER_EXTRA_ARGS` | `docker_extra_args` | JSON array | | `TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE` | `docker_mount_cwd_to_workspace` | `true` / `false` | | `TERMINAL_DOCKER_RUN_AS_HOST_USER` | `docker_run_as_host_user` | `true` / `false` | | `TERMINAL_DOCKER_NETWORK` | `docker_network` | `true` / `false` — default `true`; `false` = `--network=none` | | `TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES` | `docker_persist_across_processes` | `true` / `false` — default `true` | | `TERMINAL_DOCKER_SHARED_CONTAINER_KEY` | `docker_shared_container_key` | Explicit shared identity for trusted profiles; empty by default | | `TERMINAL_DOCKER_ORPHAN_REAPER` | `docker_orphan_reaper` | `true` / `false` — default `true` | | `TERMINAL_CONTAINER_CPU` | `container_cpu` | CPU cores | | `TERMINAL_CONTAINER_MEMORY` | `container_memory` | MB | | `TERMINAL_CONTAINER_DISK` | `container_disk` | MB | | `TERMINAL_CONTAINER_PERSISTENT` | `container_persistent` | `true` / `false` — controls the bind-mount workspace dirs, distinct from `docker_persist_across_processes` | | `TERMINAL_LIFETIME_SECONDS` | `lifetime_seconds` | Idle reaper window | | `TERMINAL_TEMP_DIR` | `temp_dir` | Session temp root (local backend) | | `TERMINAL_TIMEOUT` | `timeout` | Per-command timeout | | `HERMES_DOCKER_BINARY` | _none_ | Force a specific docker/podman binary path | ### SSH Backend Runs commands on a remote server over SSH. Uses ControlMaster for connection reuse (5-minute idle keepalive). Persistent shell is enabled by default — state (cwd, env vars) survives across commands. ```yaml terminal: backend: ssh persistent_shell: true # Keep a long-lived bash session (default: true) ``` **Required environment variables:** ```bash TERMINAL_SSH_HOST=my-server.example.com TERMINAL_SSH_USER=ubuntu ``` **Optional:** | Variable | Default | Description | |----------|---------|-------------| | `TERMINAL_SSH_PORT` | `22` | SSH port | | `TERMINAL_SSH_KEY` | (system default) | Path to SSH private key | | `TERMINAL_SSH_PERSISTENT` | `true` | Enable persistent shell | **How it works:** Connects at init time with `BatchMode=yes` and `StrictHostKeyChecking=accept-new`. Persistent shell keeps a single `bash -l` process alive on the remote host, communicating via temporary files. Commands that need `stdin_data` or `sudo` automatically fall back to one-shot mode. ### Modal Backend Runs commands in a [Modal](https://modal.com) cloud sandbox. Each task gets an isolated VM with configurable CPU, memory, and disk. Filesystem can be snapshot/restored across sessions. ```yaml terminal: backend: modal container_cpu: 1 # CPU cores container_memory: 5120 # MB (5GB) container_disk: 51200 # MB (50GB) container_persistent: true # Snapshot/restore filesystem ``` **Required:** Either `MODAL_TOKEN_ID` + `MODAL_TOKEN_SECRET` environment variables, or a `~/.modal.toml` config file. **Persistence:** When enabled, the sandbox filesystem is snapshotted on cleanup and restored on next session. Snapshots are tracked in `~/.hermes/modal_snapshots.json`. This preserves filesystem state, not live processes, PID space, or background jobs. **Credential files:** Automatically mounted from `~/.hermes/` (OAuth tokens, etc.) and synced before each command. ### Daytona Backend Runs commands in a [Daytona](https://daytona.io) managed workspace. Supports stop/resume for persistence. ```yaml terminal: backend: daytona container_cpu: 1 # CPU cores container_memory: 5120 # MB → converted to GiB container_disk: 10240 # MB → converted to GiB (max 10 GiB) container_persistent: true # Stop/resume instead of delete ``` **Required:** `DAYTONA_API_KEY` environment variable. **Persistence:** When enabled, sandboxes are stopped (not deleted) on cleanup and resumed on next session. Sandbox names follow the pattern `hermes-{task_id}`. **Disk limit:** Daytona enforces a 10 GiB maximum. Requests above this are capped with a warning. ### Vercel Sandbox Backend Runs commands in a [Vercel Sandbox](https://vercel.com/docs/vercel-sandbox) cloud microVM. Hermes uses the normal terminal and file tool surfaces; there are no Vercel-specific model-facing tools. ```yaml terminal: backend: vercel_sandbox vercel_runtime: node24 # node24 | node22 | python3.13 cwd: /vercel/sandbox # default workspace root container_persistent: true # Snapshot/restore filesystem container_disk: 51200 # Shared default only; custom disk is unsupported ``` **Required install:** Install the optional SDK extra: ```bash pip install 'hermes-agent[vercel]' ``` **Required authentication:** Configure access-token auth with all three of `VERCEL_TOKEN`, `VERCEL_PROJECT_ID`, and `VERCEL_TEAM_ID`. This is the supported setup for deployments and normal long-running Hermes processes on Render, Railway, Docker, and similar hosts. For one-off local development, Hermes also accepts short-lived Vercel OIDC tokens: ```bash VERCEL_OIDC_TOKEN="$(vc project token )" hermes chat ``` From a linked Vercel project directory, you can omit the project name: ```bash VERCEL_OIDC_TOKEN="$(vc project token)" hermes chat ``` OIDC tokens are short-lived and should not be used as the documented deployment path. **Runtime:** `terminal.vercel_runtime` supports `node24`, `node22`, and `python3.13`. If unset, Hermes defaults to `node24`. **Persistence:** When `container_persistent: true`, Hermes snapshots the sandbox filesystem during cleanup and restores a later sandbox for the same task from that snapshot. Snapshot contents can include Hermes-synced credentials, skills, and cache files that were copied into the sandbox. This preserves filesystem state only; it does not preserve live sandbox identity, PID space, shell state, or running background processes. **Background commands:** `terminal(background=true)` uses Hermes' generic non-local background process flow. You can spawn, poll, wait, view logs, and kill processes through the normal process tool while the sandbox is alive. Hermes does not provide native Vercel detached-process recovery after cleanup or restart. **Disk sizing:** Vercel Sandbox does not currently support Hermes' `container_disk` resource knob. Leave `container_disk` unset or at the shared default `51200`; non-default values fail diagnostics and backend creation instead of being silently ignored. ### Singularity/Apptainer Backend Runs commands in a [Singularity/Apptainer](https://apptainer.org) container. Designed for HPC clusters and shared machines where Docker isn't available. ```yaml terminal: backend: singularity singularity_image: "docker://nikolaik/python-nodejs:python3.11-nodejs20" container_cpu: 1 # CPU cores container_memory: 5120 # MB container_persistent: true # Writable overlay persists across sessions ``` **Requirements:** `apptainer` or `singularity` binary in `$PATH`. **Image handling:** Docker URLs (`docker://...`) are automatically converted to SIF files and cached. Existing `.sif` files are used directly. **Scratch directory:** Resolved in order: `TERMINAL_SCRATCH_DIR` → `TERMINAL_SANDBOX_DIR/singularity` → `/scratch/$USER/hermes-agent` (HPC convention) → `~/.hermes/sandboxes/singularity`. **Isolation:** Uses `--containall --no-home` for full namespace isolation without mounting the host home directory. ### Common Terminal Backend Issues If terminal commands fail immediately or the terminal tool is reported as disabled: - **Local** — No special requirements. The safest default when getting started. - **Docker** — Run `docker version` to verify Docker is working. If it fails, fix Docker or `hermes config set terminal.backend local`. - **SSH** — Both `TERMINAL_SSH_HOST` and `TERMINAL_SSH_USER` must be set. Hermes logs a clear error if either is missing. - **Modal** — Needs `MODAL_TOKEN_ID` env var or `~/.modal.toml`. Run `hermes doctor` to check. - **Daytona** — Needs `DAYTONA_API_KEY`. The Daytona SDK handles server URL configuration. - **Singularity** — Needs `apptainer` or `singularity` in `$PATH`. Common on HPC clusters. When in doubt, set `terminal.backend` back to `local` and verify that commands run there first. ### Remote-to-Host State Sync on Teardown For the **SSH**, **Modal**, and **Daytona** backends, Hermes pushes your `~/.hermes/` state (credential files, skills, cache) into the remote sandbox during the session, and on teardown **syncs changed state files back** to their original host locations. Files that differ from what was originally pushed (compared by content hash) are applied back in place; new remote files under a synced directory (e.g. a skill the agent created remotely) are mapped back to the corresponding host path. Upload-only credential files are never overwritten on the host. - The sync-back retries up to 3 times with backoff and refuses to extract remote archives larger than 2 GiB. - Docker and Singularity use bind mounts (live host filesystem view) and don't need this. - This covers Hermes state (`~/.hermes/`), **not** arbitrary working-tree files inside the sandbox — have the agent copy important artifacts out explicitly (e.g. `scp`, `modal volume put`) before the sandbox is destroyed. ### Docker Volume Mounts When using the Docker backend, `docker_volumes` lets you share host directories with the container. Each entry uses standard Docker `-v` syntax: `host_path:container_path[:options]`. ```yaml terminal: backend: docker docker_volumes: - "/home/user/projects:/workspace/projects" # Read-write (default) - "/home/user/datasets:/data:ro" # Read-only - "/home/user/.hermes/cache/documents:/output" # Gateway-visible exports ``` This is useful for: - **Providing files** to the agent (datasets, configs, reference code) - **Receiving files** from the agent (generated code, reports, exports) - **Shared workspaces** where both you and the agent access the same files If you use a messaging gateway and want the agent to send generated files via `MEDIA:/...`, prefer a dedicated host-visible export mount such as `/home/user/.hermes/cache/documents:/output`. - Write files inside Docker to `/output/...` - Emit the **host path** in `MEDIA:`, for example: `MEDIA:/home/user/.hermes/cache/documents/report.txt` - Do **not** emit `/workspace/...` or `/output/...` unless that exact path also exists for the gateway process on the host :::warning YAML duplicate keys silently override earlier ones. If you already have a `docker_volumes:` block, merge new mounts into the same list instead of adding another `docker_volumes:` key later in the file. ::: Can also be set via environment variable: `TERMINAL_DOCKER_VOLUMES='["/host:/container"]'` (JSON array). ### Docker Credential Forwarding By default, Docker terminal sessions do not inherit arbitrary host credentials. If you need a specific token inside the container, add it to `terminal.docker_forward_env`. ```yaml terminal: backend: docker docker_forward_env: - "GITHUB_TOKEN" - "NPM_TOKEN" ``` Hermes resolves each listed variable from your current shell first, then falls back to `~/.hermes/.env` if it was saved with `hermes config set`. :::warning Anything listed in `docker_forward_env` becomes visible to commands run inside the container. Only forward credentials you are comfortable exposing to the terminal session. ::: ### Running the Container as Your Host User By default Docker containers run as `root` (UID 0). Files created inside `/workspace` or other bind-mounts end up owned by root on the host, so after a session you have to `sudo chown` them before you can edit them from your host editor. The `terminal.docker_run_as_host_user` flag fixes this: ```yaml terminal: backend: docker docker_run_as_host_user: true # default: false ``` When enabled, Hermes appends `--user $(id -u):$(id -g)` to the `docker run` command so files written into bind-mounted directories (`/workspace`, `/root`, anything in `docker_volumes`) are owned by your host user, not root. The trade-off: the container can no longer `apt install` or write to root-owned paths like `/root/.npm` — use a base image whose `HOME` is owned by a non-root user (or add your required tooling at image build time) if you need both. Leave this `false` (the default) for backwards-compatible behavior. Turn it on when your workflow is mostly "edit mounted host files" and you're tired of `sudo chown -R`. ### Optional: Mount the Launch Directory into `/workspace` Docker sandboxes stay isolated by default. Hermes does **not** pass your current host working directory into the container unless you explicitly opt in. Enable it in `config.yaml`: ```yaml terminal: backend: docker docker_mount_cwd_to_workspace: true ``` When enabled: - if you launch Hermes from `~/projects/my-app`, that host directory is bind-mounted to `/workspace` - the Docker backend starts in `/workspace` - file tools and terminal commands both see the same mounted project When disabled, `/workspace` stays sandbox-owned unless you explicitly mount something via `docker_volumes`. Security tradeoff: - `false` preserves the sandbox boundary - `true` gives the sandbox direct access to the directory you launched Hermes from Use the opt-in only when you intentionally want the container to work on live host files. ### Persistent Shell By default, each terminal command runs in its own subprocess — working directory, environment variables, and shell variables reset between commands. When **persistent shell** is enabled, a single long-lived bash process is kept alive across `execute()` calls so that state survives between commands. This is most useful for the **SSH backend**, where it also eliminates per-command connection overhead. Persistent shell is **enabled by default for SSH** and disabled for the local backend. ```yaml terminal: persistent_shell: true # default — enables persistent shell for SSH ``` To disable: ```bash hermes config set terminal.persistent_shell false ``` **What persists across commands:** - Working directory (`cd /tmp` sticks for the next command) - Exported environment variables (`export FOO=bar`) - Shell variables (`MY_VAR=hello`) **Precedence:** | Level | Variable | Default | |-------|----------|---------| | Config | `terminal.persistent_shell` | `true` | | SSH override | `TERMINAL_SSH_PERSISTENT` | follows config | | Local override | `TERMINAL_LOCAL_PERSISTENT` | `false` | Per-backend environment variables take highest precedence. If you want persistent shell on the local backend too: ```bash export TERMINAL_LOCAL_PERSISTENT=true ``` :::note Commands that require `stdin_data` or sudo automatically fall back to one-shot mode, since the persistent shell's stdin is already occupied by the IPC protocol. ::: See [Code Execution](features/code-execution.md) and the [Terminal section of the README](features/tools.md) for details on each backend. ## Skill Settings Skills can declare their own configuration settings via their SKILL.md frontmatter. These are non-secret values (paths, preferences, domain settings) stored under the `skills.config` namespace in `config.yaml`. ```yaml skills: config: myplugin: path: ~/myplugin-data # Example — each skill defines its own keys ``` **How skill settings work:** - `hermes config migrate` scans all enabled skills, finds unconfigured settings, and offers to prompt you - `hermes config show` displays all skill settings under "Skill Settings" with the skill they belong to - When a skill loads, its resolved config values are injected into the skill context automatically **Setting values manually:** ```bash hermes config set skills.config.myplugin.path ~/myplugin-data ``` For details on declaring config settings in your own skills, see [Creating Skills — Config Settings](/developer-guide/creating-skills#config-settings-configyaml). ### Guard on agent-created skill writes When the agent uses `skill_manage` to create, edit, patch, or delete a skill, Hermes can optionally scan the new/updated content for dangerous keyword patterns (credential harvesting, obvious prompt injection, exfil instructions). The scanner is **off by default** — real agent workflows that legitimately touch `~/.ssh/` or mention `$OPENAI_API_KEY` were tripping the heuristic too often. Turn it back on if you want the scanner to prompt you before the agent's skill writes land: ```yaml skills: guard_agent_created: true # default: false ``` When on, any flagged `skill_manage` write surfaces as an approval prompt with the scanner's rationale. Accepted writes land; denied writes return an explanatory error to the agent. ### Write approval for skill writes Independent of the content scanner above, `skills.write_approval` gates **every** agent skill write (create / edit / patch / delete / supporting files) behind your explicit approval — the same approve/deny mechanism as dangerous commands: ```yaml skills: write_approval: false # false = write freely (default) | true = stage every write for review ``` When on, skill writes are staged under `~/.hermes/pending/skills/` and reviewed with `/skills pending`, `/skills diff `, `/skills approve `, `/skills reject ` — from the CLI or any messaging platform. Toggle at runtime with `/skills approval on|off`. Memory has the same gate (`memory.write_approval`, below). Full walkthrough: [Gating agent skill writes](/user-guide/features/skills#gating-agent-skill-writes-skillswrite_approval). ## Memory Configuration ```yaml memory: memory_enabled: true user_profile_enabled: true memory_char_limit: 2200 # ~800 tokens user_char_limit: 1375 # ~500 tokens write_approval: false # true = require approval before any memory write ``` With `memory.write_approval: true`, memory writes need your approval before they land: interactive CLI turns prompt inline; messaging sessions and the background self-improvement review stage the write for `/memory pending` → `/memory approve ` / `/memory reject ` review. Toggle at runtime with `/memory approval on|off`. See [Controlling memory writes](/user-guide/features/memory#controlling-memory-writes-write_approval). ## Context File Truncation Controls how much content Hermes loads from each automatic context file before applying head/tail truncation. This applies to files injected into the system prompt such as `SOUL.md`, `.hermes.md`, `AGENTS.md`, `CLAUDE.md`, and `.cursorrules`. It does **not** affect the `read_file` tool. ```yaml context_file_max_chars: null # default — dynamic cap scaled to the model's context window (floor 20K, ceiling 500K chars) ``` Set a positive integer to pin a fixed cap instead of the dynamic behavior: ```yaml context_file_max_chars: 25000 ``` Each context file read is also bounded by `context_file_read_timeout` (seconds, default `5.0`). A file that takes longer to read — typically on a network-backed filesystem such as iCloud Drive, OneDrive or NFS — is skipped with a warning so the rest of the system prompt still loads: ```yaml context_file_read_timeout: 5.0 ``` ## File Read Safety Controls how much content a single `read_file` call can return. Reads that exceed the limit are rejected with an error telling the agent to use `offset` and `limit` for a smaller range. This prevents a single read of a minified JS bundle or large data file from flooding the context window. ```yaml file_read_max_chars: 100000 # default — ~25-35K tokens ``` Raise it if you're on a model with a large context window and frequently read big files. Lower it for small-context models to keep reads efficient: ```yaml # Large context model (200K+) file_read_max_chars: 200000 # Small local model (16K context) file_read_max_chars: 30000 ``` The agent also deduplicates file reads automatically — if the same file region is read twice and the file hasn't changed, a lightweight stub is returned instead of re-sending the content. This resets on context compression so the agent can re-read files after their content is summarized away. ## Tool Output Truncation Limits Three related caps control how much raw output a tool can return before Hermes truncates it: ```yaml tool_output: max_bytes: 50000 # terminal output cap (chars) max_lines: 2000 # read_file pagination cap max_line_length: 2000 # per-line cap in read_file's line-numbered view ``` - **`max_bytes`** — When a `terminal` command produces more than this many characters of combined stdout/stderr, Hermes keeps the first 40% and last 60% and inserts a `[OUTPUT TRUNCATED]` notice between them. Default `50000` (≈12-15K tokens across typical tokenisers). - **`max_lines`** — Upper bound on the `limit` parameter of a single `read_file` call. Requests above this are clamped so a single read can't flood the context window. Default `2000`. - **`max_line_length`** — Per-line cap applied when `read_file` emits the line-numbered view. Lines longer than this are truncated to this many chars followed by `... [truncated]`. Default `2000`. Raise the limits on models with large context windows that can afford more raw output per call. Lower them for small-context models to keep tool results compact: ```yaml # Large context model (200K+) tool_output: max_bytes: 150000 max_lines: 5000 # Small local model (16K context) tool_output: max_bytes: 20000 max_lines: 500 ``` ### Tool-Result Spillover Budget Separately from truncation, oversized tool *results* are spilled to disk rather than cut: the full output is saved under `$HERMES_HOME/cache/spillover/` and the in-context content is replaced by a preview plus the saved file's path (readable with `read_file` using `offset`/`limit`, or processable with `execute_code`). The generic per-result spillover threshold is 100,000 chars, scaled down automatically for small-context models. MCP tool results (tools named `mcp_*`) spill at a tighter **50,000-char** default: MCP servers routinely return large un-paginated payloads (tool-discovery catalogs, batched executions) that would otherwise sit under the generic threshold and bloat context on every subsequent turn. Nothing is lost — the full result is preserved on disk. Override the threshold via: ```yaml tool_budget: mcp_result_size_chars: 50000 # per-result spillover threshold for mcp_* tools ``` The MCP threshold is always capped at the (possibly context-scaled) generic per-result threshold, so raising it cannot exceed what the active model's window allows. Hermes also flags **provider-side elision**: when an MCP or web tool result embeds its own truncation markers (`...N more items`, `"has_more": true`, "saved to sandbox" notes), a one-line notice is appended to the result warning that the visible data is incomplete and should be paged/fetched before treating any enumeration as complete. ## Global Toolset Disable To suppress specific toolsets across the CLI and every gateway platform in one place, list their names under `agent.disabled_toolsets`: ```yaml agent: disabled_toolsets: - memory # hide memory tools + MEMORY_GUIDANCE injection - web # no web_search / web_extract anywhere ``` This applies **after** per-platform tool config (`platform_toolsets` written by `hermes tools`), so a toolset listed here is always removed — even if a platform's saved config still lists it. Use this when you want a single switch for "turn X off everywhere" rather than editing 15+ platform rows in the `hermes tools` UI. Leaving the list empty, or omitting the key, is a no-op. ## Git Worktree Isolation Enable isolated git worktrees for running multiple agents in parallel on the same repo: ```yaml worktree: true # Always create a worktree (same as hermes -w) # worktree: false # Default — only when -w flag is passed ``` When enabled, each CLI session creates a fresh worktree under `.worktrees/` with its own branch. Agents can edit files, commit, push, and create PRs without interfering with each other. Clean worktrees are removed on exit; dirty ones are kept for manual recovery. By default the new worktree branches from the **freshly-fetched remote tip** (the current branch's upstream, otherwise the remote's default branch) so it starts current with the project rather than from the local clone's possibly-stale `HEAD`. This keeps a PR's diff scoped to the actual change instead of inheriting whatever the local clone was behind by. Set `worktree_sync: false` to branch from local `HEAD` instead — useful offline, or when you deliberately want the clone's exact current state as the base. If the remote can't be reached, it falls back to local `HEAD` automatically. ```yaml worktree_sync: true # Default — branch from the fetched remote tip # worktree_sync: false # Branch from local HEAD (offline / pinned base) ``` You can also list gitignored files to copy into worktrees via `.worktreeinclude` in your repo root: ``` # .worktreeinclude .env .venv/ node_modules/ ``` ## Context Compression Hermes automatically compresses long conversations to stay within your model's context window. The compression summarizer is a separate LLM call — you can point it at any provider or endpoint. All compression settings live in `config.yaml` (no environment variables). ### Full reference ```yaml compression: enabled: true # Toggle compression on/off progress_notices: false # Opt-in: deliver routine compression progress notices to chat platforms — see below threshold: 0.50 # Compress at this % of context limit threshold_tokens: null # Absolute token cap (optional) — takes lower of ratio vs absolute target_ratio: 0.20 # Fraction of threshold to preserve as recent tail tail_mode: lean # Tail retention: "lean" (default — clamped 2.5% tail, 10K-25K, with a detailed session log + anchor index + session_search recovery pointers in the summary, all from ONE auxiliary summarizer call; ~3x fewer retained tokens after compaction) or "legacy" (0.20×threshold verbatim tail) protect_last_n: 20 # Min recent messages to keep uncompressed protect_first_n: 3 # Non-system head messages pinned across compactions (0 = pin nothing) in_place: true # Compact on the same session id (no rotation) — see below idle_compact_after_seconds: 0 # Opt-in idle compaction (0 = disabled) — see below hygiene_hard_message_limit: 5000 # Gateway safety valve — see below hygiene_timeout_seconds: 30 # Max seconds of NO summary-model output before hygiene compression is cut off hygiene_total_ceiling_seconds: 600 # Absolute cap on the hygiene wait even while tokens are still streaming hygiene_max_turn_hold_seconds: 10 # Max wall-clock the incoming turn waits on hygiene compression before proceeding uncompressed — see below hygiene_failure_cooldown_seconds: 300 # First rung of the per-session hygiene-failure backoff (x1/x3/x9, capped at 1h) context_timeout_seconds: 120 # Inactivity budget for in-agent compress_context (loop /compress / preflight) — see below context_total_ceiling_seconds: 600 # Absolute cap on the *pre-commit* in-agent compress_context wait even while tokens are still streaming (an already-started SessionDB commit is never abandoned; overruns are logged + surfaced) proactive_prune_tokens: 0 # Opt-in tokens trigger for the no-LLM tool-result prune (0 = off; see below) proactive_prune_min_result_chars: 8000 # Prune's summarize pass only touches tool results larger than this (clamped >= 200) proactive_prune_min_reclaim_tokens: 4096 # Prune only commits when it reclaims at least this many tokens (0 = commit any) # The summarization model/provider is configured under auxiliary: auxiliary: compression: model: "" # Empty = use main chat model. Override with e.g. "google/gemini-3-flash-preview" for cheaper/faster compression. provider: "auto" # Provider: "auto", "openrouter", "nous", "codex", "main", etc. base_url: null # Custom OpenAI-compatible endpoint (overrides provider) ``` :::info Legacy config migration Older configs with `compression.summary_model`, `compression.summary_provider`, and `compression.summary_base_url` are automatically migrated to `auxiliary.compression.*` on first load (config version 17). No manual action needed. ::: `progress_notices` (default `false`) controls whether **routine** compression progress statuses reach chat platforms (Telegram, Discord, Slack, etc.). By design, automatic compression is silent on chat surfaces — it runs in the background with server-side logging only. Set `progress_notices: true` to opt into seeing the routine lifecycle on chat platforms: the "Compacting context…" start notice, preflight/pre-API compression triggers, idle compaction, retry progress ("Compressed 30 → 12 messages, retrying…"), and the "Context compaction complete" notice. The gate is scoped to compression statuses only — unrelated operational noise (auxiliary model failures, provider rate-limit/retry chatter) stays suppressed either way. Compression **failure** notices and manual `/compress` feedback are always visible regardless of this setting. Editing this value on a running gateway takes effect on the next message. `hygiene_hard_message_limit` is a gateway-only **pre-compression safety valve**. It exists to break a death spiral: when API calls keep disconnecting on an oversized session, the gateway never receives token-usage data, so the token-based threshold can't fire, so the transcript keeps growing and disconnects get worse. This count-based floor fires on message count alone (always known, regardless of API failures) to force compression and recover the session. Default `5000` — far above any normal session, including large-context (1M+) models doing thousands of short turns, which compress on the token threshold long before this. Raise it further for unusual platforms, lower it to force more aggressive compression. Editing this value on a running gateway takes effect on the next message (see below). `hygiene_timeout_seconds` is the gateway's **inactivity budget** for this pre-agent compression pass — not a total wall-clock cap. The compression summary call streams from the model, and each arriving token counts as forward progress: a slow reasoning model that is still generating keeps extending its own deadline, so slow-but-healthy summary models are never cut off mid-generation. Only when the summary model produces **no output** for this many seconds (backend down, hung connection, silent provider) does the gateway warn the user, continue the incoming message without compression, and record a temporary per-session failure cooldown instead of appearing stuck. `hygiene_total_ceiling_seconds` (default `600`) bounds the total wait even while tokens are still moving, so a degenerate trickle stream can't hold a turn hostage indefinitely. It is clamped to at least `hygiene_timeout_seconds`. `hygiene_max_turn_hold_seconds` (default `10`) is the gateway's **turn-hold budget** — the maximum wall-clock the incoming message is held waiting on hygiene compression before the gateway stops waiting and proceeds on the uncompressed transcript. It exists because `hygiene_total_ceiling_seconds` alone can leave the wire silent for far longer than a chat transport's idle-timeout: a summary model that keeps streaming tokens keeps resetting the inactivity slice, so without a turn-hold budget the wait can stretch toward the ceiling while zero bytes reach the user — Telegram (and similar transports) then drop the connection and the turn appears frozen. Capping the turn's wait at this budget (well under the typical ~30s transport idle-timeout) guarantees the message is answered promptly. **The compression is not lost when the budget expires**: the worker keeps running detached and — when its commit is watermark-fenced (the normal case with a session DB) — it keeps its commit admission, so the finished summary is adopted at the next safe boundary and turns appended after the wait was abandoned survive verbatim as concurrent tail. This matters especially for **thinking/reasoning summary models** (DeepSeek, QwQ, etc.) whose reasoning phase alone can exceed the budget: their summaries land one turn late instead of never. If the commit cannot be safely fenced, the late result is discarded (`CompressionCommitFence`) and it cannot overwrite newer turns. Raise the budget if you'd rather have compression apply within the same turn and your transport tolerates the wait; lower it for snappier recovery on very slow backends. `hygiene_failure_cooldown_seconds` controls that per-session cooldown after a hygiene compression timeout or abort. During the cooldown, the gateway skips repeated hygiene attempts for the same oversized session so every incoming message does not block on the same broken auxiliary backend. `/compress`, `/reset`, or a healthy later turn can still recover the session. The value is the **first rung** of an escalating ladder, not a fixed interval: consecutive failures for the same session wait `1x`, `3x`, then `9x` this value, capped at one hour. A session whose summary model is permanently broken therefore backs off instead of retrying forever on a fixed interval, and a run that actually shrinks the transcript resets it to the first rung. Escalation is per-session and process-local — a gateway restart resets it to the first rung while the cooldown deadline itself survives. `context_timeout_seconds` (default `120`) is the same **inactivity budget** for in-agent `compress_context` — the conversation loop, preflight compaction, and manual `/compress` — so a hung summary model cannot stall a session indefinitely. Streamed summary tokens extend the wait; only a silent worker is cut off. On timeout Hermes retries the summary once against the first entry of `auxiliary.compression.fallback_chain` (using that entry's own `timeout` when it declares one) — a stalled route never raises, so the auxiliary client's own fallback handling cannot see it. Only if that attempt also fails, or no fallback chain is configured, does Hermes skip compaction, keep the existing messages, and warn the user. Set to `0` to disable. Gateway session hygiene keeps its own `hygiene_timeout_seconds` path and is not double-wrapped. `context_total_ceiling_seconds` (default `600`) bounds the in-agent **pre-commit** wait (summary / stream phase) even while tokens are still moving. It is clamped to at least `context_timeout_seconds`. The exact guarantee: **the summary phase is bounded by this ceiling; the commit phase is logged and surfaced if it exceeds it.** Once the worker has entered the compression commit fence and SessionDB mutation is in flight, the commit is never abandoned mid-flight — that would risk transcript divergence — but the wait is no longer silent: if the commit runs past the ceiling, Hermes logs the overrun (WARNING, escalating to ERROR on repeat), sends a one-shot warning through the user-visible warning channel, and keeps waiting in bounded increments until the commit completes. When the ceiling expires during the summary phase, the summary model's stream is closed at that same instant on every auxiliary wire (chat.completions, Codex Responses, Anthropic Messages) — an abandoned summary is not billed to completion on a connection nobody is waiting for, and its session lease is freed for the next attempt. `protect_first_n` controls how many **non-system** head messages are pinned across every compaction. Default `3` — the opening user/assistant exchange survives every summarizer pass so the original goal stays visible. On long-running rolling-compaction sessions where the opening turn is no longer relevant, set `protect_first_n: 0` to pin nothing but the system prompt + summary + tail. The system prompt itself is always preserved regardless of this setting. `in_place` (default `true`) controls what happens to the session identity when compaction fires. When `true`, compaction rewrites the message list and rebuilds the system prompt **without rotating the session id** — the conversation keeps one durable id for its whole life (no `parent_session_id` chain, no `name #2` / `#3` renumbering in session lists). Compaction is non-destructive: the live context is compacted, but the pre-compaction turns are soft-archived under the same id (marked inactive/compacted) — still searchable via `session_search` and recoverable, not deleted. Hooks see the mode via the `in_place` field on the `session:compress` event. Set `in_place: false` to restore the legacy behavior where each compaction rotates to a new session id linked to the old one. `threshold_tokens` sets an optional **absolute token cap** for the compression trigger. When set, compression fires at the lower of the ratio-based `threshold` and this absolute count — so compression never fires later than the user's preferred token number regardless of which model is active. This solves the problem where switching between models with different context windows (e.g. 1M → 400K) shifts the absolute trigger point. The cap is clamped to the model's context length, so setting it higher than the model supports is safe — the ratio-based threshold is used instead. Default `null` (disabled — ratio-based threshold only). The cap survives model switches and fallback activations. `idle_compact_after_seconds` is an **opt-in, time-based** trigger that complements the size-based `threshold`. Default `0` (disabled). When set above 0, a session that resumes after at least that many seconds of inactivity compacts its accumulated history up front, before the first reply — so a long-lived thread (e.g. a Telegram conversation you come back to hours later) doesn't re-read its full stale context on every subsequent turn. It never fires when the context is already at or below the post-compression target (`threshold × target_ratio`), and it honors the same failure-cooldown, anti-thrash, and per-session lock guards as every automatic compaction. Example: `idle_compact_after_seconds: 1800` compacts after 30 minutes idle. `proactive_prune_tokens` enables a deterministic, no-LLM prune of old tool-result payloads that runs independently of `threshold`. On large-window models the `threshold` compaction (≈50% of the window) rarely fires, so bulky tool outputs (terminal dumps, file reads, web extracts) ride along in history and get re-sent on every subsequent turn. When re-sent history exceeds `proactive_prune_tokens` (default `0` = off; try `48000` to enable), the prune dedupes identical results, summarizes older oversized ones, and truncates large tool-call arguments — protecting the most recent `protect_last_n` messages and never calling the model. Full outputs stay recoverable from the session store. `proactive_prune_min_result_chars` (default `8000`, clamped to ≥ 200) sets the size below which a tool result is left untouched. `proactive_prune_min_reclaim_tokens` (default `4096`) prevents a prune from committing unless it reclaims at least that many tokens — a committed prune rewrites already-sent history and invalidates the provider's prompt-cache prefix, so this gate keeps those cache breaks episodic and amortized (one meaningful break, like a compression boundary) instead of firing on every tool iteration. This runs only under the built-in `compressor` engine; other context engines inherit a no-op. :::tip Gateway hot-reload of compression and context length As of recent releases, editing `model.context_length` or any `compression.*` key in `config.yaml` on a running gateway takes effect on the next message — no gateway restart, no `/reset`, no session rotation required. The cached-agent signature includes these keys, so the gateway transparently rebuilds the agent when it sees a change. API keys and tool/skill config still require the usual reload paths. ::: ### Common setups **Default (auto-detect) — no configuration needed:** ```yaml compression: enabled: true threshold: 0.50 ``` Uses your main provider and main model. Override per-task (e.g. `auxiliary.compression.provider: openrouter` + `model: google/gemini-2.5-flash`) if you want compression on a cheaper model than your main chat model. **Force a specific provider** (OAuth or API-key based): ```yaml auxiliary: compression: provider: nous model: gemini-3-flash ``` Works with any provider: `nous`, `openrouter`, `codex`, `anthropic`, `main`, etc. **Custom endpoint** (self-hosted, Ollama, zai, DeepSeek, etc.): ```yaml auxiliary: compression: model: glm-4.7 base_url: https://api.z.ai/api/coding/paas/v4 ``` Points at a custom OpenAI-compatible endpoint. Uses `OPENAI_API_KEY` for auth. ### How the three knobs interact | `auxiliary.compression.provider` | `auxiliary.compression.base_url` | Result | |---------------------|---------------------|--------| | `auto` (default) | not set | Auto-detect best available provider | | `nous` / `openrouter` / etc. | not set | Force that provider, use its auth | | any | set | Use the custom endpoint directly (provider ignored) | :::warning Summary model context length requirement The summary model **must** have a context window at least as large as your main agent model's. The compressor sends the full middle section of the conversation to the summary model — if that model's context window is smaller than the main model's, the summarization call will fail with a context length error. When this happens, the middle turns are **dropped without a summary**, losing conversation context silently. If you override the model, verify its context length meets or exceeds your main model's. ::: ## Gateway Turn Lease Timeout The gateway serializes turns by their resolved session ID so two routing keys cannot load and write the same transcript concurrently. Configure the maximum lease wait independently of the ordinary agent inactivity timeout: ```yaml agent: gateway_turn_lease_timeout: 5 ``` If another turn still holds the session lease when this budget expires, Hermes fails closed: it does not load the transcript or run the model for the waiting message. The user receives a rejection notice and must resend. Hermes does not automatically requeue the message because doing so without durable ordering and idempotency could process it twice. Non-positive values use the 5-second default. ## Session Stall Watchdog The gateway runs a notify-only stall watchdog (`agent.session_stall_timeout`, default `300` seconds, `0` = disabled). When a busy session has a **pending inbound follow-up** and the agent's shared activity clock has been idle for at least this long, the gateway logs a WARNING and sends the user a one-shot notification: ``` ⚠️ Agent session appears stalled (last activity N min ago). Try /new to reset. ``` Semantics: - **Notify-only.** The watchdog never kills the turn — contrast `agent.gateway_timeout`, which cancels a run after prolonged inactivity. The stall notice just tells you the agent looks wedged so you can decide (`/new`, `/stop`, or keep waiting). - **One notification per stall episode.** The latch clears when the pending inbound drains or activity resumes, so a session that recovers and stalls again notifies again. - Progress comes only from the shared activity snapshot (tool calls, API stream progress, compression heartbeats). Pending inbound is a notify gate, not a progress clock. ```yaml agent: session_stall_timeout: 300 # seconds; 0 disables the watchdog ``` ## Reconnect Attention Escalation When a platform adapter fails to connect (network outage, revoked bot token, broken sidecar), the gateway retries it indefinitely with capped exponential backoff — retries never stop, so a transient outage always self-heals without operator action. The downside is that a *permanent* failure (a revoked Telegram token, missing Discord privileged intents) looks identical to a blip: "retrying", forever. Two mechanisms make permanent failures visible: - **Terminal classification.** Failures whose exception *type* proves they can never self-heal — rejected/revoked tokens (`telegram_auth_error`, `discord_auth_error`, `email_auth_error`), missing privileged intents (`discord_intents_required`), a Photon sidecar whose dependencies cannot install (`SIDECAR_DEPS_MISSING`) or whose node binary is missing (`SIDECAR_NODE_MISSING`) — are marked fatal instead of entering the retry queue. Classification is strictly type-based; ambiguous errors always keep retrying. - **Needs-attention escalation.** A platform continuously in the retry queue past `agent.reconnect_attention_after` (default `7200` seconds = 2 hours, `0` disables) gets `needs_attention: true` and a `retrying_since` timestamp in gateway runtime status (`hermes status`), plus a WARNING log. Retries continue unchanged — this is a signal, not a circuit breaker. The flag clears on successful reconnect. ```yaml agent: reconnect_attention_after: 7200 # seconds; 0 disables the escalation flag ``` ## Gateway Agent Cache The gateway keeps one agent per session so a conversation reuses its cached prompt prefix instead of rebuilding the system prompt every turn. That cached agent also holds the session's full transcript — tool output included, which is tens of megabytes on a session with a hundred tool calls. On a busy multi-platform gateway the cache is therefore the largest single consumer of memory in the process. ```yaml agent: agent_cache: max_size: 128 # LRU entry cap idle_ttl_secs: 3600 # evict an agent idle this long memory_high_mb: auto # anon-RSS budget; number, "auto", or 0/off max_evictions_per_pass: 16 protect_recent: 8 ``` `max_size` and `idle_ttl_secs` bound the cache by count and by time. Neither knows how many bytes it holds, so `memory_high_mb` adds a third bound: once the gateway's own anonymous resident memory crosses the budget, it sheds least-recently-used transcripts, which reload from the stored session on the next turn. Lower it if the gateway is competing for memory with other services; raise it (or set `0` to switch the pass off) if you would rather keep every prefix warm. `auto` derives the budget from the memory limit the gateway actually runs under — the cgroup limit for a container or systemd unit, total RAM otherwise — so a `MemoryMax`/`MemoryHigh` on the unit is respected without a second number to keep in sync. Sessions that are mid-turn, the `protect_recent` most recently used ones, and any session whose transcript has not finished being written to disk are never shed. Eviction is logged at WARNING with the measured RSS and the sessions dropped: ``` Agent cache pressure: anon RSS 6802MB over budget 6656MB — evicting 5 LRU session(s): ... ``` ## Context Engine The context engine controls how conversations are managed when approaching the model's token limit. The built-in `compressor` engine uses lossy summarization (see [Context Compression](/developer-guide/context-compression-and-caching)). Plugin engines can replace it with alternative strategies. ```yaml context: engine: "compressor" # default — built-in lossy summarization ``` To use a plugin engine (e.g., LCM for lossless context management): ```yaml context: engine: "lcm" # must match the plugin's name ``` Plugin engines are **never auto-activated** — you must explicitly set `context.engine` to the plugin name. Available engines can be browsed and selected via `hermes plugins` → Provider Plugins → Context Engine. See [Memory Providers](/user-guide/features/memory-providers) for the analogous single-select system for memory plugins. ## Iteration Budget When the agent is working on a complex task with many tool calls, it can burn through its iteration budget (default: 500 turns). Hermes does **not** inject mid-task pressure warnings — earlier builds warned the model at 70%/90% budget, which caused models to abandon complex tasks prematurely and was removed in April 2026. Instead, when the budget is actually exhausted (500/500), Hermes injects one message asking the model to wrap up and allows a single **grace call** so it can deliver a final response. If that grace call still doesn't produce text, the agent is asked to summarise what it accomplished. ```yaml agent: max_turns: none # Iterations per conversation turn (default: none = unlimited) # Set a positive integer to cap; "none"/"null"/ # "unlimited"/"inf"/"infinity"/"infinite"/0/-1 = no limit api_max_retries: 3 # Retries per provider before fallback engages (default: 3) ``` `agent.max_turns` is **unlimited by default** — the turn cap caused more problems than it solved (silent mid-task truncation), so out of the box Hermes runs a conversation turn to completion. To impose a cap, set a positive integer. To be explicit about "no limit", any of these case-insensitive spellings work: `"none"`, `"null"`, `"unlimited"`, `"infinite"`, `"infinity"`, `"inf"`, `0`, `-1` (they resolve to a `sys.maxsize` sentinel so the loop never exits on a turn count). `agent.api_max_retries` controls how many times Hermes retries a provider API call on transient errors (rate limits, connection drops, 5xx) **before** fallback-provider switching engages. The default is `3` — four attempts total. If you have [fallback providers](/user-guide/features/fallback-providers) configured and want to fail over faster, drop this to `0` so the first transient error on your primary immediately hands off to the fallback instead of churning retries against the flaky endpoint. ## Wall-Clock Run Budget Separate from the iteration budget, you can give each conversation run an optional **wall-clock** budget. This is designed for one-shot and eval-harness invocations that run under a hard external ceiling (e.g. a 900-second per-task limit): without it, a run can time out with the work essentially done — one generation short of emitting the final answer, or stuck in a single hung provider call. ```yaml agent: run_budget_seconds: null # Optional; unset/null = feature fully off (default) ``` Or per-invocation via the CLI: ```bash hermes chat --run-budget 850 -q "..." ``` When a budget is set, two things happen: 1. **Wrap-up notice at 80%.** When 80% of the budget has elapsed, Hermes injects a **one-time** notice (delivered cache-safely, appended to the newest tool result like `/steer` messages) telling the model to stop new discovery/verification work and produce the final deliverable from the state it already has. It fires at most once per run and mirrors the existing iteration-budget wrap-up mechanism — there are no repeated pressure warnings. 2. **Deadline-scaled stale timeouts.** Implicit non-streaming stale timeouts (the 90s default and the reasoning-model floors, e.g. 600s for DeepSeek reasoning models) are capped at `max(60, remaining_budget × 0.5)` so a single silently-hung provider call can never consume the rest of the run. The cap only ever *tightens* the timeout — it never raises it — and an explicitly configured `stale_timeout_seconds` (provider/model config or `HERMES_API_CALL_STALE_TIMEOUT`) always wins untouched. The budget is per `run_conversation` turn (it resets on each user message) and the feature is completely dormant when unset — no clock reads, no injection, no timeout changes. ## Verify-on-Stop (coding verification) When enabled, Hermes refuses to accept a final answer on a turn where the agent edited code in a workspace but produced no fresh verification evidence (a passing test run, build, lint, etc.) — it injects a synthetic follow-up asking the agent to verify or explain why it can't. Doc/markdown/skill-only edits never trigger it, and the loop is bounded so it can never trap the agent. ```yaml agent: verify_on_stop: false # true | false | "auto" (surface-aware: on for CLI/TUI/desktop, off for messaging) verify_guidance: true # Append creative-UI / clean-diff guidance to the missing-evidence nudge max_verify_nudges: 3 # Cap on consecutive continue nudges per turn (built-in + pre_verify hooks) coding_instructions: "" # Standing project-wide coding rules appended to the coding brief ``` `verify_on_stop` accepts `true` (on everywhere), `false` (off — the default), or `"auto"` (legacy surface-aware behavior: on for interactive coding surfaces — CLI, TUI, desktop — and programmatic callers; off for messaging surfaces like Telegram/Discord where the verification narrative reads as chat noise). Off is the default everywhere: fresh installs ship `false` and the config migration turned it off on existing installs, so enabling it is an explicit opt-in. The `HERMES_VERIFY_ON_STOP` env var overrides the config value when set. For a user/plugin policy gate at the same point — keep the agent going with your own checks — see the [`pre_verify` hook](/user-guide/features/hooks#pre_verify). ## Standing Goals (`/goal`) When a standing goal is active, Hermes judges whether each assistant response satisfies it. If not, it feeds a continuation prompt back into the same session and keeps working until the goal is done, the turn budget is exhausted, or the user pauses/clears it. The turn budget is the real backstop — judge failures fail **open** (continue) so a flaky judge never wedges progress. ```yaml goals: max_turns: 20 # Max continuation turns before Hermes auto-pauses the goal (default: 20) ``` `max_turns` caps how many continuation turns a goal can drive before Hermes auto-pauses it and asks the user to `/goal resume`. It protects against judge false negatives (goal actually done but judge says continue) and unbounded model spend on fuzzy or unachievable goals. See [Goals](/user-guide/features/goals) for the full feature. ### API Timeouts Hermes has separate timeout layers for streaming, plus a stale detector for non-streaming calls. The stale detectors auto-adjust for local providers only when you leave them at their implicit defaults. | Timeout | Default | Local providers | Config / env | |---------|---------|----------------|--------------| | Socket read timeout | 120s | Auto-raised to 1800s | `HERMES_STREAM_READ_TIMEOUT` | | Stale stream detection | 180s | Raised to a 900s ceiling (`agent.local_stream_stale_timeout`) | `HERMES_STREAM_STALE_TIMEOUT` | | Stale non-stream detection | 90s | Auto-disabled when left implicit | `providers..stale_timeout_seconds` or `HERMES_API_CALL_STALE_TIMEOUT` | | API call (non-streaming) | 1800s | Unchanged | `providers..request_timeout_seconds` / `timeout_seconds` or `HERMES_API_TIMEOUT` | The **socket read timeout** controls how long httpx waits for the next chunk of data from the provider. Local LLMs can take minutes for prefill on large contexts before producing the first token, so Hermes raises this to 30 minutes when it detects a local endpoint. If you explicitly set `HERMES_STREAM_READ_TIMEOUT`, that value is always used regardless of endpoint detection. The **stale stream detection** kills connections that receive SSE keep-alive pings but no actual content. For local providers (which don't send keep-alive pings during prefill) the default is raised to a finite 900-second ceiling instead of the 180s base — configurable via `agent.local_stream_stale_timeout` or the `HERMES_LOCAL_STREAM_STALE_TIMEOUT` env var. The **stale non-stream detection** kills non-streaming calls that produce no response for too long. By default Hermes disables this on local endpoints to avoid false positives during long prefills. If you explicitly set `providers..stale_timeout_seconds`, `providers..models..stale_timeout_seconds`, or `HERMES_API_CALL_STALE_TIMEOUT`, that explicit value is honored even on local endpoints. This budget bounds every non-streaming call. A provider that accepts a request and then goes silent — connection held open, no bytes, no error — is aborted at the stale timeout and retried, rather than hanging until the much longer socket read timeout (or, for an unattended cron run, until something external kills the process). Cron jobs and delegated subagents stream too. They run the request inline on their own thread (the interrupt worker other sessions use wedges inside the gateway's nested thread pools), but the wire request is still `stream: true`, so the **stale stream detection** budget above governs them — every token counts as liveness, so a reasoning model that thinks for minutes is not mistaken for a hung provider, and edge proxies that kill silent connections keep seeing bytes. ### Disabling API streaming `model.streaming: false` forces non-streaming requests for the whole session — parent and subagents alike. It is an escape hatch for self-hosted OpenAI-compatible servers whose *streaming* tool-call path is broken (for example vLLM with `--tool-call-parser qwen3_xml` plus a reasoning parser can leak tool-call markup into plain text and return zero `tool_calls`, so delegated tasks silently no-op). Default is `true`; leave it unless you hit that class of bug, since non-streaming calls lose the liveness properties described above. This is separate from `display.streaming`, which only controls token rendering in the terminal. ```yaml model: streaming: false ``` ## Context Pressure Warnings Separate from iteration budget pressure, context pressure tracks how close the conversation is to the **compaction threshold** — the point where context compression fires to summarize older messages. This helps both you and the agent understand when the conversation is getting long. | Progress | Level | What happens | |----------|-------|-------------| | **≥ 60%** to threshold | Info | CLI shows a cyan progress bar; gateway sends an informational notice | | **≥ 85%** to threshold | Warning | CLI shows a bold yellow bar; gateway warns compaction is imminent | In the CLI, context pressure appears as a progress bar in the tool output feed: ``` ◐ context ████████████░░░░░░░░ 62% to compaction 48k threshold (50%) · approaching compaction ``` On messaging platforms, a plain-text notification is sent: ``` ◐ Context: ████████████░░░░░░░░ 62% to compaction (threshold: 50% of window). ``` If auto-compression is disabled, the warning tells you context may be truncated instead. Context pressure is automatic — no configuration needed. It fires purely as a user-facing notification and does not modify the message stream or inject anything into the model's context. ## Credential Pool Strategies When you have multiple API keys or OAuth tokens for the same provider, configure the rotation strategy: ```yaml credential_pool_strategies: openrouter: round_robin # cycle through keys evenly anthropic: least_used # always pick the least-used key ``` Options: `fill_first` (default), `round_robin`, `least_used`, `random`. See [Credential Pools](/user-guide/features/credential-pools) for full documentation. ## Prompt caching Hermes turns on cross-session prompt caching automatically when the active provider supports it — no user config needed. For Claude on **native Anthropic**, **OpenRouter**, and **Nous Portal**, Hermes attaches `cache_control` breakpoints with the 1-hour TTL (`ttl: "1h"`) on the system prompt and skill blocks. The first send within a fresh hour pays full input rates; subsequent sends across any session within the same hour pull from the cache at the discounted cached-read rate. This means the system prompt, loaded skill content, and the early portion of any long-context include get reused across `hermes` sessions and across forked subagents for the first hour. The Qwen Cloud (Alibaba DashScope) upstream caps cache TTL at 5 minutes, so Hermes uses the 5-minute breakpoint TTL there instead. Other Claude-via-third-party paths (AWS Bedrock, Azure Foundry) fall back to the provider's own caching defaults. xAI Grok uses a separate session-pinned conversation-id mechanism — see [xAI prompt caching](/integrations/providers#xai-grok--responses-api--prompt-caching). No knob exists to disable this — caching is always-on and saves money even on single-turn conversations because the system prompt alone is a meaningful fraction of the input token count. The one explicit knob is the cache TTL tier Hermes requests on Anthropic-style breakpoints: ```yaml prompt_caching: cache_ttl: "5m" # "5m" or "1h" (Anthropic-supported tiers); other values are ignored ``` `cache_ttl` selects the breakpoint TTL Hermes attaches for Claude via the native Anthropic API, OpenRouter, and Nous Portal. Only the two Anthropic-supported tiers (`"5m"`, `"1h"`) are honored — any other value is ignored. Providers with their own caps (e.g. Qwen Cloud, which maxes at 5 minutes) still clamp to what the upstream allows. ## Auxiliary Models Hermes uses "auxiliary" models for side tasks like image analysis, browser screenshot analysis, session-title generation, and context compression. By default (`auxiliary.*.provider: "auto"`), Hermes routes every auxiliary task to your **main chat model** — the same provider/model you picked in `hermes model`. You don't need to configure anything to get started, but be aware that on expensive reasoning models (Opus, MiniMax M2.7, etc.) auxiliary tasks add meaningful cost. If you want cheap-and-fast side tasks regardless of your main model, set `auxiliary..provider` and `auxiliary..model` explicitly (for example, Gemini Flash on OpenRouter for vision). (Web extraction is not an auxiliary task: `web_extract` and browser snapshots truncate long content deterministically and store the full text for `read_file` paging — no LLM involved.) :::note Why "auto" uses your main model Earlier builds split aggregator users (OpenRouter, Nous Portal) onto a cheap provider-side default. That was surprising — users who paid for an aggregator subscription would see a different model handling their auxiliary traffic. `auto` now uses the main model for everyone, and per-task overrides in `config.yaml` still win (see [Full auxiliary config reference](#full-auxiliary-config-reference) below). ::: ### Configuring auxiliary models interactively Instead of hand-editing YAML, run `hermes model` and pick **"Configure auxiliary models"** from the menu. You'll get an interactive per-task picker: ``` $ hermes model → Configure auxiliary models [ ] vision currently: auto / main model [ ] title_generation currently: openrouter / google/gemini-3-flash-preview [ ] tts_audio_tags currently: auto / main model [ ] compression currently: auto / main model [ ] approval currently: auto / main model [ ] triage_specifier currently: auto / main model [ ] kanban_decomposer currently: auto / main model [ ] profile_describer currently: auto / main model [ ] delegation currently: auto / inherit main agent ``` Select a task, pick a provider (OAuth flows open a browser; API-key providers prompt), pick a model. The change persists to `auxiliary..*` in `config.yaml`. Same machinery as the main-model picker — no extra syntax to learn. The **Delegation** entry is special: it routes the model used by `delegate_task` subagents and persists to the top-level `delegation.*` section (`delegation.provider` / `delegation.model`) rather than `auxiliary.*`, because subagents are full child agents, not side-LLM calls. Its `auto` means "inherit the parent agent's provider, model, and credentials." If you do not want Hermes to auto-generate titles after the first exchange, set `auxiliary.title_generation.enabled: false`. Manual titles still work through `/title` and `hermes sessions rename`. ### Stream-only endpoints Some OpenAI-compatible endpoints reject non-streaming chat requests outright (e.g. Tencent Copilot returns HTTP 400 `"Non-stream chat request is currently not supported"`). Interactive chat already streams, but auxiliary tasks (title generation, compression, vision) use non-streaming calls and would fail on every attempt. Hermes always treats `copilot.tencent.com` as stream-only; for any other such endpoint, list a URL substring under `auxiliary.stream_only_base_urls`: ```yaml auxiliary: stream_only_base_urls: - "my-stream-only-proxy.example.com" ``` Matching auxiliary calls are sent with `stream=True` and the chunks (including tool-call deltas) are aggregated client-side — no behavior change for any other endpoint. ### Video Tutorial