--- sidebar_position: 2 title: "Hermes Agent Configuration" description: "Configure Hermes Agent — config.yaml, providers, models, API keys, and more" --- # Hermes Agent Configuration Python dependency commands on this page use a [PM-prepared source checkout](../reference/package-management.md#developer-workflow). After a dependency change, reactivate the checkout and restart Hermes. 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.md). ::: ## 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 and stale saved selections 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 — every `UPPER_SNAKE` name (`OPENROUTER_API_KEY`, `DISCORD_HOME_CHANNEL`, `TELEGRAM_GROUP_ALLOWED_USERS`, `HERMES_TIMEZONE`, …) is an environment variable and is saved to `.env`, never to `config.yaml`; dotted settings go to `config.yaml`. Any other `UPPER_SNAKE` name is saved to `.env` as-is (it is exported to the process environment for plugins and skills); names on the env writer's denylist (`HERMES_YOLO_MODE`, `PATH`, …) are refused. A known key written under the wrong prefix (`gateway.discord.foo`, where `discord.foo` is itself a known key) is refused with a did-you-mean before anything is written; pass `--force` to write it anyway. Any other unknown path under a known section (a typo such as `agent.max_turnz`, or a runtime-read key with no seeded default) is written together with a did-you-mean notice, since the schema alone cannot tell the two apart. `hermes config get` on such a path prints the value from your file together with a stderr notice that Hermes may not read it, so a leftover key cannot silently pass for a live setting. ::: ## 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](./managed-scope.md). ::: ## 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). On # virtiofs/9p bind mounts (Docker Desktop, Podman on macOS, OrbStack) # Hermes detects the mount and creates fresh databases in delete mode # automatically. 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 (or that the WAL database sits on a # cross-VM mount). To convert an existing database, stop # every process using it and run # `hermes sessions set-journal-mode delete` (see Sessions). 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. The reverse never happens automatically: a database that is already in WAL mode is not live-downgraded when you set `journal_mode: delete` (a downgrade under open connections can corrupt it). `hermes doctor` warns ` is in WAL mode despite database.journal_mode=delete` until you stop every Hermes process for the profile and run `hermes sessions set-journal-mode delete` (it refuses while anything still holds the file and verifies the converted header). Under that warning it names the processes currently holding the database (` is held by PID ()`) so you know what to stop; when the holder scan is partial or unavailable it says `cannot prove the database is quiet` instead of giving an all-clear. ## 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](./multi-profile-gateways.md), 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 (kept verbatim, warning logged) unless B defines the variable itself. This holds wherever B's config is loaded inside the multiplexer: routed gateway turns, B's adapter startup, and B's cron jobs. Single-profile runs are unchanged. See [What is isolated per profile](./multi-profile-gateways.md#what-is-isolated-per-profile) for the full list. 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.md). ### 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. The same key is the streaming stale-stream deadline: an explicit value is used as-is — the implicit context-size tiers (240s above 50k tokens, 300s above 100k) and the reasoning-model floors apply only to the 180s default, so an explicit value can shorten how long a hung stream is tolerated. 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 ### Background checks Passive update checks (CLI banner, TUI badge, dashboard, desktop app) ask the GitHub REST API for the tip of `main` and, when it differs from your checkout, the compare endpoint for the exact count and changelog. They never run `git fetch` — in a partial (`--filter=blob:none`) clone they also never download missing objects from the promisor remote (Git 2.44 or newer) — and every install asks at most **once per 24 hours** (a failed check retries after an hour). Applying an update (`hermes update`, or the desktop's Update button) always fetches fresh and invalidates the cached answer. Explicit checks — `hermes update --check`, the desktop's "Check for Updates…" menu item, Settings → About → "Check now" — bypass the cache. ### SSH authentication The startup update check reads the origin URL with the same isolated Git configuration used for its network calls. Global `url.*.insteadOf` rewrites therefore cannot hide an official SSH remote from the public HTTPS path. Hermes's isolated internal Git commands default to `ssh -o BatchMode=yes`: unknown host keys, passwords, and encrypted keys needing a passphrase fail instead of opening a terminal prompt. Trusted hosts with usable keys or an SSH agent continue to authenticate. This does not change your Git or SSH configuration on disk, or commands you run in the terminal tool. The internal default overrides repository `core.sshCommand` settings. An explicit `GIT_SSH_COMMAND` environment variable still takes precedence, so custom identity or transport commands can be retained there. Include `-o BatchMode=yes` in such an override if it must remain non-interactive; an override that permits prompting can still interrupt a background check. `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`). Point-in-time copies of `config.yaml` itself (taken before `hermes setup` rewrites it, before `hermes migrate` edits it, every time the file parses successfully, and when it fails to parse) go to `backups/config/config.yaml..`. Identical repeats are skipped and only the newest five per reason are kept, so they never pile up beside `config.yaml`. If `config.yaml` is broken, Hermes serves the newest `good` copy instead of built-in defaults and warns on every start until the YAML is fixed; the broken file is never modified. 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) sync_back_max_bytes: 2147483648 # Remote backends: refuse to extract a state archive larger than this (bytes) singularity_image: "docker://nousresearch/hermes-sandbox:desktop" # Container image for Singularity backend modal_image: "nousresearch/hermes-sandbox:desktop" # Container image for Modal backend daytona_image: "nousresearch/hermes-sandbox:desktop" # 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 idle for 24 hours (no write anywhere inside them) 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. Independently of `terminal.temp_dir`, every Hermes process (CLI, TUI, gateway, Desktop backend, cron) and every child it launches gets `TMPDIR`, `TMP` and `TEMP` pointed at **`~/.hermes/cache/scratch`** (per profile) at startup, so `tempfile.mkdtemp()`, `mktemp`, browser profiles and probe scripts all land on real storage instead of a RAM-backed system temp dir. The system prompt names this directory as the scratch directory. Hermes only sets these when they are not already set — a `TMPDIR` exported by you or by the OS (macOS `/var/folders`, Windows `%TEMP%`) is left alone. Entries are pruned at startup (at most once per hour) once they have been **idle for 24 hours**: an entry stays as long as anything anywhere inside it was written in the last day, and goes a day after the last write. Before an idle entry is deleted, Hermes also stops any process still running with its working directory inside that entry (or inside a scratch path that no longer exists, such as a headless browser left behind by a test run) and drops any `git worktree` registration that pointed into it. `hermes doctor` reports the directory and its size, and warns about directories over 1 GB elsewhere under `cache/` that no pruner covers. `desktop.font_family` sets the font for chat and the rest of the Hermes Desktop interface (the terminal pane has its own key above). Give it one installed family name (for example, `OpenDyslexic` or `Atkinson Hyperlegible`) or a CSS font stack; Hermes keeps the active theme's own stack behind it so CJK and emoji glyphs still resolve, and an empty value uses the theme's font. Edit it in **Settings → Appearance → Chat Font**. `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 # Default: nikolaik/python-nodejs (Python 3.13 / Node 26) plus a display stack, so Bot Screen, # computer_use and the browser run INSIDE this sandbox (Bot Screen → "Where the screen runs"). # Any other image works for shell work; the screen then needs bot_desktop.placement: gateway. # Writing this key is a decision: a persisted container on another image is recreated on the next # terminal call. Left unset, an existing container is kept and the CLI / Screen pane ask first. docker_image: "nousresearch/hermes-sandbox:desktop" 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_snap_compat: false # See "Snap-packaged Docker (AppArmor)" 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 Hermes-managed containers carry labels so subsequent processes (and the orphan reaper) can identify them: - `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 - `hermes-environment=` — for containers without an explicit shared key, scopes reuse to the requested image, mount arguments, and active `HERMES_HOME`; host paths and volume sources are hashed rather than exposed in this label. Per-process tempdir-sourced mounts (the symlink-safe skills copy) are hashed by their stable container path, not the random host tempdir, so they don't defeat reuse 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. Without an explicit shared key, the reuse probe also requires a matching `hermes-environment` label. Changing the image, mount arguments, or `HERMES_HOME` starts a fresh container instead of silently using another configuration. Containers created before this label was introduced do not match, so the first session after upgrading starts a fresh container; existing running containers are not removed. 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_SNAP_COMPAT` | `docker_snap_compat` | `true` / `false` — default `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. **Skill / config env passthrough:** variables a skill declares in `required_environment_variables`, or that you list under `terminal.env_passthrough`, are forwarded with OpenSSH `SendEnv` — the names go on the `ssh` command line and the values travel in the client's environment, never in the remote command text. The remote `sshd` must accept them; add to `/etc/ssh/sshd_config` on the server and reload sshd: ``` AcceptEnv NEXTCLOUD_URL NEXTCLOUD_* # or the names your skills need ``` Without a matching `AcceptEnv`, the server silently drops the variables and the remote shell sees them unset. Hermes provider credentials (`OPENAI_API_KEY`, …) are never forwarded even if listed. See [Env Var Passthrough](security.md#environment-variable-passthrough). ### 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` and are retained until you delete them (Hermes opts out of the Modal SDK's 30-day snapshot expiry). 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_image: vercel/sandbox/universal:latest # Vercel managed image or a VCR repository[:tag] 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 python -c "import pm; pm.sync_venv(['vercel'], explicit=True)" ``` **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. **Image:** `terminal.vercel_image` picks the container image for fresh sandboxes: a [Vercel managed image](https://vercel.com/docs/sandbox/concepts/images) such as `vercel/sandbox/universal:latest` (the default: Ubuntu, Node.js 24, Python 3.14), `vercel/sandbox/node:26` or `vercel/sandbox/python:3.14`, or a repository from your project's Vercel Container Registry (a bare name resolves to `latest`; a tag or digest pins it). The older `terminal.vercel_runtime` presets (`node24`, `node22`, `python3.13`) are [deprecated by Vercel](https://vercel.com/docs/sandbox/concepts/runtimes); a pinned runtime still works and overrides the image, but the two cannot be combined. Snapshot restores carry their own filesystem and send neither. **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://nousresearch/hermes-sandbox:desktop" 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; set `terminal.sync_back_max_bytes` (bytes) in `config.yaml` to raise the cap for a larger state tree. Live sockets under the remote `~/.hermes/` (e.g. `gateway.sock`) are skipped rather than failing the transfer. - The downloaded archive is staged under the system temp directory (`hermes-sync-back--*`); leftovers from a hard-killed process are reclaimed on the next sync-back. - 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`. ### Snap-packaged Docker (AppArmor) On hosts where Docker was installed as a snap (common on Ubuntu cloud images, e.g. Azure VMs), the snap's AppArmor confinement rejects two of the sandbox's hardening flags and the container dies at start: ``` exec /sbin/docker-init: operation not permitted # --init exec /usr/bin/sleep: operation not permitted # --security-opt no-new-privileges ``` This is a snapd limitation ([LP#1908448](https://bugs.launchpad.net/snapd/+bug/1908448)), not something Hermes can probe around. Either install Docker from Docker's apt repository instead of the snap (preferred — all hardening stays on), or opt in: ```yaml terminal: docker_snap_compat: true # drops --init and no-new-privileges; cap-drop, tmpfs, PID limits stay ``` With it on, zombie processes inside the sandbox are not reaped by an init and a setuid binary inside the container can regain privileges; a warning is logged at container start. ### 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. A host path in `terminal.cwd` (for example `C:\Users\me\project` on Windows, or a desktop/TUI session's workspace) never becomes the container's working directory: when it is the directory mounted at `/workspace`, file tools and terminal commands use `/workspace`; otherwise the container keeps its own working directory. If a file tool still cannot enter its working directory, the error names the invalid `terminal.cwd` for the active backend rather than the shell's raw `cd:` line. ### 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 ~/project` 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.md#config-settings-configyaml). ### Auto-loading skills every session Pin skills so they are fully loaded at the start of every new session, on every surface: ```yaml skills: auto_load: - my-workflow - github-pr-workflow ``` Resolved once per session when the system prompt is first built (so the prompt stays cache-stable; edits apply to the next session). Missing or disabled skills warn and are skipped; `--ignore-rules` / `HERMES_IGNORE_RULES=1` suppresses the list. Profile-scoped. See [CLI — persistent auto-load](./cli.md#persistent-auto-load-via-config). ### 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](./features/skills.md#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](./features/memory.md#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, never above 20% of the window, 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). The same limit is also a **fail-closed bound on what the model is sent** whenever hygiene does not land by the time a turn starts — the turn-hold budget expired, the summary timed out or failed, a failure cooldown is active, another compression is still in flight, or compression is disabled. In that case the gateway keeps the leading system/setup rows plus the newest messages, total at most `hygiene_hard_message_limit`, and never starts the kept tail on an orphaned tool result. Only the payload of that one turn is clipped: the transcript on disk is untouched, nothing is deleted, and a summary that lands later is adopted as-is. This is what keeps a week-long DM from feeding the model the full uncompressed history when a compression pass keeps missing. `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. The budget is floored at the auxiliary compression request's own timeout (`auxiliary.compression.timeout`, minimum 300s), so the host never gives up on a silent summariser before the request itself would — a reasoning summariser thinking before its first token, or a route that cannot stream, gets the same budget the provider call has. 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. If that attempt also fails, or no fallback chain is configured, what happens next depends on whether the request still fits the model's context window: a request that fits is sent uncompressed this turn (the summary-failure cooldown stops the retry from repeating every turn); a request above the window cannot be sent at all, so Hermes commits its deterministic fallback summary (old tool results pruned, a static handoff in place of the summarised middle) instead of ending the turn — ending the turn with the "compression timed out" recovery result (and, on the messaging gateway, the automatic session reset) is the last resort, reached only when even the deterministic pass cannot shrink the transcript. Set to `0` to disable. A preflight pass that reclaims nothing on a request still above the window ends the turn immediately with guidance to start a new session (`/new`) instead of sending a request the model cannot accept. 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`. For a request already above the model's context window the pre-commit wait is bounded by one `context_timeout_seconds` budget instead of this ceiling: such a request cannot be sent uncompressed anyway, and a summary that keeps streaming while reclaiming nothing would otherwise hold the session (and the Desktop UI) for the full ceiling on every turn — the deterministic fallback summary then carries the compaction. Raise `context_timeout_seconds` if your summariser legitimately needs longer. 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 compaction never fires later than that token count regardless of which model is active. Use it when you want a fixed cost ceiling per call, for example `threshold_tokens: 256000` to compact a 1M-window model at 256K instead of 500K. The cap is clamped to the model's context length, so a value above the window is a no-op. 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 and summarizes older oversized tool results, protecting the most recent `protect_last_n` messages and never calling the model. Tool-call arguments are execution records and are never rewritten by pruning; the summary model has a separate bounded serializer for copies included in its prompt. The pressure pass likewise demotes tool-result bodies only. During full semantic compaction the carried head/tail rows keep those tool-result demotions (so an oversized tail can still compress), while tool-call arguments stay byte-exact. The opt-in proactive prune still commits eligible tool-result-body demotions, so `proactive_prune_min_reclaim_tokens` (default `4096`) keeps those cache-breaking commits episodic; `proactive_prune_min_result_chars` (default `8000`, clamped to ≥ 200) sets the size below which a tool result is left untouched. 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) | ### Stream progress timeout When the summary streams (compression always streams over chat-completions and Responses routes, e.g. `openai-codex` or a custom OpenAI-compatible endpoint), two timeouts apply, and they are independent: - `auxiliary.compression.timeout` — the overall request budget (default 120s). - `auxiliary.compression.no_progress_timeout` — how long the stream may go without a **substantive** event (a text/reasoning delta or a completed output item) before the attempt aborts (`... stream stalled: no new output for Ns`) and the normal retry / fallback chain takes over. Default **60s** when unset. On a chat-completions stream the window starts at the first token. Before it, a remote endpoint gets the main loop's stale patience (the provider's `providers..stale_timeout_seconds` when set, else 180s raised for large prompts and to 600s for reasoning models such as o3 or DeepSeek R1; capped at `timeout`), so a model thinking silently is not cut. On a local endpoint (localhost, LAN, Tailscale) a silent prefill is bounded only by `timeout`. The same rule applies to every streamed auxiliary task, including MoA reference calls. Keepalive and lifecycle frames (`response.in_progress`, pings) do not count as progress; every substantive event re-arms the window, so a slow but progressing summary is never cut off by it. Raising `timeout` alone does **not** widen the progress window — a request configured for 600s still aborts after a 60s gap. Set `no_progress_timeout` to change that gap; the effective window is capped at `timeout`, and the host's hard deadline / cancellation still win. The host's own inactivity budget is the outer cap here: in-agent compaction gives up on a silent summariser after `compression.context_timeout_seconds` (default 120s, floored at the effective `auxiliary.compression.timeout`, itself at least 300s) and gateway hygiene after `compression.hygiene_timeout_seconds` (default 30s), so a `no_progress_timeout` larger than the applicable host budget is silently cut short by it. The key is per task (`auxiliary..no_progress_timeout`), so widening it for compression does not change other auxiliary tasks. A value that is not a positive number is ignored with a warning in the log and the 60s default applies. ```yaml auxiliary: compression: provider: openai-codex timeout: 600 no_progress_timeout: 180 # tolerate a 3-minute silent gap on a long reasoning summary ``` :::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 anonymous 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. Under such a limit the measurement is scoped the same way: the cgroup's own anonymous charge (`memory.stat` `anon`), which includes child processes such as `execute_code` kernels and terminal commands that count against the unit's limit. Uncapped, the gateway's own anonymous RSS is measured. 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.md)). 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](./features/memory-providers.md) 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 budget_warning_ratio: null # Optional one-time checkpoint warning, e.g. 0.75 api_max_retries: 3 # Retries per provider before fallback engages (default: 3) auto_recovery_cycles: 5 # Wait-and-retry cycles after retries + fallback are spent on an outage (0 = off) ``` `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.budget_warning_ratio` is off by default for ordinary and delegated conversations. When set to a value strictly between `0` and `1` alongside a finite `max_turns`, Hermes appends one model-visible checkpoint notice to the latest tool result after the threshold is reached. The notice rearms each conversation turn and uses each agent's own iteration budget. It only appends to a current tool-result tail, never an older turn, and does not add a synthetic user/system message or change the existing exhaustion grace call. Dispatcher-owned Kanban workers receive a completion checkpoint at 90% by default (an explicit ratio changes that threshold), while their tools are still available. The checkpoint asks for verified completion or a durable progress comment, not premature success. `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](./features/fallback-providers.md) 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. `agent.auto_recovery_cycles` is the safety net *after* both the retries and the fallback chain are spent. When the failure is a transient outage (HTTP 5xx, an `overloaded`/529 response, a connect or read timeout) and no answer text has reached you yet, Hermes does not end the turn with "API failed after N retries" — it waits and tries again, up to this many cycles (default `5`), with a jittered 15/30/60/60/60 s schedule. A provider `Retry-After` header wins over the schedule (honoured up to 120 s). Every surface shows the same line while it waits — `⏳ Provider temporarily unavailable — retrying automatically in 30s (cycle 2/5); press Esc to stop` on the CLI/TUI/Desktop, a status bubble on messaging platforms (`send /stop to cancel`), a `hermes.status` SSE event on the API server, and a log line for cron jobs. Pressing Esc (or `/stop`) cancels the wait immediately. Fallback still comes first: with a fallback chain configured, exhaustion moves to the next provider as before, and the ladder only engages once the chain has nothing left. Authentication, billing, request-format, entitlement, content-policy and account-policy errors never enter the ladder. Set `0` to disable it. ## 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. The evidence that feeds this guard (which test/lint/build commands ran, which files were edited since) lives in `~/.hermes/verification_evidence.db`. That ledger is only written or created while the guard is enabled; with `verify_on_stop: false` nothing is recorded and an existing file can be deleted freely. For a user/plugin policy gate at the same point — keep the agent going with your own checks — see the [`pre_verify` hook](./features/hooks.md#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](./features/goals.md) 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` | | Responses first-event watchdog | 120s | Raised to the 900s ceiling (`agent.local_stream_stale_timeout`) | `HERMES_CODEX_TTFB_TIMEOUT_SECONDS` | | API call (non-streaming) | 1800s | Unchanged | `providers..request_timeout_seconds` / `timeout_seconds` or `HERMES_API_TIMEOUT` | | Post-terminal stream drain (Codex/Responses) | 2s | Unchanged | `agent.stream_drain_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 **Responses first-event watchdog** (Codex / `codex_responses` transport, including custom providers declared with the Responses transport) aborts and reconnects a request that accepts the connection but emits no stream event within 120 seconds. A local server prefilling a large context legitimately stays silent longer than that, so on local endpoints the implicit default is raised to the same ceiling as the stale stream detector (`agent.local_stream_stale_timeout` / `HERMES_LOCAL_STREAM_STALE_TIMEOUT`, 900s). An explicit `HERMES_CODEX_TTFB_TIMEOUT_SECONDS` is always used as-is (`0` disables the watchdog). 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. The **post-terminal stream drain** bounds how long a Codex/Responses stream keeps reading after its terminal `response.completed` frame (a courtesy so the relay finalizer can run). Some relays never close the SSE socket after the terminal frame; without a bound the turn wedged until the stale-stream watchdog fired and discarded the already-billed response, then retried. After `agent.stream_drain_timeout` seconds the stream is closed and the completed response is returned. Endpoints that close the connection normally finish the drain immediately and never wait this long; set `0` to skip the drain entirely. 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). The periodic provider-wait notice appears only after at least **60 seconds of silence**. The Codex Responses **waiting status** describes silence, not total generation time: active stream events (including reasoning) keep it quiet. If events stop, it reports time without stream events instead of claiming no response has arrived; the notice clears when events resume. When a reconnect starts a fresh first-event watchdog phase, the waiting status follows that phase. This display behavior does not extend the separate wall-clock stale-call budget or change watchdog timeouts. The status is shown once per silence (after 60s), in neutral wording that names the wait phase (`waiting for the first provider event` vs `provider stream active; Ns without stream events`) and the watchdog that would reconnect (`TTFB`, `stream idle`, or `wall-clock stale`) with the seconds left before it fires; it is rewritten only when the phase changes or that deadline is near, not on every 30s liveness heartbeat. Chat-completion streams follow the same rule (`waiting for the first stream chunk` / `stream open; Ns without stream output`, `stream stale` watchdog) and likewise clear their silence notice promptly when chunks resume, without replacing a local model-loading status. 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. Hermes also switches a session to non-streaming on its own when streaming cannot make progress: the provider reports that streaming is not supported, or an OpenAI-compatible gateway answers a streaming request with a contentless SSE frame (a bare `data:` / `event: ping` keepalive with no payload, typical of a degraded relay). The turn is retried without streaming, a warning is shown, and streaming stays off for the rest of that session. ```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](./features/credential-pools.md) 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.md#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", "1h" (Anthropic-supported tiers) or "auto"; other values are ignored ``` `cache_ttl` selects the breakpoint TTL Hermes attaches for Claude via the native Anthropic API, OpenRouter, and Nous Portal. The two Anthropic tiers (`"5m"`, `"1h"`) are sent as-is; 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. The 1h tier writes at 2x the base input price (5m writes at 1.25x) and only pays off when your turns are more than five minutes apart — otherwise every tool result is written at the dearer rate for retention nobody uses. `"auto"` picks the tier per session from who paces it: `1h` for sessions a person types into (CLI, TUI, Desktop, Telegram/Discord/Slack and the other messaging platforms), `5m` for machine-paced ones (subagents, cron, `hermes -q` one-shots, webhooks, Kanban workers, the API server, tool-invoked and batch runs). On an install where interactive sessions are parked and resumed through the day, `auto` cut the interactive cache-write bill by roughly 40% while leaving fan-out subagent spend untouched. Delegated subagents are always clamped to `5m`, whatever the setting. ## 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`. To keep the instant derived title (the first line of your opening message) but never spend a model call upgrading it, set `auxiliary.title_generation.model_upgrade_enabled: false`. No background `auto-title` thread starts and no automatic title-model request is sent; the explicit repair command `hermes sessions retitle-skills` still calls the model. `enabled: false` still disables both stages. On a `custom` main provider (llama.cpp, Ollama, vLLM, LM Studio and other self-hosted OpenAI-compatible servers) the title model call is sent **after** the turn's reply has arrived, not concurrently with it, unless `auxiliary.title_generation` is pinned to another provider or `base_url`. A single-slot local server that receives the `json_schema` title request while decoding the reply can otherwise answer the reply with `{"title": ...}`, which is then stored and replayed as the assistant's turn. In Hermes Desktop, a plain-text paste over 3,000 characters becomes a generated `.txt` attachment. The first ~1,000 characters of that paste are handed to the title stages as a title-only hint (the agent turn still sees only the attachment reference), so a "summarize this" plus a large paste is named after the pasted topic. Files you attach yourself are never read for titling. In the local messaging gateway, text messages supply their original request to session titling, before channel-bound skills and platform context are added. The main model and conversation history still retain the full skill content. Attachment-only turns retain the existing enriched-message title fallback. This affects new title generation; it does not repair previously named sessions. ### 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