Files
aiturk-hermes-ide/website/docs/user-guide/configuration.md
T

190 KiB
Raw Blame History

sidebar_position, title, description
sidebar_position title description
2 Hermes Agent Configuration Configure Hermes Agent — config.yaml, providers, models, API keys, and more

Hermes Agent Configuration

All settings are stored in the ~/.hermes/ directory for easy access.

:::tip Easiest path to a working config.yaml Run hermes setup --portal — one OAuth gets you a model provider and all four Tool Gateway tools without hand-editing YAML. Portal subscribers also get 10% off token-billed providers. See Nous Portal. :::

Directory Structure

~/.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

hermes config              # View current configuration
hermes config edit         # Open config.yaml in your editor
hermes config get KEY      # Print a resolved value
hermes config set KEY VAL  # Set a specific value
hermes config unset KEY    # Remove a user-set value
hermes config check        # Check for missing options (after updates)
hermes config migrate      # Interactively add missing options

# Examples:
hermes config get model
hermes config set model anthropic/claude-opus-4
hermes config set terminal.backend docker
hermes config unset terminal.backend
hermes config set OPENROUTER_API_KEY sk-or-...  # Saves to .env

:::tip The hermes config set command automatically routes values to the right file — API keys are saved to .env, everything else to config.yaml. :::

Configuration Precedence

Settings are resolved in this order (highest priority first):

  1. CLI arguments — e.g., hermes chat --model anthropic/claude-sonnet-4 (per-invocation override)
  2. ~/.hermes/config.yaml — the primary config file for all non-secret settings
  3. ~/.hermes/.env — fallback for env vars; required for secrets (API keys, tokens, passwords)
  4. Built-in defaults — hardcoded safe defaults when nothing else is set

:::info Rule of Thumb Secrets (API keys, bot tokens, passwords) go in .env. Everything else (model, terminal backend, compression settings, memory limits, toolsets) goes in config.yaml. When both are set, config.yaml wins for non-secret settings. :::

:::tip Org deployments An administrator can pin specific config and secret values that a standard user cannot override, via a system-level managed directory. See Managed Scope. :::

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:

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:

database:
  # Journal mode for state.db: wal (default) or delete.
  # Use delete on filesystems where WAL is unsafe (network mounts, some
  # virtiofs setups). Note: an existing on-disk WAL database is never
  # live-downgraded — Hermes keeps WAL and logs an error telling you the
  # configured delete did not apply. To convert an existing database, stop
  # every process using it and run a one-time offline
  # `PRAGMA journal_mode=DELETE` on the file.
  journal_mode: wal

  # Durability level for every state.db connection: OFF, NORMAL, FULL,
  # EXTRA (or 0-3). Unset leaves SQLite's compile-time default, which
  # differs between interpreter builds. On macOS this is a floor, not a
  # pin: values below FULL are refused to protect against Darwin fsync
  # reordering; EXTRA is honored.
  # synchronous: FULL

  # Optional WAL sizing pragmas (integers). Unset = SQLite defaults.
  # wal_autocheckpoint: 1000     # pages between automatic checkpoints
  # journal_size_limit: 67108864 # cap the WAL/journal size in bytes

Hermes also warns (once per process per database) when an existing database's on-disk journal mode is silently flipped to WAL on open — for example a database an operator had manually converted to delete — and names database.journal_mode as the setting that makes the choice stick.

Environment Variable Substitution

You can reference environment variables in config.yaml using ${VAR_NAME} syntax:

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, references in a profile's config.yaml resolve against that profile's .env (its secret scope), not the shared process environment — a ${MATRIX_ACCESS_TOKEN} in profile B stays unresolved unless B defines the variable itself. Single-profile runs are unchanged.

Cursor-style SecretRef syntax is also accepted: ${env:VAR_NAME} resolves exactly like ${VAR_NAME} (the env: prefix is stripped), so MCP or provider snippets copied from Cursor / Claude configs work unchanged in both config.yaml and the mcp_servers block. Other SecretRef sources (${file:...}, ${vault:...}, ${bitwarden:...}) are not resolved inline — external secret backends inject their values into the environment at startup via the secrets: block, so reference them as ${env:NAME} instead; unknown prefixes warn once and stay verbatim.

For AI provider setup (OpenRouter, Anthropic, Copilot, custom endpoints, self-hosted LLMs, fallback models, etc.), see AI Providers.

Provider Timeouts

You can set providers.<id>.request_timeout_seconds for a provider-wide request timeout, plus providers.<id>.models.<model>.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.<id>.stale_timeout_seconds for the non-streaming stale-call detector, plus providers.<id>.models.<model>.stale_timeout_seconds for a model-specific override. This wins over the legacy HERMES_API_CALL_STALE_TIMEOUT env var.

Leaving these unset keeps the legacy defaults (HERMES_API_TIMEOUT=1800s, HERMES_API_CALL_STALE_TIMEOUT=90s, 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.

Update Behavior

hermes update settings live under updates in config.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 (truefull, falseoff).

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.

terminal:
  backend: local    # local | docker | ssh | modal | daytona | vercel_sandbox | singularity
  cwd: "."          # Gateway/cron working directory (CLI always uses launch dir)
  temp_dir: ""      # Session temp root; empty = TMPDIR, else ~/.hermes/cache/terminal
  font_family: ""   # Desktop terminal font; e.g. "MesloLGS NF"
  timeout: 180      # Per-command timeout in seconds
  home_mode: auto   # auto | real | profile — subprocess HOME policy
  env_passthrough: []  # Env var names to forward to sandboxed execution (terminal + execute_code)
  singularity_image: "docker://nikolaik/python-nodejs:python3.11-nodejs20"  # Container image for Singularity backend
  modal_image: "nikolaik/python-nodejs:python3.11-nodejs20"                 # Container image for Modal backend
  daytona_image: "nikolaik/python-nodejs:python3.11-nodejs20"               # Container image for Daytona backend

terminal.temp_dir controls where Hermes puts session temp artifacts on the local backend — background-process logs/pid/exit files, code-execution sandboxes, and spilled tool results. When it's empty (the default), Hermes honors an explicit TMPDIR/TMP/TEMP from the environment and otherwise uses a managed directory on real storage at ~/.hermes/cache/terminal instead of /tmp — on many distros (Arch-based setups in particular) /tmp is a small RAM-backed tmpfs that Hermes session artifacts can fill under load. The managed directory is auto-pruned: artifacts older than 72 hours are swept hourly by gateway housekeeping and once per process on CLI-only installs. Set temp_dir to an existing absolute path to redirect session temp anywhere else; user-set paths are never auto-pruned.

terminal.font_family controls the embedded terminal in Hermes Desktop. It accepts either one locally installed family name (for example, MesloLGS NF) or a CSS font stack. Hermes appends its bundled JetBrains Mono stack as a fallback, and an empty value keeps the default. You can edit the same profile-scoped setting in Settings → Appearance → Terminal Font; no Google Fonts download or system-font permission is required.

For cloud sandboxes such as Modal, Daytona, and Vercel Sandbox, container_persistent: true means Hermes will try to preserve filesystem state across sandbox recreation. It does not promise that the same live sandbox, PID space, or background processes will still be running later.

Backend Overview

Backend Where commands run Isolation Best for
local Your machine directly None Development, personal use
docker Single persistent Docker container (shared across session, /new, subagents) Full (namespaces, cap-drop) Safe sandboxing, CI/CD
ssh Remote server via SSH Network boundary Remote dev, powerful hardware
modal Modal cloud sandbox Full (cloud VM) Ephemeral cloud compute, evals
daytona Daytona workspace Full (cloud container) Managed cloud dev environments
vercel_sandbox Vercel Sandbox Full (cloud microVM) Cloud execution with snapshot-backed filesystem persistence
singularity Singularity/Apptainer container Namespaces (--containall) HPC clusters, shared machines

Local Backend

The default. Commands run directly on your machine with no isolation. No special setup required.

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:

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:

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.

terminal:
  backend: docker
  docker_image: "nikolaik/python-nodejs:python3.11-nodejs20"
  docker_mount_cwd_to_workspace: false  # Mount launch dir into /workspace
  docker_run_as_host_user: false   # See "Running container as host user" below
  docker_forward_env:              # Host env vars to forward into container
    - "GITHUB_TOKEN"
  docker_env:                      # Literal env vars to inject (KEY=value)
    DEBUG: "1"
    PYTHONUNBUFFERED: "1"
  docker_volumes:                  # Host directory mounts
    - "/home/user/projects:/workspace/projects"
    - "/home/user/data:/data:ro"   # :ro for read-only
  docker_extra_args:               # Extra flags appended verbatim to `docker run`
    - "--gpus=all"
    - "--network=host"
  docker_network: true             # false = air-gap the container (--network=none)

  # Resource limits
  container_cpu: 1                 # CPU cores (0 = unlimited)
  container_memory: 5120           # MB (0 = unlimited)
  container_disk: 51200            # MB (requires overlay2 on XFS+pquota)
  container_persistent: true       # true = persist /workspace + /root, shared container; false = fresh container per session (see below)

  # Cross-process container reuse (defaults match the "one long-lived
  # container shared across sessions" contract — see Container lifecycle).
  docker_persist_across_processes: true   # Reuse container across Hermes restarts
  docker_shared_container_key: ""         # Opt in trusted profiles to one identity
  docker_orphan_reaper: true              # Sweep abandoned Exited containers at startup

  # Cross-backend lifecycle settings (apply to docker as well)
  timeout: 180                     # Per-command timeout in seconds
  lifetime_seconds: 300            # Idle-reaper window; also feeds 2× orphan-reaper threshold

docker_env vs docker_forward_env: the former injects literal KEY=value pairs you specify in the config (the values live in your config.yaml or are passed as a JSON dict via TERMINAL_DOCKER_ENV='{"DEBUG":"1"}'). The latter forwards values from your shell or ~/.hermes/.env, so the actual secret never appears in the config file. Use docker_forward_env for tokens and docker_env for static knobs the container needs.

terminal.docker_extra_args (also overridable via TERMINAL_DOCKER_EXTRA_ARGS='["--gpus=all"]') lets you pass arbitrary docker run flags that Hermes doesn't surface as first-class keys — --gpus, --network, --add-host, alternative --security-opt overrides, etc. Each entry must be a string; the list is appended last to the assembled docker run invocation so it can override Hermes' defaults if needed. Use sparingly — flags that conflict with the sandbox hardening (capability drops, --user, the workspace bind mount) will silently weaken isolation.

terminal.docker_network (default true; env: TERMINAL_DOCKER_NETWORK) — set to false to run the sandbox container with --network=none, cutting off all network egress from agent commands. This applies to the execution container used by terminal, execute_code, and the file tools. Because containers persist across Hermes processes, flipping this to false while an older networked container exists will remove that container and start a fresh air-gapped one (a warning is logged); background processes running inside it are lost. Prefer this key over passing --network=none through docker_extra_args.

Requirements: Docker Desktop or Docker Engine installed and running. Hermes probes $PATH plus common macOS install locations (/usr/local/bin/docker, /opt/homebrew/bin/docker, Docker Desktop app bundle). Podman is supported out of the box: set HERMES_DOCKER_BINARY=podman (or the full path) to force it when both are installed.

Container lifecycle

Every Hermes-managed container is tagged with three labels so subsequent processes (and the orphan reaper) can identify it:

  • hermes-agent=1 — marks it as Hermes-managed
  • hermes-task-id=<sanitized task_id> — keys the per-task reuse probe
  • hermes-profile=<sanitized profile name> — scopes reuse and reaping to the active Hermes profile by default; when docker_shared_container_key is set, its sanitized value is used instead

On startup, Hermes runs docker ps --filter label=hermes-task-id=<id> --filter label=hermes-profile=<identity> and attaches to the existing container when it finds one. The identity is the active profile unless docker_shared_container_key explicitly opts trusted profiles into a common value. If the container is exited (e.g. after a Docker daemon restart), it's docker start'd and reused — filesystem state and any installed packages survive, but in-container background processes do not.

When a Hermes process exits — /quit, closing a TUI session, gateway shutdown, even SIGKILL — the cleanup path is a no-op for the container in default mode. The container keeps running. The next Hermes process attaches to it in milliseconds via the label probe. This is the behavior the "one long-lived container shared across sessions" contract requires: it's the only way background processes (npm watchers, dev servers, long-running pytest) survive across sessions.

The container is only torn down (stopped and docker rm -f'd) in these cases:

Trigger When it fires
docker_persist_across_processes: false Explicit per-process isolation. Every cleanup() does stop + rm -f. Matches pre-issue-#20561 behavior.
Idle reaper (lifetime_seconds, default 300s) Only when the env is persist_across_processes=false. Persist-mode envs are no-op'd; container survives the idle sweep.
Orphan reaper at next startup Sweeps Exited hermes-labeled containers older than 2 × lifetime_seconds (default 600s = 10 min), scoped to the current profile. Running containers are never touched — sibling-process safety. Set docker_orphan_reaper: false to disable.
Direct user action docker rm -f, docker system prune, Docker Desktop restart. We don't set --restart=always, so a host reboot leaves the container Exited (its CoW layer survives and gets reused on next startup, but bg processes are gone).

Edge cases worth knowing:

  • OOM kill of in-container PID 1 transitions the container to Exited. Next reuse will docker start it; filesystem state survives, bg processes do not.
  • Switching profiles isolates containers from each other — a container labeled hermes-profile=work is invisible to a Hermes process running under hermes-profile=research. The orphan reaper is profile-scoped too, so cross-profile containers don't get reaped accidentally, but they also won't get cleaned up automatically until you start Hermes again under their original profile.
  • Explicit cross-profile sharing — set the same non-empty docker_shared_container_key under terminal: for profiles that intentionally collaborate in one trusted workspace. This replaces only their container identity label; task, egress, and network compatibility checks still apply. Profiles without the key remain isolated. The identity label is derived from the key with a short digest suffix, so similar-looking keys (team/workspace vs team_workspace) never collide into one container. Important: a shared container is created once, by whichever profile starts it first — that profile's docker_image, volumes, shm size, and other immutable Docker settings win, and later profiles attach to it as-is; differing settings in their configs are ignored until the container is removed and recreated. Profiles sharing a key should agree on image and mounts.

Parallel subagents spawned via delegate_task(tasks=[...]) share this one container — concurrent cd, env mutations, and writes to the same path will collide. If a subagent needs an isolated sandbox, it must register a per-task image override via register_task_env_overrides(), which RL and benchmark environments (TerminalBench2, HermesSweEnv, etc.) do automatically for their per-task Docker images.

Security hardening:

  • --cap-drop ALL with only DAC_OVERRIDE, CHOWN, FOWNER added back
  • --security-opt no-new-privileges
  • --pids-limit 256
  • Size-limited tmpfs for /tmp (512MB), /var/tmp (256MB), /run (64MB)

Credential forwarding: Env vars listed in docker_forward_env are resolved from your shell environment first, then ~/.hermes/.env. Skills can also declare required_environment_variables which are merged automatically.

Environment variable overrides

Every key under terminal: has an env-var override of the form TERMINAL_<KEY_UPPERCASE>. The most useful ones for the Docker backend:

Env var Maps to Notes
TERMINAL_DOCKER_IMAGE docker_image Base image
TERMINAL_DOCKER_FORWARD_ENV docker_forward_env JSON array: '["GITHUB_TOKEN","OPENAI_API_KEY"]'
TERMINAL_DOCKER_ENV docker_env JSON dict: '{"DEBUG":"1"}'
TERMINAL_DOCKER_VOLUMES docker_volumes JSON array of "host:container[:ro]" strings
TERMINAL_DOCKER_EXTRA_ARGS docker_extra_args JSON array
TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE docker_mount_cwd_to_workspace true / false
TERMINAL_DOCKER_RUN_AS_HOST_USER docker_run_as_host_user true / false
TERMINAL_DOCKER_NETWORK docker_network true / false — default true; false = --network=none
TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES docker_persist_across_processes true / false — default true
TERMINAL_DOCKER_SHARED_CONTAINER_KEY docker_shared_container_key Explicit shared identity for trusted profiles; empty by default
TERMINAL_DOCKER_ORPHAN_REAPER docker_orphan_reaper true / false — default true
TERMINAL_CONTAINER_CPU container_cpu CPU cores
TERMINAL_CONTAINER_MEMORY container_memory MB
TERMINAL_CONTAINER_DISK container_disk MB
TERMINAL_CONTAINER_PERSISTENT container_persistent true / false — controls the bind-mount workspace dirs, distinct from docker_persist_across_processes
TERMINAL_LIFETIME_SECONDS lifetime_seconds Idle reaper window
TERMINAL_TEMP_DIR temp_dir Session temp root (local backend)
TERMINAL_TIMEOUT timeout Per-command timeout
HERMES_DOCKER_BINARY none Force a specific docker/podman binary path

SSH Backend

Runs commands on a remote server over SSH. Uses ControlMaster for connection reuse (5-minute idle keepalive). Persistent shell is enabled by default — state (cwd, env vars) survives across commands.

terminal:
  backend: ssh
  persistent_shell: true           # Keep a long-lived bash session (default: true)

Required environment variables:

TERMINAL_SSH_HOST=my-server.example.com
TERMINAL_SSH_USER=ubuntu

Optional:

Variable Default Description
TERMINAL_SSH_PORT 22 SSH port
TERMINAL_SSH_KEY (system default) Path to SSH private key
TERMINAL_SSH_PERSISTENT true Enable persistent shell

How it works: Connects at init time with BatchMode=yes and StrictHostKeyChecking=accept-new. Persistent shell keeps a single bash -l process alive on the remote host, communicating via temporary files. Commands that need stdin_data or sudo automatically fall back to one-shot mode.

Modal Backend

Runs commands in a Modal cloud sandbox. Each task gets an isolated VM with configurable CPU, memory, and disk. Filesystem can be snapshot/restored across sessions.

terminal:
  backend: modal
  container_cpu: 1                 # CPU cores
  container_memory: 5120           # MB (5GB)
  container_disk: 51200            # MB (50GB)
  container_persistent: true       # Snapshot/restore filesystem

Required: Either MODAL_TOKEN_ID + MODAL_TOKEN_SECRET environment variables, or a ~/.modal.toml config file.

Persistence: When enabled, the sandbox filesystem is snapshotted on cleanup and restored on next session. Snapshots are tracked in ~/.hermes/modal_snapshots.json. This preserves filesystem state, not live processes, PID space, or background jobs.

Credential files: Automatically mounted from ~/.hermes/ (OAuth tokens, etc.) and synced before each command.

Daytona Backend

Runs commands in a Daytona managed workspace. Supports stop/resume for persistence.

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 cloud microVM. Hermes uses the normal terminal and file tool surfaces; there are no Vercel-specific model-facing tools.

terminal:
  backend: vercel_sandbox
  vercel_runtime: node24          # node24 | node22 | python3.13
  cwd: /vercel/sandbox            # default workspace root
  container_persistent: true      # Snapshot/restore filesystem
  container_disk: 51200           # Shared default only; custom disk is unsupported

Required install: Install the optional SDK extra:

pip install 'hermes-agent[vercel]'

Required authentication: Configure access-token auth with all three of VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID. This is the supported setup for deployments and normal long-running Hermes processes on Render, Railway, Docker, and similar hosts.

For one-off local development, Hermes also accepts short-lived Vercel OIDC tokens:

VERCEL_OIDC_TOKEN="$(vc project token <project-name>)" hermes chat

From a linked Vercel project directory, you can omit the project name:

VERCEL_OIDC_TOKEN="$(vc project token)" hermes chat

OIDC tokens are short-lived and should not be used as the documented deployment path.

Runtime: terminal.vercel_runtime supports node24, node22, and python3.13. If unset, Hermes defaults to node24.

Persistence: When container_persistent: true, Hermes snapshots the sandbox filesystem during cleanup and restores a later sandbox for the same task from that snapshot. Snapshot contents can include Hermes-synced credentials, skills, and cache files that were copied into the sandbox. This preserves filesystem state only; it does not preserve live sandbox identity, PID space, shell state, or running background processes.

Background commands: terminal(background=true) uses Hermes' generic non-local background process flow. You can spawn, poll, wait, view logs, and kill processes through the normal process tool while the sandbox is alive. Hermes does not provide native Vercel detached-process recovery after cleanup or restart.

Disk sizing: Vercel Sandbox does not currently support Hermes' container_disk resource knob. Leave container_disk unset or at the shared default 51200; non-default values fail diagnostics and backend creation instead of being silently ignored.

Singularity/Apptainer Backend

Runs commands in a Singularity/Apptainer container. Designed for HPC clusters and shared machines where Docker isn't available.

terminal:
  backend: singularity
  singularity_image: "docker://nikolaik/python-nodejs:python3.11-nodejs20"
  container_cpu: 1                 # CPU cores
  container_memory: 5120           # MB
  container_persistent: true       # Writable overlay persists across sessions

Requirements: apptainer or singularity binary in $PATH.

Image handling: Docker URLs (docker://...) are automatically converted to SIF files and cached. Existing .sif files are used directly.

Scratch directory: Resolved in order: TERMINAL_SCRATCH_DIRTERMINAL_SANDBOX_DIR/singularity/scratch/$USER/hermes-agent (HPC convention) → ~/.hermes/sandboxes/singularity.

Isolation: Uses --containall --no-home for full namespace isolation without mounting the host home directory.

Common Terminal Backend Issues

If terminal commands fail immediately or the terminal tool is reported as disabled:

  • Local — No special requirements. The safest default when getting started.
  • Docker — Run docker version to verify Docker is working. If it fails, fix Docker or hermes config set terminal.backend local.
  • SSH — Both TERMINAL_SSH_HOST and TERMINAL_SSH_USER must be set. Hermes logs a clear error if either is missing.
  • Modal — Needs MODAL_TOKEN_ID env var or ~/.modal.toml. Run hermes doctor to check.
  • Daytona — Needs DAYTONA_API_KEY. The Daytona SDK handles server URL configuration.
  • Singularity — Needs apptainer or singularity in $PATH. Common on HPC clusters.

When in doubt, set terminal.backend back to local and verify that commands run there first.

Remote-to-Host State Sync on Teardown

For the SSH, Modal, and Daytona backends, Hermes pushes your ~/.hermes/ state (credential files, skills, cache) into the remote sandbox during the session, and on teardown syncs changed state files back to their original host locations. Files that differ from what was originally pushed (compared by content hash) are applied back in place; new remote files under a synced directory (e.g. a skill the agent created remotely) are mapped back to the corresponding host path. Upload-only credential files are never overwritten on the host.

  • The sync-back retries up to 3 times with backoff and refuses to extract remote archives larger than 2 GiB.
  • Docker and Singularity use bind mounts (live host filesystem view) and don't need this.
  • This covers Hermes state (~/.hermes/), not arbitrary working-tree files inside the sandbox — have the agent copy important artifacts out explicitly (e.g. scp, modal volume put) before the sandbox is destroyed.

Docker Volume Mounts

When using the Docker backend, docker_volumes lets you share host directories with the container. Each entry uses standard Docker -v syntax: host_path:container_path[:options].

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.

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:

terminal:
  backend: docker
  docker_run_as_host_user: true   # default: false

When enabled, Hermes appends --user $(id -u):$(id -g) to the docker run command so files written into bind-mounted directories (/workspace, /root, anything in docker_volumes) are owned by your host user, not root. The trade-off: the container can no longer apt install or write to root-owned paths like /root/.npm — use a base image whose HOME is owned by a non-root user (or add your required tooling at image build time) if you need both.

Leave this false (the default) for backwards-compatible behavior. Turn it on when your workflow is mostly "edit mounted host files" and you're tired of sudo chown -R.

Optional: Mount the Launch Directory into /workspace

Docker sandboxes stay isolated by default. Hermes does not pass your current host working directory into the container unless you explicitly opt in.

Enable it in config.yaml:

terminal:
  backend: docker
  docker_mount_cwd_to_workspace: true

When enabled:

  • if you launch Hermes from ~/projects/my-app, that host directory is bind-mounted to /workspace
  • the Docker backend starts in /workspace
  • file tools and terminal commands both see the same mounted project

When disabled, /workspace stays sandbox-owned unless you explicitly mount something via docker_volumes.

Security tradeoff:

  • false preserves the sandbox boundary
  • true gives the sandbox direct access to the directory you launched Hermes from

Use the opt-in only when you intentionally want the container to work on live host files.

Persistent Shell

By default, each terminal command runs in its own subprocess — working directory, environment variables, and shell variables reset between commands. When persistent shell is enabled, a single long-lived bash process is kept alive across execute() calls so that state survives between commands.

This is most useful for the SSH backend, where it also eliminates per-command connection overhead. Persistent shell is enabled by default for SSH and disabled for the local backend.

terminal:
  persistent_shell: true   # default — enables persistent shell for SSH

To disable:

hermes config set terminal.persistent_shell false

What persists across commands:

  • Working directory (cd /tmp sticks for the next command)
  • Exported environment variables (export FOO=bar)
  • Shell variables (MY_VAR=hello)

Precedence:

Level Variable Default
Config terminal.persistent_shell true
SSH override TERMINAL_SSH_PERSISTENT follows config
Local override TERMINAL_LOCAL_PERSISTENT false

Per-backend environment variables take highest precedence. If you want persistent shell on the local backend too:

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 and the Terminal section of the README 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.

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:

hermes config set skills.config.myplugin.path ~/myplugin-data

For details on declaring config settings in your own skills, see Creating Skills — Config Settings.

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:

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:

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 <id>, /skills approve <id>, /skills reject <id> — 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.

Memory Configuration

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 <id> / /memory reject <id> review. Toggle at runtime with /memory approval on|off. See Controlling memory writes.

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.

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:

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:

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.

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:

# 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:

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:

# 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:

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:

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:

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.

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

compression:
  enabled: true                                     # Toggle compression on/off
  progress_notices: false                           # Opt-in: deliver routine compression progress notices to chat platforms — see below
  threshold: 0.50                                   # Compress at this % of context limit
  threshold_tokens: null                            # Absolute token cap (optional) — takes lower of ratio vs absolute
  target_ratio: 0.20                                # Fraction of threshold to preserve as recent tail
  tail_mode: lean                                   # Tail retention: "lean" (default — clamped 2.5% tail, 10K-25K, with a detailed session log + anchor index + session_search recovery pointers in the summary, all from ONE auxiliary summarizer call; ~3x fewer retained tokens after compaction) or "legacy" (0.20×threshold verbatim tail)
  protect_last_n: 20                                # Min recent messages to keep uncompressed
  protect_first_n: 3                                # Non-system head messages pinned across compactions (0 = pin nothing)
  in_place: true                                    # Compact on the same session id (no rotation) — see below
  idle_compact_after_seconds: 0                     # Opt-in idle compaction (0 = disabled) — see below
  hygiene_hard_message_limit: 5000                  # Gateway safety valve — see below
  hygiene_timeout_seconds: 30                       # Max seconds of NO summary-model output before hygiene compression is cut off
  hygiene_total_ceiling_seconds: 600                # Absolute cap on the hygiene wait even while tokens are still streaming
  hygiene_max_turn_hold_seconds: 10                 # Max wall-clock the incoming turn waits on hygiene compression before proceeding uncompressed — see below
  hygiene_failure_cooldown_seconds: 300             # First rung of the per-session hygiene-failure backoff (x1/x3/x9, capped at 1h)
  context_timeout_seconds: 120                      # Inactivity budget for in-agent compress_context (loop /compress / preflight) — see below
  context_total_ceiling_seconds: 600                # Absolute cap on the *pre-commit* in-agent compress_context wait even while tokens are still streaming (an already-started SessionDB commit is never abandoned; overruns are logged + surfaced)
  proactive_prune_tokens: 0                         # Opt-in tokens trigger for the no-LLM tool-result prune (0 = off; see below)
  proactive_prune_min_result_chars: 8000            # Prune's summarize pass only touches tool results larger than this (clamped >= 200)
  proactive_prune_min_reclaim_tokens: 4096          # Prune only commits when it reclaims at least this many tokens (0 = commit any)

# The summarization model/provider is configured under auxiliary:
auxiliary:
  compression:
    model: ""                                       # Empty = use main chat model. Override with e.g. "google/gemini-3-flash-preview" for cheaper/faster compression.
    provider: "auto"                                # Provider: "auto", "openrouter", "nous", "codex", "main", etc.
    base_url: null                                  # Custom OpenAI-compatible endpoint (overrides provider)

:::info Legacy config migration Older configs with compression.summary_model, compression.summary_provider, and compression.summary_base_url are automatically migrated to auxiliary.compression.* on first load (config version 17). No manual action needed. :::

progress_notices (default false) controls whether routine compression progress statuses reach chat platforms (Telegram, Discord, Slack, etc.). By design, automatic compression is silent on chat surfaces — it runs in the background with server-side logging only. Set progress_notices: true to opt into seeing the routine lifecycle on chat platforms: the "Compacting context…" start notice, preflight/pre-API compression triggers, idle compaction, retry progress ("Compressed 30 → 12 messages, retrying…"), and the "Context compaction complete" notice. The gate is scoped to compression statuses only — unrelated operational noise (auxiliary model failures, provider rate-limit/retry chatter) stays suppressed either way. Compression failure notices and manual /compress feedback are always visible regardless of this setting. Editing this value on a running gateway takes effect on the next message.

hygiene_hard_message_limit is a gateway-only pre-compression safety valve. It exists to break a death spiral: when API calls keep disconnecting on an oversized session, the gateway never receives token-usage data, so the token-based threshold can't fire, so the transcript keeps growing and disconnects get worse. This count-based floor fires on message count alone (always known, regardless of API failures) to force compression and recover the session. Default 5000 — far above any normal session, including large-context (1M+) models doing thousands of short turns, which compress on the token threshold long before this. Raise it further for unusual platforms, lower it to force more aggressive compression. Editing this value on a running gateway takes effect on the next message (see below).

hygiene_timeout_seconds is the gateway's inactivity budget for this pre-agent compression pass — not a total wall-clock cap. The compression summary call streams from the model, and each arriving token counts as forward progress: a slow reasoning model that is still generating keeps extending its own deadline, so slow-but-healthy summary models are never cut off mid-generation. Only when the summary model produces no output for this many seconds (backend down, hung connection, silent provider) does the gateway warn the user, continue the incoming message without compression, and record a temporary per-session failure cooldown instead of appearing stuck.

hygiene_total_ceiling_seconds (default 600) bounds the total wait even while tokens are still moving, so a degenerate trickle stream can't hold a turn hostage indefinitely. It is clamped to at least hygiene_timeout_seconds.

hygiene_max_turn_hold_seconds (default 10) is the gateway's turn-hold budget — the maximum wall-clock the incoming message is held waiting on hygiene compression before the gateway stops waiting and proceeds on the uncompressed transcript. It exists because hygiene_total_ceiling_seconds alone can leave the wire silent for far longer than a chat transport's idle-timeout: a summary model that keeps streaming tokens keeps resetting the inactivity slice, so without a turn-hold budget the wait can stretch toward the ceiling while zero bytes reach the user — Telegram (and similar transports) then drop the connection and the turn appears frozen. Capping the turn's wait at this budget (well under the typical ~30s transport idle-timeout) guarantees the message is answered promptly. The compression is not lost when the budget expires: the worker keeps running detached and — when its commit is watermark-fenced (the normal case with a session DB) — it keeps its commit admission, so the finished summary is adopted at the next safe boundary and turns appended after the wait was abandoned survive verbatim as concurrent tail. This matters especially for thinking/reasoning summary models (DeepSeek, QwQ, etc.) whose reasoning phase alone can exceed the budget: their summaries land one turn late instead of never. If the commit cannot be safely fenced, the late result is discarded (CompressionCommitFence) and it cannot overwrite newer turns. Raise the budget if you'd rather have compression apply within the same turn and your transport tolerates the wait; lower it for snappier recovery on very slow backends.

hygiene_failure_cooldown_seconds controls that per-session cooldown after a hygiene compression timeout or abort. During the cooldown, the gateway skips repeated hygiene attempts for the same oversized session so every incoming message does not block on the same broken auxiliary backend. /compress, /reset, or a healthy later turn can still recover the session.

The value is the first rung of an escalating ladder, not a fixed interval: consecutive failures for the same session wait 1x, 3x, then 9x this value, capped at one hour. A session whose summary model is permanently broken therefore backs off instead of retrying forever on a fixed interval, and a run that actually shrinks the transcript resets it to the first rung. Escalation is per-session and process-local — a gateway restart resets it to the first rung while the cooldown deadline itself survives.

context_timeout_seconds (default 120) is the same inactivity budget for in-agent compress_context — the conversation loop, preflight compaction, and manual /compress — so a hung summary model cannot stall a session indefinitely. Streamed summary tokens extend the wait; only a silent worker is cut off. On timeout Hermes retries the summary once against the first entry of auxiliary.compression.fallback_chain (using that entry's own timeout when it declares one) — a stalled route never raises, so the auxiliary client's own fallback handling cannot see it. Only if that attempt also fails, or no fallback chain is configured, does Hermes skip compaction, keep the existing messages, and warn the user. Set to 0 to disable. Gateway session hygiene keeps its own hygiene_timeout_seconds path and is not double-wrapped.

context_total_ceiling_seconds (default 600) bounds the in-agent pre-commit wait (summary / stream phase) even while tokens are still moving. It is clamped to at least context_timeout_seconds. The exact guarantee: the summary phase is bounded by this ceiling; the commit phase is logged and surfaced if it exceeds it. Once the worker has entered the compression commit fence and SessionDB mutation is in flight, the commit is never abandoned mid-flight — that would risk transcript divergence — but the wait is no longer silent: if the commit runs past the ceiling, Hermes logs the overrun (WARNING, escalating to ERROR on repeat), sends a one-shot warning through the user-visible warning channel, and keeps waiting in bounded increments until the commit completes. When the ceiling expires during the summary phase, the summary model's stream is closed at that same instant on every auxiliary wire (chat.completions, Codex Responses, Anthropic Messages) — an abandoned summary is not billed to completion on a connection nobody is waiting for, and its session lease is freed for the next attempt.

protect_first_n controls how many non-system head messages are pinned across every compaction. Default 3 — the opening user/assistant exchange survives every summarizer pass so the original goal stays visible. On long-running rolling-compaction sessions where the opening turn is no longer relevant, set protect_first_n: 0 to pin nothing but the system prompt + summary + tail. The system prompt itself is always preserved regardless of this setting.

in_place (default true) controls what happens to the session identity when compaction fires. When true, compaction rewrites the message list and rebuilds the system prompt without rotating the session id — the conversation keeps one durable id for its whole life (no parent_session_id chain, no name #2 / #3 renumbering in session lists). Compaction is non-destructive: the live context is compacted, but the pre-compaction turns are soft-archived under the same id (marked inactive/compacted) — still searchable via session_search and recoverable, not deleted. Hooks see the mode via the in_place field on the session:compress event. Set in_place: false to restore the legacy behavior where each compaction rotates to a new session id linked to the old one.

threshold_tokens sets an optional absolute token cap for the compression trigger. When set, compression fires at the lower of the ratio-based threshold and this absolute count — so compression never fires later than the user's preferred token number regardless of which model is active. This solves the problem where switching between models with different context windows (e.g. 1M → 400K) shifts the absolute trigger point. The cap is clamped to the model's context length, so setting it higher than the model supports is safe — the ratio-based threshold is used instead. Default null (disabled — ratio-based threshold only). The cap survives model switches and fallback activations.

idle_compact_after_seconds is an opt-in, time-based trigger that complements the size-based threshold. Default 0 (disabled). When set above 0, a session that resumes after at least that many seconds of inactivity compacts its accumulated history up front, before the first reply — so a long-lived thread (e.g. a Telegram conversation you come back to hours later) doesn't re-read its full stale context on every subsequent turn. It never fires when the context is already at or below the post-compression target (threshold × target_ratio), and it honors the same failure-cooldown, anti-thrash, and per-session lock guards as every automatic compaction. Example: idle_compact_after_seconds: 1800 compacts after 30 minutes idle.

proactive_prune_tokens enables a deterministic, no-LLM prune of old tool-result payloads that runs independently of threshold. On large-window models the threshold compaction (≈50% of the window) rarely fires, so bulky tool outputs (terminal dumps, file reads, web extracts) ride along in history and get re-sent on every subsequent turn. When re-sent history exceeds proactive_prune_tokens (default 0 = off; try 48000 to enable), the prune dedupes identical results, summarizes older oversized ones, and truncates large tool-call arguments — protecting the most recent protect_last_n messages and never calling the model. Full outputs stay recoverable from the session store. proactive_prune_min_result_chars (default 8000, clamped to ≥ 200) sets the size below which a tool result is left untouched. proactive_prune_min_reclaim_tokens (default 4096) prevents a prune from committing unless it reclaims at least that many tokens — a committed prune rewrites already-sent history and invalidates the provider's prompt-cache prefix, so this gate keeps those cache breaks episodic and amortized (one meaningful break, like a compression boundary) instead of firing on every tool iteration. This runs only under the built-in compressor engine; other context engines inherit a no-op.

:::tip Gateway hot-reload of compression and context length As of recent releases, editing model.context_length or any compression.* key in config.yaml on a running gateway takes effect on the next message — no gateway restart, no /reset, no session rotation required. The cached-agent signature includes these keys, so the gateway transparently rebuilds the agent when it sees a change. API keys and tool/skill config still require the usual reload paths. :::

Common setups

Default (auto-detect) — no configuration needed:

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):

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.):

auxiliary:
  compression:
    model: glm-4.7
    base_url: https://api.z.ai/api/coding/paas/v4

Points at a custom OpenAI-compatible endpoint. Uses OPENAI_API_KEY for auth.

How the three knobs interact

auxiliary.compression.provider auxiliary.compression.base_url Result
auto (default) not set Auto-detect best available provider
nous / openrouter / etc. not set Force that provider, use its auth
any set Use the custom endpoint directly (provider ignored)

:::warning Summary model context length requirement The summary model must have a context window at least as large as your main agent model's. The compressor sends the full middle section of the conversation to the summary model — if that model's context window is smaller than the main model's, the summarization call will fail with a context length error. When this happens, the middle turns are dropped without a summary, losing conversation context silently. If you override the model, verify its context length meets or exceeds your main model's. :::

Gateway Turn Lease Timeout

The gateway serializes turns by their resolved session ID so two routing keys cannot load and write the same transcript concurrently. Configure the maximum lease wait independently of the ordinary agent inactivity timeout:

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.
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.
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.

agent:
  agent_cache:
    max_size: 128            # LRU entry cap
    idle_ttl_secs: 3600      # evict an agent idle this long
    memory_high_mb: auto     # anon-RSS budget; number, "auto", or 0/off
    max_evictions_per_pass: 16
    protect_recent: 8

max_size and idle_ttl_secs bound the cache by count and by time. Neither knows how many bytes it holds, so memory_high_mb adds a third bound: once the gateway's own anonymous resident memory crosses the budget, it sheds least-recently-used transcripts, which reload from the stored session on the next turn. Lower it if the gateway is competing for memory with other services; raise it (or set 0 to switch the pass off) if you would rather keep every prefix warm.

auto derives the budget from the memory limit the gateway actually runs under — the cgroup limit for a container or systemd unit, total RAM otherwise — so a MemoryMax/MemoryHigh on the unit is respected without a second number to keep in sync.

Sessions that are mid-turn, the protect_recent most recently used ones, and any session whose transcript has not finished being written to disk are never shed. Eviction is logged at WARNING with the measured RSS and the sessions dropped:

Agent cache pressure: anon RSS 6802MB over budget 6656MB — evicting 5 LRU session(s): ...

Context Engine

The context engine controls how conversations are managed when approaching the model's token limit. The built-in compressor engine uses lossy summarization (see Context Compression). Plugin engines can replace it with alternative strategies.

context:
  engine: "compressor"    # default — built-in lossy summarization

To use a plugin engine (e.g., LCM for lossless context management):

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 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.

agent:
  max_turns: none              # Iterations per conversation turn (default: none = unlimited)
                               # Set a positive integer to cap; "none"/"null"/
                               # "unlimited"/"inf"/"infinity"/"infinite"/0/-1 = no limit
  api_max_retries: 3           # Retries per provider before fallback engages (default: 3)

agent.max_turns is unlimited by default — the turn cap caused more problems than it solved (silent mid-task truncation), so out of the box Hermes runs a conversation turn to completion. To impose a cap, set a positive integer. To be explicit about "no limit", any of these case-insensitive spellings work: "none", "null", "unlimited", "infinite", "infinity", "inf", 0, -1 (they resolve to a sys.maxsize sentinel so the loop never exits on a turn count).

agent.api_max_retries controls how many times Hermes retries a provider API call on transient errors (rate limits, connection drops, 5xx) before fallback-provider switching engages. The default is 3 — four attempts total. If you have fallback providers configured and want to fail over faster, drop this to 0 so the first transient error on your primary immediately hands off to the fallback instead of churning retries against the flaky endpoint.

Wall-Clock Run Budget

Separate from the iteration budget, you can give each conversation run an optional wall-clock budget. This is designed for one-shot and eval-harness invocations that run under a hard external ceiling (e.g. a 900-second per-task limit): without it, a run can time out with the work essentially done — one generation short of emitting the final answer, or stuck in a single hung provider call.

agent:
  run_budget_seconds: null     # Optional; unset/null = feature fully off (default)

Or per-invocation via the CLI:

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.

agent:
  verify_on_stop: false        # true | false | "auto" (surface-aware: on for CLI/TUI/desktop, off for messaging)
  verify_guidance: true        # Append creative-UI / clean-diff guidance to the missing-evidence nudge
  max_verify_nudges: 3         # Cap on consecutive continue nudges per turn (built-in + pre_verify hooks)
  coding_instructions: ""      # Standing project-wide coding rules appended to the coding brief

verify_on_stop accepts true (on everywhere), false (off — the default), or "auto" (legacy surface-aware behavior: on for interactive coding surfaces — CLI, TUI, desktop — and programmatic callers; off for messaging surfaces like Telegram/Discord where the verification narrative reads as chat noise). Off is the default everywhere: fresh installs ship false and the config migration turned it off on existing installs, so enabling it is an explicit opt-in. The HERMES_VERIFY_ON_STOP env var overrides the config value when set.

For a user/plugin policy gate at the same point — keep the agent going with your own checks — see the pre_verify hook.

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.

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 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.<id>.stale_timeout_seconds or HERMES_API_CALL_STALE_TIMEOUT
API call (non-streaming) 1800s Unchanged providers.<id>.request_timeout_seconds / timeout_seconds or HERMES_API_TIMEOUT

The socket read timeout controls how long httpx waits for the next chunk of data from the provider. Local LLMs can take minutes for prefill on large contexts before producing the first token, so Hermes raises this to 30 minutes when it detects a local endpoint. If you explicitly set HERMES_STREAM_READ_TIMEOUT, that value is always used regardless of endpoint detection.

The stale stream detection kills connections that receive SSE keep-alive pings but no actual content. For local providers (which don't send keep-alive pings during prefill) the default is raised to a finite 900-second ceiling instead of the 180s base — configurable via agent.local_stream_stale_timeout or the HERMES_LOCAL_STREAM_STALE_TIMEOUT env var.

The stale non-stream detection kills non-streaming calls that produce no response for too long. By default Hermes disables this on local endpoints to avoid false positives during long prefills. If you explicitly set providers.<id>.stale_timeout_seconds, providers.<id>.models.<model>.stale_timeout_seconds, or HERMES_API_CALL_STALE_TIMEOUT, that explicit value is honored even on local endpoints.

This budget bounds every non-streaming call. A provider that accepts a request and then goes silent — connection held open, no bytes, no error — is aborted at the stale timeout and retried, rather than hanging until the much longer socket read timeout (or, for an unattended cron run, until something external kills the process).

Cron jobs and delegated subagents stream too. They run the request inline on their own thread (the interrupt worker other sessions use wedges inside the gateway's nested thread pools), but the wire request is still stream: true, so the stale stream detection budget above governs them — every token counts as liveness, so a reasoning model that thinks for minutes is not mistaken for a hung provider, and edge proxies that kill silent connections keep seeing bytes.

Disabling API streaming

model.streaming: false forces non-streaming requests for the whole session — parent and subagents alike. It is an escape hatch for self-hosted OpenAI-compatible servers whose streaming tool-call path is broken (for example vLLM with --tool-call-parser qwen3_xml plus a reasoning parser can leak tool-call markup into plain text and return zero tool_calls, so delegated tasks silently no-op). Default is true; leave it unless you hit that class of bug, since non-streaming calls lose the liveness properties described above. This is separate from display.streaming, which only controls token rendering in the terminal.

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:

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 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.

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:

prompt_caching:
  cache_ttl: "5m"   # "5m" or "1h" (Anthropic-supported tiers); other values are ignored

cache_ttl selects the breakpoint TTL Hermes attaches for Claude via the native Anthropic API, OpenRouter, and Nous Portal. Only the two Anthropic-supported tiers ("5m", "1h") are honored — any other value is ignored. Providers with their own caps (e.g. Qwen Cloud, which maxes at 5 minutes) still clamp to what the upstream allows.

Auxiliary Models

Hermes uses "auxiliary" models for side tasks like image analysis, browser screenshot analysis, session-title generation, and context compression. By default (auxiliary.*.provider: "auto"), Hermes routes every auxiliary task to your main chat model — the same provider/model you picked in hermes model. You don't need to configure anything to get started, but be aware that on expensive reasoning models (Opus, MiniMax M2.7, etc.) auxiliary tasks add meaningful cost. If you want cheap-and-fast side tasks regardless of your main model, set auxiliary.<task>.provider and auxiliary.<task>.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 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.<task>.* in config.yaml. Same machinery as the main-model picker — no extra syntax to learn.

The Delegation entry is special: it routes the model used by delegate_task subagents and persists to the top-level delegation.* section (delegation.provider / delegation.model) rather than auxiliary.*, because subagents are full child agents, not side-LLM calls. Its auto means "inherit the parent agent's provider, model, and credentials."

If you do not want Hermes to auto-generate titles after the first exchange, set auxiliary.title_generation.enabled: false. Manual titles still work through /title and hermes sessions rename.

Stream-only endpoints

Some OpenAI-compatible endpoints reject non-streaming chat requests outright (e.g. Tencent Copilot returns HTTP 400 "Non-stream chat request is currently not supported"). Interactive chat already streams, but auxiliary tasks (title generation, compression, vision) use non-streaming calls and would fail on every attempt. Hermes always treats copilot.tencent.com as stream-only; for any other such endpoint, list a URL substring under auxiliary.stream_only_base_urls:

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