# Hermes Agent — Full Documentation
This file is the entire Hermes Agent documentation concatenated for LLM context ingestion. Section order reflects docs-site navigation: Getting Started, Using Hermes, Features, Messaging, Integrations, Guides, Developer Guide, Reference, then everything else.
Canonical site: https://hermes-agent.nousresearch.com/docs
Short index: https://hermes-agent.nousresearch.com/docs/llms.txt
---
# Installation
# Installation
Get Hermes Agent up and running in under two minutes!
:::tip Platform Support
For the full platform support matrix (which OSes, distribution methods, and
platform-gated features are supported), see **[Platform Support](./platform-support.md)**.
:::
## Quick Install
### Desktop packages on macOS or Windows
Download the package for your platform from the
[Hermes website](https://hermes-agent.nousresearch.com/).
- **Windows:** open the `.appinstaller` download with Windows App Installer.
It installs the signed MSIX bundle and records its update source.
Microsoft Store packages have separate Store ownership.
- **macOS:** open the DMG, then copy `Hermes.app` to Applications. The ZIP
artifact carries the signed app used by the automatic updater.
Bundled packages contain the agent, Python, supported dependencies, and prebuilt
interfaces. First launch does not build that base runtime. Provider access and
optional integrations can still require network access.
A `Hermes-Setup` bootstrap installer is different: it downloads a source
installation and builds the desktop app. Light is a remote-only build variant,
not a bundled local runtime. See [Hermes Desktop](../user-guide/desktop.md).
:::note
The macOS installer is **Apple Silicon only**. macOS on x86 (Intel) processors is [not a supported platform](./platform-support.md#unsupported).
:::
### Without Hermes Desktop:
For a command-line only install without Hermes Desktop, run:
#### Linux / macOS / WSL2
```bash
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
```
#### Windows (native)
Run in powershell:
```powershell
iex (irm https://hermes-agent.nousresearch.com/install.ps1)
```
If you want to install & run Hermes Desktop after a command-line only install, simply run
```bash
hermes desktop
```
### Android / Termux
Use the [Termux APT package](./termux.md) on aarch64 Android devices.
Configure its signed repository before running `pkg install hermes-agent`.
The desktop/server scripts are not the Termux installation path.
### What the source installer does
The scripts clone the source, bootstrap uv, and delegate dependency preparation
to PM. PM provides pinned Python, Node.js, npm, ripgrep, and FFmpeg. The source
installation selects the `all` Python extra, not every optional extra.
PM also installs the browser tools (`agent-browser` and its pinned Chromium) by
default. If that download fails, the install still completes and prints the
command to retry. Other optional tools use their feature-specific installation
paths.
To leave the browser tools out, pass `--skip-browser` on POSIX or `-SkipBrowser`
on Windows. Hermes remembers this choice: later installs and `hermes update` do
not add them back. Run `hermes pm install agent-browser` to install them and
undo the choice.
The scripts create a launcher and prepare the data directory. Interactive runs
also invoke setup and gateway configuration. `--non-interactive` on POSIX, or
`-NonInteractive` on Windows, skips stages that need input. The optional
`--include-desktop` / `-IncludeDesktop` stage builds the desktop from source.
On a terminal the scripts show one status line per step and write the output
of git, uv and the builds to `logs/install.log` under the Hermes data
directory; a failed step prints its last lines and the log path. CI (`CI` or
`GITHUB_ACTIONS` set), redirected output, `--verbose` / `-Verbose` or
`HERMES_INSTALL_VERBOSE=1` stream everything instead.
#### Install layout
| Method | Code | CLI entry point | Default user data |
|---|---|---|---|
| POSIX source script | `~/.hermes/hermes-agent/` | `~/.local/bin/hermes` wrapper | `~/.hermes/` |
| Windows source script | `%LOCALAPPDATA%\hermes\hermes-agent\` | `%LOCALAPPDATA%\hermes\bin\` | `%LOCALAPPDATA%\hermes\` |
| Desktop bundle | Inside the installed app package | Packaged launchers; Windows execution aliases | Platform default Hermes data directory |
| Docker | `/opt/hermes/` | Image entrypoint and `hermes` shim | Mounted `/opt/data/` |
| Termux APT | `$PREFIX/lib/hermes-agent/` | Symlinks in `$PREFIX/bin/` | `~/.hermes/` |
`HERMES_HOME` selects user data. The POSIX script's `--dir` selects its source
checkout independently. Windows provides `-HermesHome` and `-InstallDir`.
Running the POSIX script as root does not select an automatic FHS layout:
it uses root's home unless you provide an explicit source path.
PM's tool store and per-install Python generations have separate lifetimes.
See [Package management](../reference/package-management.md) for their locations.
Do not remove the data root to repair an application installation.
### After Installation
Reload your shell and start chatting:
```bash
source ~/.bashrc # or: source ~/.zshrc
hermes # Start chatting!
```
To reconfigure individual settings later, use the dedicated commands:
```bash
hermes model # Choose your LLM provider and model
hermes tools # Configure which tools are enabled
hermes gateway setup # Set up messaging platforms
hermes config set # Set individual config values
hermes config get # Inspect individual config values
hermes setup # Or run the full setup wizard to configure everything at once
```
:::tip Fastest path: Nous Portal
One subscription covers 300+ models plus the [Tool Gateway](../user-guide/features/tool-gateway.md) (web search, image generation, TTS, cloud browser). Skip the per-tool key juggling:
```bash
hermes setup --portal
```
That logs you in, sets Nous as your provider, and turns on the Tool Gateway in one command.
:::
:::tip Already running Hermes on another machine?
You don't need to rebuild your setup from scratch. Restore a full backup with `hermes import` (see [Exporting Hermes to another machine](../reference/faq.md#exporting-hermes-to-another-machine)), or bring over a single agent with `hermes profile import` (see [Moving a single profile to another machine](../reference/faq.md#moving-a-single-profile-to-another-machine)). Note that a profile export excludes credentials by design, so an export alone is not a full backup — [`hermes backup` vs `hermes profile export`](../reference/faq.md#hermes-backup-vs-hermes-profile-export) explains which to use.
:::
---
## Prerequisites
For the POSIX source script, provide Git, curl, tar, and SHA-256 utilities.
Windows can bootstrap its pinned Git for Windows archive when Git is absent.
An existing uv can bootstrap PM; otherwise the script downloads its verified pin.
Current first-party installations run on **Python 3.14**. The broader
`>=3.11,<3.15` range in `pyproject.toml` lets older Python installations
run the updater before PM switches them to 3.14; it does not promise current
runtime support on 3.11–3.13. PM selects the managed tool versions from
`pm/lock.json`; it does not adopt arbitrary system Node versions as the
installed runtime.
Source builds can require a native compiler and platform development libraries.
Building Electron from source adds Node native-module requirements. These
build prerequisites do not apply to installing a complete desktop package.
Linux Chromium also requires system libraries supplied by the distribution.
:::tip Nix users
Nix is **no longer an explicitly supported install path** (best-effort only). If you already use Nix (on NixOS, macOS, or Linux), there's a dedicated setup path with a Nix flake, declarative NixOS module, and optional container mode. See the **[Nix & NixOS Setup](./nix-setup.md)** guide.
:::
---
## Manual / Developer Installation
For a source checkout, start with the
[PM developer workflow](../reference/package-management.md#developer-workflow).
It covers activation, daily commands, dependency refresh, and current bootstrap limits.
[Development Setup](../developer-guide/contributing.md#development-setup) covers the separate test environment and checks.
---
## Non-Sudo / System Service User Installs
Run the source installer as the intended service user. Its home, tool store,
configuration, and launcher must belong to that user.
1. As an administrator, install the source-build prerequisites and any Linux
libraries needed by the selected browser backend.
2. As the service user, run the regular installer:
```bash
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
```
3. Add the actual launcher directory to the service user's shell environment:
```bash
export PATH="$HOME/.local/bin:$PATH"
```
4. Run `hermes doctor` from that account. Use the installed wrapper, not a
hardcoded `venv/bin/hermes` path.
5. For a Linux user service that must survive logout, enable lingering as an administrator:
```bash
sudo loginctl enable-linger SERVICE_USER
```
The current source installer does not run Playwright's `--with-deps` step or
provide a package-manager-specific sudo fallback. PM manages tool binaries;
the administrator supplies system libraries. See
[Browser automation](../user-guide/features/browser.md) and
[Messaging Gateway](../user-guide/messaging/index.md).
---
## Troubleshooting
| Problem | Solution |
|---------|----------|
| `hermes: command not found` | Reload your shell (`source ~/.bashrc`) or check PATH |
| `API key not set` | Run `hermes model` to configure your provider, or `hermes config set OPENROUTER_API_KEY your_key` |
| Missing config after update | Run `hermes config check` then `hermes config migrate` |
For more diagnostics, run `hermes doctor` — it will tell you exactly what's missing and how to fix it.
### Symlinked home directories and external storage
Hermes supports a symlinked `HERMES_HOME` and symlinked home subdirectories,
including `hooks`, `skills`, `sessions`, and `logs`. During home initialization,
existing directory links are preserved, and permissions on linked directories
(and descendants such as `logs/curator`) are left to their owner.
If a link target is missing, inaccessible, or not a directory, initialization
stops with a storage error naming the path and link target. Hermes does **not**
replace the link or create its missing target: doing so could write data onto
the local disk while an external or NAS volume is unmounted. Check the reported
link, restore the mount or correct its target, and verify access permissions
before retrying. For a deliberately new dotfiles target, create it yourself only
after confirming the intended storage is available.
`hermes doctor` reports these failures as storage problems, not invalid YAML.
Keep your existing `config.yaml`; running `hermes setup` is not the repair for an
unavailable directory. This is a directory-availability check, not a mount monitor:
an existing directory cannot establish that the intended volume is mounted.
## Install method auto-detection
The update owner depends on the running installation, not only its data home.
Source checkouts use the managed Git update path. Desktop bundles, Docker,
Nix, and Termux packages retain their package owner's update mechanism.
`hermes doctor` reports installation provenance. See
[Updating & Uninstalling](./updating.md) before changing package-owned files.
---
# Hermes Agent Quickstart
# Hermes Agent Quickstart
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.
This guide gets you from zero to a working Hermes setup that survives real use. Install, choose a provider, verify a working chat, and know exactly what to do when something breaks.
## Prefer to watch?
**Onchain AI Garage** put together a Masterclass walkthrough of installation, setup, and basic commands — a good companion to this page if you'd rather follow along on video. For more, see the full [Hermes Agent Tutorials & Use Cases](https://www.youtube.com/playlist?list=PLmpUb_PWAkDxewld5ZYyKifuHxgIbiq2d) playlist.
## Who this is for
- Brand new and want the shortest path to a working setup
- Switching providers and don't want to lose time to config mistakes
- Setting up Hermes for a team, bot, or always-on workflow
- Tired of "it installed, but it still does nothing"
## The fastest path
Pick the row that matches your goal:
| Goal | Do this first | Then do this |
|---|---|---|
| I just want Hermes working on my machine | `hermes setup` | Run a real chat and verify it responds |
| I already know my provider | `hermes model` | Save the config, then start chatting |
| I want a bot or always-on setup | `hermes gateway setup` after CLI works | Connect Telegram, Discord, Slack, or another platform |
| I want a local or self-hosted model | `hermes model` → custom endpoint | Verify the endpoint, model name, and context length |
| I want multi-provider fallback | `hermes model` first | Add routing and fallback only after the base chat works |
**Rule of thumb:** if Hermes cannot complete a normal chat, do not add more features yet. Get one clean conversation working first, then layer on gateway, cron, skills, voice, or routing.
---
## 1. Install Hermes Agent
### With the Hermes Desktop installer on macOS or Windows (recommended)
To easily install the command-line and desktop applications, [download the Hermes Desktop installer](https://hermes-agent.nousresearch.com/) from our website and run it.
### Without Hermes Desktop:
For a command-line only install without Hermes Desktop, run:
For aarch64 Android devices, use the separate [Termux APT guide](./termux.md).
#### Linux / macOS / WSL2
```bash
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
```
#### Windows (native)
Run in powershell:
```powershell
iex (irm https://hermes-agent.nousresearch.com/install.ps1)
```
After it finishes, reload your shell:
```bash
source ~/.bashrc # or source ~/.zshrc
```
For detailed installation options, prerequisites, and troubleshooting, see the [Installation guide](./installation.md).
## 2. Choose a Provider
The single most important setup step. Use `hermes model` to walk through the choice interactively:
```bash
hermes model
```
:::tip Easiest path: Nous Portal
One subscription covers 300+ models plus the [Tool Gateway](../user-guide/features/tool-gateway.md) (web search, image generation, TTS, cloud browser). On a fresh install:
```bash
hermes setup --portal
```
That logs you in, sets Nous as your provider, and turns on the Tool Gateway in one command.
:::
:::info Setup modes
On a fresh install, `hermes setup` offers three modes:
- **Quick Setup (Nous Portal)** — OAuth login, no API keys to manage; sets up a model plus the Tool Gateway tools, billed to your [Nous Portal subscription](../integrations/nous-portal.md). The recommended fast path.
- **Full Setup** — walk through every provider, tool, and option yourself (bring your own keys).
- **Blank Slate** — everything starts **off** except the bare minimum needed to run an agent: **provider & model, the File Operations toolset, and the Terminal toolset**. No web, browser, code execution, vision, memory, delegation, cron, skills, plugins, or MCP servers — and compression, checkpoints, smart routing, and memory capture are all disabled. After the minimal baseline is applied, you choose one of two paths: **start with everything disabled** (finish now with the minimal agent), or **walk through all configurations** (opt in to tools, skills, plugins, MCP, and messaging). Pick this when you want a minimal, fully-controlled agent and intend to enable only exactly what you need.
Blank Slate writes an explicit `platform_toolsets.cli` list plus `agent.disabled_toolsets`, so nothing you didn't choose ever loads — not even after `hermes update`. Re-enable anything later with `hermes tools`, seed skills with `hermes skills opt-in --sync`, or tune settings with `hermes setup agent`.
:::
Good defaults:
| Provider | What it is | How to set up |
|----------|-----------|---------------|
| **Nous Portal** | Subscription-based, zero-config | OAuth login via `hermes model` |
| **OpenAI Codex** | ChatGPT or Codex subscription, uses Codex models | Device code auth via `hermes model` → **ChatGPT or Codex Subscription** |
| **Anthropic** | Claude models directly — Max plan + extra usage credits (OAuth), or API key for pay-per-token | `hermes model` → OAuth login (requires Max + extra credits), or an Anthropic API key |
| **OpenRouter** | Multi-provider routing across many models | Enter your API key |
| **Fireworks AI** | Direct OpenAI-compatible model API | Set `FIREWORKS_API_KEY` |
| **Z.AI** | GLM / Zhipu-hosted models | Set `GLM_API_KEY` / `ZAI_API_KEY` (also accepts `Z_AI_API_KEY`) |
| **Kimi / Moonshot** | Moonshot-hosted coding and chat models | Set `KIMI_API_KEY` (or the Kimi-Coding-specific `KIMI_CODING_API_KEY`) |
| **Kimi / Moonshot China** | China-region Moonshot endpoint | Set `KIMI_CN_API_KEY` |
| **Arcee AI** | Trinity models | Set `ARCEEAI_API_KEY` |
| **GMI Cloud** | Multi-model direct API | Set `GMI_API_KEY` |
| **Actual Computer** | Your own hardware as a private inference cluster — hosted relay or local daemon | Set `ACTUAL_API_KEY` (relay) or `ACTUAL_BASE_URL=http://127.0.0.1:8080` (local, no key) |
| **MiniMax (OAuth)** | MiniMax frontier model via browser OAuth — no API key needed (model name in `hermes_cli/models.py` may change between releases) | `hermes model` → MiniMax (OAuth) |
| **MiniMax** | International MiniMax endpoint | Set `MINIMAX_API_KEY` |
| **MiniMax China** | China-region MiniMax endpoint | Set `MINIMAX_CN_API_KEY` |
| **Alibaba Cloud** | Qwen models via DashScope | Set `DASHSCOPE_API_KEY` (Qwen Coding Plan also accepts `ALIBABA_CODING_PLAN_API_KEY`) |
| **Hugging Face** | 20+ open models via unified router (Qwen, DeepSeek, Kimi, etc.) | Set `HF_TOKEN` |
| **AWS Bedrock** | Claude, Nova, Llama, DeepSeek via native Converse API | IAM role or `aws configure` ([guide](../guides/aws-bedrock.md)) |
| **Azure Foundry** | Azure AI Foundry-hosted models | Set `AZURE_FOUNDRY_API_KEY` + `AZURE_FOUNDRY_BASE_URL` |
| **Google AI Studio** | Gemini models via direct API | Set `GOOGLE_API_KEY` / `GEMINI_API_KEY` |
| **xAI** | Grok models via direct API | Set `XAI_API_KEY` |
| **xAI Grok OAuth** | SuperGrok / Premium+ subscription, no API key needed | `hermes model` → xAI Grok OAuth |
| **NovitaAI** | Multi-model API gateway | Set `NOVITA_API_KEY` |
| **Ramp Router** | Responses-native LLM gateway routing across OpenAI/Anthropic/xAI/... | Set `RAMP_ROUTER_API_KEY` |
| **Nebius Token Factory** | Open models on Nebius AI cloud | Set `NEBIUS_API_KEY` |
| **StepFun** | Step Plan models | Set `STEPFUN_API_KEY` |
| **Xiaomi MiMo** | Xiaomi-hosted models | Set `XIAOMI_API_KEY` |
| **Tencent TokenHub** | Tencent-hosted models | Set `TOKENHUB_API_KEY` |
| **Tencent TokenPlan** | Tencent Hy models via Anthropic-style endpoint | Set `TOKENPLAN_API_KEY` |
| **Ollama Cloud** | Managed Ollama-hosted models | Set `OLLAMA_API_KEY` |
| **LM Studio** | Local desktop app exposing an OpenAI-compatible API | Set `LM_API_KEY` (and `LM_BASE_URL` if non-default) |
| **Qwen OAuth** | Qwen Portal browser OAuth — no API key needed | `hermes model` → Qwen OAuth |
| **Kilo Code** | KiloCode-hosted models | Set `KILOCODE_API_KEY` |
| **OpenCode Zen** | Pay-as-you-go access to curated models | Set `OPENCODE_ZEN_API_KEY` |
| **OpenCode Go** | $10/month subscription for open models | Set `OPENCODE_GO_API_KEY` |
| **DeepSeek** | Direct DeepSeek API access | Set `DEEPSEEK_API_KEY` |
| **NVIDIA NIM** | Nemotron models via build.nvidia.com or local NIM | Set `NVIDIA_API_KEY` (optional: `NVIDIA_BASE_URL`) |
| **GitHub Copilot** | GitHub Copilot subscription (GPT-5.x, Claude, Gemini, etc.) | OAuth via `hermes model`, or `COPILOT_GITHUB_TOKEN` / `GH_TOKEN` |
| **GitHub Copilot ACP** | Copilot ACP agent backend (spawns local `copilot` CLI) | `hermes model` (requires `copilot` CLI + `copilot login`) |
| **Vercel AI Gateway** | Vercel AI Gateway routing | Set `AI_GATEWAY_API_KEY` |
| **Custom Endpoint** | VLLM, SGLang, Ollama, or any OpenAI-compatible API | Set base URL + API key |
For most first-time users: choose a provider, accept the defaults unless you know why you're changing them. The full provider catalog with env vars and setup steps lives on the [Providers](../integrations/providers.md) page.
:::caution Minimum context: 64K tokens
Hermes Agent requires a model with at least **64,000 tokens** of context. Models with smaller windows cannot maintain enough working memory for multi-step tool-calling workflows and will be rejected at startup. Most hosted models (Claude, GPT, Gemini, Qwen, DeepSeek) meet this easily. If you're running a local model, set its context size to at least 64K (e.g. `--ctx-size 65536` for llama.cpp or `-c 65536` for Ollama).
:::
:::tip
You can switch providers at any time with `hermes model` — no lock-in. For a full list of all supported providers and setup details, see [AI Providers](../integrations/providers.md).
:::
### How settings are stored
Hermes separates secrets from normal config:
- **Secrets and tokens** → `~/.hermes/.env`
- **Non-secret settings** → `~/.hermes/config.yaml`
The easiest way to set values correctly is through the CLI:
```bash
hermes config set model anthropic/claude-opus-4.6
hermes config set terminal.backend docker
hermes config set OPENROUTER_API_KEY sk-or-...
```
The right value goes to the right file automatically.
## 3. Run Your First Chat
```bash
hermes # classic CLI
hermes --tui # modern TUI (recommended)
```
You'll see a welcome banner with your model, available tools, and skills. Use a prompt that's specific and easy to verify:
:::tip Pick your interface
Hermes ships with two terminal interfaces: the classic `prompt_toolkit` CLI and a newer [TUI](../user-guide/tui.md) with modal overlays, mouse selection, and non-blocking input. Both share the same sessions, slash commands, and config — try each with `hermes` vs `hermes --tui`.
:::
```
Summarize this repo in 5 bullets and tell me what the main entrypoint is.
```
```
Check my current directory and tell me what looks like the main project file.
```
```
Help me set up a clean GitHub PR workflow for this codebase.
```
**What success looks like:**
- The banner shows your chosen model/provider
- Hermes replies without error
- It can use a tool if needed (terminal, file read, web search)
- The conversation continues normally for more than one turn
If that works, you're past the hardest part.
## 4. Verify Sessions Work
Before moving on, make sure resume works:
```bash
hermes --continue # Resume the most recent session
hermes -c # Short form
```
That should bring you back to the session you just had. If it doesn't, check whether you're in the same profile and whether the session actually saved. This matters later when you're juggling multiple setups or machines.
## 5. Try Key Features
### Use the terminal
```
❯ What's my disk usage? Show the top 5 largest directories.
```
The agent runs terminal commands on your behalf and shows results.
### Slash commands
Type `/` to see an autocomplete dropdown of all commands:
| Command | What it does |
|---------|-------------|
| `/help` | Show all available commands |
| `/tools` | List available tools |
| `/model` | Switch models interactively |
| `/personality pirate` | Try a fun personality |
| `/save` | Save the conversation |
### Multi-line input
Press `Alt+Enter`, `Ctrl+J`, or `Shift+Enter` to add a new line. `Shift+Enter` requires a terminal that sends it as a distinct sequence (Kitty / foot / WezTerm / Ghostty by default; iTerm2 / Alacritty / VS Code terminal once the Kitty keyboard protocol is enabled). `Alt+Enter` and `Ctrl+J` work in every terminal.
### Interrupt the agent
If the agent is taking too long, type a new message and press Enter — it interrupts the current task and switches to your new instructions. `Ctrl+C` also works.
## 6. Add the Next Layer
Only after the base chat works. Pick what you need:
### Bot or shared assistant
```bash
hermes gateway setup # Interactive platform configuration
```
Connect [Telegram](../user-guide/messaging/telegram.md), [Discord](../user-guide/messaging/discord.md), [Slack](../user-guide/messaging/slack.md), [WhatsApp](../user-guide/messaging/whatsapp.md), [Signal](../user-guide/messaging/signal.md), [Email](../user-guide/messaging/email.md), or [Home Assistant](../user-guide/messaging/homeassistant.md), or [Microsoft Teams](../user-guide/messaging/teams.md).
### Automation and tools
- `hermes tools` — tune tool access per platform
- `hermes skills` — browse and install reusable workflows
- Cron — only after your bot or CLI setup is stable
### Sandboxed terminal
For safety, run the agent in a Docker container or on a remote server:
```bash
hermes config set terminal.backend docker # Docker isolation
hermes config set terminal.backend ssh # Remote server
```
For Docker sandboxes, you can also enable the **egress credential-injection proxy** so the sandbox never sees your real API keys — only opaque proxy tokens that work exclusively from behind a local TLS-intercepting daemon. See [Egress proxy](../user-guide/egress/iron-proxy.md). Setup is `hermes egress setup && hermes egress start`; `hermes setup terminal` also points Docker users at it. Modal, SSH, Daytona, and Singularity are not wired yet.
### Voice mode
Run `hermes tools` and configure the Voice providers. Then enable `/voice on`
in the CLI and press `Ctrl+B` to record. PM handles missing supported
requirements; a dependency change can require a restart. Local Faster-Whisper
is not available on every architecture. See [Voice Mode](../user-guide/features/voice-mode.md).
### Skills
Skills are on-demand instruction documents that teach Hermes how to do a specific task — deploy to Kubernetes, open a GitHub PR, fine-tune a model, search for GIFs. Each is a `SKILL.md` file with a name, a description, and a step-by-step procedure. The agent reads the short descriptions for free and only loads a skill's full content when a task actually calls for it, so adding skills doesn't bloat every request.
Hermes ships with a catalog of bundled skills already installed in `~/.hermes/skills/`. You can add more from the Skills Hub, or write your own.
**Browse and install from the hub:**
```bash
hermes skills browse # list everything available
hermes skills search kubernetes # find skills by keyword
hermes skills install openai/skills/k8s # install one (runs a security scan first)
```
The install argument is a `source/path` slug from the hub — `openai/skills/k8s` means the `k8s` skill from OpenAI's catalog. `hermes skills browse` shows the exact slugs to use.
**Use a skill** — every installed skill becomes a slash command automatically:
```bash
/k8s deploy the staging manifest # run the skill with a request
/k8s # load it and let Hermes ask what you need
```
This works in the CLI and in any connected messaging platform. You don't have to install everything up front — the agent picks the right bundled skill on its own during normal conversation when a task matches one.
See [Skills System](../user-guide/features/skills.md) for writing your own, external skill directories, and the full hub source list.
### MCP servers
```yaml
# Add to ~/.hermes/config.yaml
mcp_servers:
github:
command: npx
args: ["-y", "@modelcontextprotocol/server-github"]
env:
GITHUB_PERSONAL_ACCESS_TOKEN: "ghp_xxx"
```
### Editor integration (ACP)
ACP support ships with the standard `[all]` extras, so the curl installer already includes it. Just run:
```bash
hermes acp
```
(If you installed without `[all]`, run `cd ~/.hermes/hermes-agent && python -c "import pm; pm.sync_venv(['acp'], explicit=True)"` first.)
See [ACP Editor Integration](../user-guide/features/acp.md).
---
## Common Failure Modes
These are the problems that waste the most time:
| Symptom | Likely cause | Fix |
|---|---|---|
| Hermes opens but gives empty or broken replies | Provider auth or model selection is wrong | Run `hermes model` again and confirm provider, model, and auth |
| Custom endpoint "works" but returns garbage | Wrong base URL, model name, or not actually OpenAI-compatible | Verify the endpoint in a separate client first |
| Gateway starts but nobody can message it | Bot token, allowlist, or platform setup is incomplete | Re-run `hermes gateway setup` and check `hermes gateway status` |
| `hermes --continue` can't find old session | Switched profiles or session never saved | Check `hermes sessions list` and confirm you're in the right profile |
| Model unavailable or odd fallback behavior | Provider routing or fallback settings are too aggressive | Keep routing off until the base provider is stable |
| `hermes doctor` flags config problems | Config values are missing or stale | Fix the config, retest a plain chat before adding features |
## Recovery Toolkit
When something feels off, use this order:
1. `hermes doctor`
2. `hermes model`
3. `hermes setup`
4. `hermes sessions list`
5. `hermes --continue`
6. `hermes gateway status`
That sequence gets you from "broken vibes" back to a known state fast.
---
## Quick Reference
| Command | Description |
|---------|-------------|
| `hermes` | Start chatting |
| `hermes model` | Choose your LLM provider and model |
| `hermes tools` | Configure which tools are enabled per platform |
| `hermes setup` | Full setup wizard (configures everything at once) |
| `hermes doctor` | Diagnose issues |
| `hermes update` | Update to latest version |
| `hermes gateway` | Start the messaging gateway |
| `hermes --continue` | Resume last session |
## Next Steps
- **[CLI Guide](../user-guide/cli.md)** — Master the terminal interface
- **[Configuration](../user-guide/configuration.md)** — Customize your setup
- **[Messaging Gateway](../user-guide/messaging/index.md)** — Connect Telegram, Discord, Slack, WhatsApp, Signal, Email, Home Assistant, Teams, and more
- **[Tools & Toolsets](../user-guide/features/tools.md)** — Explore available capabilities
- **[AI Providers](../integrations/providers.md)** — Full provider list and setup details
- **[Skills System](../user-guide/features/skills.md)** — Reusable workflows and knowledge
- **[Tips & Best Practices](../guides/tips.md)** — Power user tips
- **[Moving to another machine](../reference/faq.md#exporting-hermes-to-another-machine)** — `hermes backup` migrates your whole setup (or [a single profile](../reference/faq.md#moving-a-single-profile-to-another-machine)); no need to rebuild from scratch
---
# Learning Path
# Learning Path
Hermes Agent can do a lot — CLI assistant, Telegram/Discord bot, task automation, RL training, and more. This page helps you figure out where to start and what to read based on your experience level and what you're trying to accomplish.
:::tip Start Here
If you haven't installed Hermes Agent yet, begin with the [Installation guide](./installation.md) and then run through the [Quickstart](./quickstart.md). Everything below assumes you have a working installation.
:::
:::tip First-time provider setup
First-time users almost always want `hermes setup --portal` — one OAuth covers a model plus the four Tool Gateway tools (search/image/TTS/browser). See [Nous Portal](../integrations/nous-portal.md).
:::
## How to Use This Page
- **Know your level?** Jump to the [experience-level table](#by-experience-level) and follow the reading order for your tier.
- **Have a specific goal?** Skip to [By Use Case](#by-use-case) and find the scenario that matches.
- **Just browsing?** Check the [Key Features](#key-features-at-a-glance) table for a quick overview of everything Hermes Agent can do.
## By Experience Level
| Level | Goal | Recommended Reading | Time Estimate |
|---|---|---|---|
| **Beginner** | Get up and running, have basic conversations, use built-in tools | [Installation](./installation.md) → [Quickstart](./quickstart.md) → [CLI Usage](../user-guide/cli.md) → [Configuration](../user-guide/configuration.md) | ~1 hour |
| **Intermediate** | Set up messaging bots, use advanced features like memory, cron jobs, and skills | [Sessions](../user-guide/sessions.md) → [Messaging](../user-guide/messaging/index.md) → [Tools](../user-guide/features/tools.md) → [Skills](../user-guide/features/skills.md) → [Memory](../user-guide/features/memory.md) → [Cron](../user-guide/features/cron.md) | ~2–3 hours |
| **Advanced** | Build custom tools, create skills, train models with RL, contribute to the project | [Architecture](../developer-guide/architecture.md) → [Adding Tools](../developer-guide/adding-tools.md) → [Creating Skills](../developer-guide/creating-skills.md) → [Contributing](../developer-guide/contributing.md) | ~4–6 hours |
## By Use Case
Pick the scenario that matches what you want to do. Each one links you to the relevant docs in the order you should read them.
### "I want a CLI coding assistant"
Use Hermes Agent as an interactive terminal assistant for writing, reviewing, and running code.
1. [Installation](./installation.md)
2. [Quickstart](./quickstart.md)
3. [CLI Usage](../user-guide/cli.md)
4. [Code Execution](../user-guide/features/code-execution.md)
5. [Context Files](../user-guide/features/context-files.md)
6. [Tips & Tricks](../guides/tips.md)
:::tip
Pass files directly into your conversation with context files. Hermes Agent can read, edit, and run code in your projects.
:::
### "I want a Telegram/Discord bot"
Deploy Hermes Agent as a bot on your favorite messaging platform.
1. [Installation](./installation.md)
2. [Configuration](../user-guide/configuration.md)
3. [Messaging Overview](../user-guide/messaging/index.md)
4. [Telegram Setup](../user-guide/messaging/telegram.md)
5. [Discord Setup](../user-guide/messaging/discord.md)
6. [Voice Mode](../user-guide/features/voice-mode.md)
7. [Use Voice Mode with Hermes](../guides/use-voice-mode-with-hermes.md)
8. [Security](../user-guide/security.md)
For full project examples, see:
- [Daily Briefing Bot](../guides/daily-briefing-bot.md)
- [Team Telegram Assistant](../guides/team-telegram-assistant.md)
### "I want to automate tasks"
Schedule recurring tasks, run batch jobs, or chain agent actions together.
1. [Quickstart](./quickstart.md)
2. [Cron Scheduling](../user-guide/features/cron.md)
3. [Batch Processing](../user-guide/features/batch-processing.md)
4. [Delegation](../user-guide/features/delegation.md)
5. [Hooks](../user-guide/features/hooks.md)
:::tip
Cron jobs let Hermes Agent run tasks on a schedule — daily summaries, periodic checks, automated reports — without you being present.
:::
### "I want a team of specialist Bots"
Create named Bots with their own model, memory, skills, routines, and chats, then bring them together in group chats or through `@mentions`.
1. [Desktop](../user-guide/desktop.md)
2. [Profiles](../user-guide/profiles.md)
3. [Bot Mode](../user-guide/bot-mode.md)
4. [Cron Scheduling](../user-guide/features/cron.md)
5. [Multi-connection Desktop](../user-guide/multi-connection-desktop.md)
### "I want to build custom tools/skills"
Extend Hermes Agent with your own tools and reusable skill packages.
1. [Plugins](../user-guide/features/plugins.md)
2. [Build a Hermes Plugin](../developer-guide/plugins/index.md)
3. [Tools Overview](../user-guide/features/tools.md)
4. [Skills Overview](../user-guide/features/skills.md)
5. [MCP (Model Context Protocol)](../user-guide/features/mcp.md)
6. [Architecture](../developer-guide/architecture.md)
7. [Adding Tools](../developer-guide/adding-tools.md)
8. [Creating Skills](../developer-guide/creating-skills.md)
:::tip
For most custom tool creation, start with plugins. The [Adding Tools](../developer-guide/adding-tools.md)
page is for built-in Hermes core development, not the usual user/custom-tool path.
:::
### "I want to train models"
Use reinforcement learning to fine-tune model behavior with Hermes Agent's RL training pipeline (powered by [Atropos](https://github.com/NousResearch/atropos)).
1. [Quickstart](./quickstart.md)
2. [Configuration](../user-guide/configuration.md)
3. [Atropos RL Environments](https://github.com/NousResearch/atropos) (external)
4. [Provider Routing](../user-guide/features/provider-routing.md)
5. [Architecture](../developer-guide/architecture.md)
:::tip
RL training works best when you already understand the basics of how Hermes Agent handles conversations and tool calls. Run through the Beginner path first if you're new.
:::
### "I want to use it as a Python library"
Integrate Hermes Agent into your own Python applications programmatically.
1. [Installation](./installation.md)
2. [Quickstart](./quickstart.md)
3. [Python Library Guide](../guides/python-library.md)
4. [Architecture](../developer-guide/architecture.md)
5. [Tools](../user-guide/features/tools.md)
6. [Sessions](../user-guide/sessions.md)
## Key Features at a Glance
Not sure what's available? Here's a quick directory of major features:
| Feature | What It Does | Link |
|---|---|---|
| **Tools** | Built-in tools the agent can call (file I/O, search, shell, etc.) | [Tools](../user-guide/features/tools.md) |
| **Skills** | Installable plugin packages that add new capabilities | [Skills](../user-guide/features/skills.md) |
| **Memory** | Persistent memory across sessions | [Memory](../user-guide/features/memory.md) |
| **Bot Mode** | Named specialist Bots with persistent chats, routines, group chats, and `@mentions` | [Bot Mode](../user-guide/bot-mode.md) |
| **Context Files** | Feed files and directories into conversations | [Context Files](../user-guide/features/context-files.md) |
| **MCP** | Connect to external tool servers via Model Context Protocol | [MCP](../user-guide/features/mcp.md) |
| **Cron** | Schedule recurring agent tasks | [Cron](../user-guide/features/cron.md) |
| **Delegation** | Spawn sub-agents for parallel work | [Delegation](../user-guide/features/delegation.md) |
| **Code Execution** | Run Python scripts that call Hermes tools programmatically | [Code Execution](../user-guide/features/code-execution.md) |
| **Browser** | Web browsing and scraping | [Browser](../user-guide/features/browser.md) |
| **Hooks** | Event-driven callbacks and middleware | [Hooks](../user-guide/features/hooks.md) |
| **Batch Processing** | Process multiple inputs in bulk | [Batch Processing](../user-guide/features/batch-processing.md) |
| **Provider Routing** | Route requests across multiple LLM providers | [Provider Routing](../user-guide/features/provider-routing.md) |
## What to Read Next
Based on where you are right now:
- **Just finished installing?** → Head to the [Quickstart](./quickstart.md) to run your first conversation.
- **Completed the Quickstart?** → Read [CLI Usage](../user-guide/cli.md) and [Configuration](../user-guide/configuration.md) to customize your setup.
- **Comfortable with the basics?** → Explore [Tools](../user-guide/features/tools.md), [Skills](../user-guide/features/skills.md), and [Memory](../user-guide/features/memory.md) to unlock the full power of the agent.
- **Setting up for a team?** → Read [Security](../user-guide/security.md) and [Sessions](../user-guide/sessions.md) to understand access control and conversation management.
- **Ready to build?** → Jump into the [Developer Guide](../developer-guide/architecture.md) to understand the internals and start contributing.
- **Want practical examples?** → Check out the [Guides](../guides/tips.md) section for real-world projects and tips.
:::tip
You don't need to read everything. Pick the path that matches your goal, follow the links in order, and you'll be productive quickly. You can always come back to this page to find your next step.
:::
---
# Updating & Uninstalling
# Updating & Uninstalling
## Updating
Choose the update method for the installation that is running:
| Installation | Update method |
|---|---|
| Managed source checkout | `hermes update`, or the source-built desktop's update handoff. |
| Windows sideload MSIX | The desktop Update control and Windows App Installer. |
| Microsoft Store package | Microsoft Store updates. |
| macOS bundled app | The desktop Update control, through `electron-updater`. |
| Docker image | Pull the chosen image and recreate the container with the same data mount. |
| Nix | Update the flake/profile and rebuild. |
| Termux APT | `pkg update`, then `pkg upgrade hermes-agent`. |
`hermes update` does not rewrite package-owned application files. PM manages
dependencies, not application distribution: `hermes pm update` is a maintainer
pin-update command, not an alternative application updater.
For a managed source installation:
```bash
hermes update
```
The default source channel tracks `main`. Configured stable and canary channels
track their published release commits. The update prepares dependencies through
PM and reports configuration changes and process-restart results.
### Bundled desktop updates
On Windows, the sideload app checks the registered App Installer source. Apply
downloads the `.appinstaller` descriptor before stopping app-owned backends,
opens that local file, and quits for Windows to replace the package.
A detached waiter attempts automatic relaunch after the package version changes.
A waiter-registration failure produces a manual-reopen warning.
On macOS, apply downloads the ZIP update and waits for Squirrel.Mac to accept
the signed app before stopping backends. Only that apply flow requests install
and relaunch. An ordinary quit does not install a pending download.
Download or verification failures leave backends running.
Store packages use the Store instead of either sideload feed. A broken or
unavailable update check must not be interpreted as "already up to date."
The application and its bundled base runtime update together; user data stays
outside the package. See [Desktop installation](../user-guide/desktop.md#install).
Stable and canary desktop bundles are separate applications. Installing canary
does not replace stable, including on Windows with MSIX. Each application keeps
its own desktop state and updates within the channel baked into its build;
changing channels means installing the other application, not changing a setting.
The canary icon has a yellow background (dark yellow in dark mode), and its CLI
command is `hermes-canary`.
One-off commit bundles are separate from both release channels and from other
commit bundles. Their red icons show the short build SHA, also used in the
`hermes-` CLI command. They do not check for or install updates,
including through `hermes update` or `hermes update --check`. They explain:
> This build doesn't get updates. Ask the developer who gave it to you for a new build.
Separate applications still share Hermes profiles, configuration, and sessions
under the same Hermes home. Running different builds against one profile is not
schema isolation: newer builds can change stored data that an older build cannot
read. Back up shared data before testing. The desktop and standalone CLI warn
when another live installation uses the same profile; this is advisory, not a
lock. Desktop post-update notices are scoped to the application, so launching
canary cannot consume stable's pending notice. The `hermes://` URL scheme remains
shared; the application that most recently registered it handles links.
### Source channels and install identity
```bash
hermes update --install-id
hermes update --set-channel stable
hermes update --channel stable --check
# Or track published canary commits in this source installation:
hermes update --set-channel canary
hermes update
```
`--install-id` prints the installation identity and path. `--set-channel`
changes only that installation's configuration, then exits without applying an
update. `--channel` is a one-run override. An explicit `--branch` takes precedence
for a source checkout.
Channel names are registered in the release archive on Cloudflare R2, not in a
fixed list shipped with Hermes. The `main` record selects source-branch delivery;
published-build channels select an exact Git commit. Custom preview channels use
the same source commands, for example `hermes update --set-channel pm-preview`.
The publisher must have created that channel before an update can resolve it.
An unavailable or invalid record reports an error rather than falling back to
`main` or another release. Switching a source channel does not install a desktop
package.
Per-install subscriptions live under `update.installs` in configuration, so one
checkout's choice does not change another installation's channel. The source-built
desktop uses that same selection for checks and update handoffs; it does not
replace a selected release channel with its default branch.
For branch-tracking source installs, the desktop keeps the current named branch
unless an explicit desktop branch override exists. A detached checkout uses the
default branch. Older checkouts without source-channel probing predate release
channels, so the desktop updates them from `main` over git; that update brings in
the probing.
Packaged desktop feed channels derive from their build tag and package owner.
Changing a source channel is not an MSIX or Store channel switch. Canary builds
can advance stored data formats; switching back is not a schema rollback.
Back up data before changing release channels.
:::tip
`hermes update` automatically detects new configuration options and prompts you to add them. If you skipped that prompt, you can manually run `hermes config check` to see missing options, then `hermes config migrate` to interactively add them.
:::
### Passive update notices
Pinned or noninteractive installations can disable passive CLI version and banner update checks:
```bash
hermes config set updates.check false
```
This suppresses both cached update notices and passive update-check network requests. The default is `true`. Explicit `hermes update --check` and `hermes update` still work; this setting does not control the Desktop application's updater.
### What happens during an update
For an admitted source checkout, `hermes update` runs these phases:
1. **Pre-update snapshot** — Hermes saves selected state files for every profile in that profile's `state-snapshots/` directory. These include pairing data, cron jobs, `config.yaml`, `.env`, and `auth.json`. Automatic quick snapshots skip individual files larger than 1 GiB. `updates.pre_update_backup` selects `quick`, `full`, or `off`. Full archives use the [backup exclusions](../reference/faq.md#hermes-backup-vs-hermes-profile-export). Recovery uses [Snapshots and rollback](../user-guide/checkpoints-and-rollback.md). Quick snapshots recover state files, not application code. The snapshot is best-effort: if it fails, the update prints a `⚠ Pre-update snapshot FAILED` warning and continues, and the receipt records `pre_update_backup` as a failed step (a deliberate `off`/`--no-backup` lands in the receipt's skips with its reason instead).
2. **Code update** — applies the configured source branch or stable release tag and updates submodules.
3. **Post-pull syntax validation + auto-rollback** — after the pull, Hermes compiles the nine critical files every `hermes` invocation imports at startup. If any fails to parse (e.g. an orphan merge-conflict marker, an accidentally truncated file), Hermes runs `git reset --hard ` to roll the install back so your shell stays bootable. Re-run `hermes update` once the upstream fix lands.
4. **Dependency preparation** — PM provisions required tools and prepares a complete Python environment from the new lock, existing extras, and enabled plugin requirements. It validates that environment before publishing its selection. A plugin never fails the update. A plugin that no longer fits the new core is added to `plugins.disabled` in every profile that enables it (a memory provider has `memory.provider` cleared). That covers a `requires-python` that excludes Hermes's Python, a `manifest_version` newer than this Hermes supports, and dependencies that the resolver proves can't resolve alongside core and earlier plugins in config order, or that fail their own build. A download or network failure gets one retry and is disabled if it fails again. The update prints `⚠ Disabled plugin '' in : `, records it in the receipt's warnings, and continues. Re-enable it with `hermes plugins enable ` once the plugin ships a compatible release, or once the network is back. A `requires_hermes` range the running version misses never disables a plugin, because a source checkout without release tags can read as an older release. That plugin sits out instead (`⚠ Left plugin '' … out of this update`), stays enabled, and rejoins once Hermes reports a version it accepts. A secondary profile whose config cannot be read sits out the same way until the config is fixed. Only a core that cannot build on its own fails this step.
5. **Config migration** — detects new config options added since your version and prompts you to set them
6. **Desktop rebuild (stage-and-swap)** — if the Hermes Desktop app was built from this checkout, it is rebuilt so the GUI matches the new code. The rebuild packs into a temporary staging directory next to `apps/desktop/release/`, verifies the staged app, and only then renames it over the previous build (on Windows a real-time scanner briefly holding `release/win-unpacked` is ridden out with a few short retries). A rebuild that fails at any point — corrupt Electron download, missing dependency, disk full — leaves the previous app untouched and launchable; the update fails at that step, and `hermes desktop --build-only --force-build` or the next `hermes update` retries the rebuild. On macOS the rebuilt bundle is then copied (with `ditto`, signature intact) over a stale `/Applications/Hermes.app` or `~/Applications/Hermes.app`, so the copy Finder and the Dock launch matches the backend; an installed copy that is currently running is left alone and the update tells you to quit it and run `hermes update` again.
7. **Gateway auto-restart**: running gateways are refreshed after the update completes. Service-managed gateways (systemd on Linux, launchd on macOS) restart through the service manager. Manual gateways are relaunched when Hermes can map their PID to a profile. Manually launched `hermes serve` / `hermes dashboard` backends are different: the updater leaves them running and asks their owner to restart them. See [Manual backend restart reminders](#manual-backend-restart-reminders). Backends owned by a running Desktop app remain the app's responsibility.
8. **Multiplex migration (multi-profile installs)** — once the fleet is verified on the new code, an install with two or more profiles that still run **one gateway per profile** is folded into a single multiplexed default gateway when nothing blocks it (same as `hermes gateway migrate --multiplex --yes`); if a blocker exists (a bot token shared by two profiles, a secondary profile binding a port with no `/p//` ingress) the update prints the blockers with their fixes and changes nothing. Single-profile installs are never touched. See [Migrating from per-profile gateways](../user-guide/multi-profile-gateways.md#migrating-from-per-profile-gateways).
### Why the gateway restart can take a while
The restart is drain-first: the running gateway refuses new turns, then waits for in-flight work (chat turns, cron jobs, API runs) to finish before exiting, capped by `agent.restart_after_turn_timeout` (30 minutes by default) so a long-running job is never cut off mid-run. While that wait is in progress the updater prints, every 30 seconds, what the gateway is still holding for — for example:
```
→ hermes-gateway: draining (up to 1875s)...
⏳ still draining — 1560s left before the forced restart
waiting on 1 active work unit(s):
• cron job 6ba19dab68df (nightly-scout) in external worker pid 573597, running 6m40s
finish or kill the work above to release the drain now; agent.restart_after_turn_timeout in config.yaml caps this wait
```
Chat turns show their session key, model and current tool; cron jobs show the job id, name and the process running them (an external restart-safe worker on systemd installs, otherwise the gateway itself). `hermes gateway status` lists the same units while the gateway is draining. To stop waiting, finish or kill the listed work, or lower `agent.restart_after_turn_timeout` in `config.yaml` (`0` enters the forced drain immediately).
Wedged work does not hold the restart: a chat turn idle past `agent.gateway_timeout`, or a cron run older than the scheduler's in-flight allowance (`max(2 × the job's interval, cron.inflight_max_minutes)`, 30 minutes by default), is excluded from the wait and interrupted by the restart instead.
### Missing Windows updater files
If the maintained updater script is missing (for example after antivirus quarantine), the legacy update forwarder fails instead of reporting a successful hand-off. Repair the installation and review the security software's quarantine report before retrying; do not disable antivirus protection. Before reporting success, the maintained updater checks the CLI import, Windows executable header, ASAR header and packaged main entry, readable renderer HTML with a local module entry, initial module files, and current build stamp. These are minimum artifact checks, not a full dependency audit or an application/backend launch test. Missing Python is reported before waiting for Desktop shutdown; dependency repair is still allowed to run as part of the update. Electron checks maintained handoff prerequisites before stopping backends when that layout is present; genuine legacy-flat updater layouts remain supported, so not every missing updater file is detected before backend shutdown.
On Windows, a Desktop reopened during packaging is stopped again immediately before the staged build is promoted. This cleanup is restricted to executables inside that checkout's Desktop release tree; unrelated installations are not stopped. A remaining lock still makes staged promotion fail rather than bypassing the rename error.
### Updating against a non-default branch: `--branch`
On the default source channel, `hermes update` tracks `origin/main`. Use
`--branch NAME` for a one-run branch override:
```bash
hermes update --branch release-candidate
hermes update --check --branch experimental # preview behindness only
```
If your local checkout is on a different branch, Hermes auto-stashes any uncommitted work, switches HEAD to the target branch, and then pulls. Branches that don't exist locally are auto-tracked from `origin/` (`git checkout -B origin/`). Branches that don't exist anywhere fail cleanly — your stashed changes are restored before exit so you're never stranded in a weird state. The `main`-only fork-upstream sync logic is automatically skipped on non-`main` branches.
### Checkout parked on a feature branch
If the source checkout was left sitting on a feature branch (by tooling, a worktree experiment, or a manual checkout), `hermes update` switches it back to the update target automatically whenever the working tree is clean:
- **Branch fully merged** (every commit already contained in `origin/main` — `git cherry` reports nothing unmerged): the update says so — `Checkout was parked on '' (fully merged) — switched back to main` — and stays on `main` afterwards.
- **Branch has unmerged commits** but the tree is clean: the update still switches to `main` so the update can proceed — this is what non-interactive callers (the desktop update button, gateway `/update`, cron) rely on, since they have no way to resolve a skip. Your commits are untouched: `git checkout` never discards committed work, and the update prints a loud notice naming the branch and commit count, plus the `git checkout ` command to pick the work back up later.
If you *deliberately* run a custom branch (local patches maintained on top of main), set `updates.parked_branch_strategy: update_in_place` in `config.yaml`. The update then merges `origin/main` **into** your branch instead of switching away from it — the checkout never moves, your commits survive, and the running code advances. Fast-forward when possible; on divergence a true merge behind a `pre-update-` safety tag, stopping cleanly (nothing changed) on conflict. `hermes update --switch-branch` overrides back to the switch path for one run — useful on a deep feature branch that must not accumulate update-driven merge commits.
When the parked branch has **uncommitted changes** (dirty tree), Hermes does **not** touch it. The code update is marked **SKIPPED** with a loud warning naming the branch, how far behind `origin/main` it is, and the exact commands to resolve — instead of pretending the update succeeded. The completion line always shows the actual branch and HEAD (`✓ Update complete! [main @ 30fcf9580]`) so drift is visible at a glance. Set `updates.auto_switch_parked_branch: false` in `config.yaml` to disable the auto-switch entirely (the skip warning still fires).
### Local commits on the target branch
Commits made directly on the update target (`main`) stop fast-forwards once upstream moves, and the checkout cannot tell them apart from an upstream force-push, so the update resets `main` to `origin/main`. Before the reset it saves the old HEAD as `refs/hermes-update-backups/diverged-main--` and prints that ref along with how many commits leave the branch. `git log origin/main..` lists them; `git branch ` or `git cherry-pick` brings them back. Re-running the installer over an existing checkout (`install.sh` / `install.ps1`, which desktop bootstrap does) writes the same refs. Whenever `hermes update` writes one, it keeps the ten newest per kind and drops any older than 30 days. To carry patches across updates, keep them on a custom branch with `updates.parked_branch_strategy: update_in_place` instead.
### Local changes on non-interactive updates
When you run `hermes update` in a terminal, Hermes stashes any uncommitted source-tree changes, pulls, then **asks** whether to restore them — exactly as it always has. Nothing changes for interactive updates.
The autostash only ever covers *source-tree* changes. On a **flat install** — where the git checkout root is also `$HERMES_HOME` (for example an install made with `HERMES_INSTALL_DIR=$HERMES_HOME`, or one created by an older installer) — the profile's runtime state (`state.db` and its WAL/SHM sidecars, `state-snapshots/`, `backups/`, `sessions/`, `cron/jobs.json`, the `cron/*.db` stores, `config.yaml`, `auth.json`, `memories/`, lock/pid files, …) lives inside the checkout as untracked files. Those paths are git-ignored, so the autostash never touches them and the running gateway keeps its database through the update. If you keep other untracked files in a flat install's root, move them out of the checkout or add them to `.git/info/exclude`; anything untracked and not ignored is swept into the autostash like a source edit.
When the update runs **without a terminal** — from the desktop/chat app's "Update" button or a gateway-triggered update — there's no prompt to answer. The `updates.non_interactive_local_changes` setting decides what happens to your stashed changes:
```yaml
# ~/.hermes/config.yaml
updates:
non_interactive_local_changes: stash # default: keep + auto-restore
# non_interactive_local_changes: discard # throw local source edits away
```
- `stash` (default) — auto-stash, pull, then auto-restore your changes on top of the updated code. Nothing is lost; if a restore hits conflicts they're preserved in a git stash for manual recovery.
- `discard` — auto-stash and drop the stash after the pull, so the update always lands on a clean tree. Use this only on machines where you never intend to keep local edits to the Hermes source. It stash-drops (not `git reset --hard` + `git clean -fd`), so ignored paths like `node_modules`, `venv`, and build outputs are never touched.
In the desktop app this is **Settings → Advanced → In-App Update Local Changes**.
**Desktop updates never auto-restore.** The desktop updater invokes `hermes update --keep-stash`: local source edits are still stashed so the update can proceed, but they are **not** re-applied afterward — they stay parked in `git stash` and the update log prints the exact `git stash apply ` command to bring them back. This prevents local edits from silently riding along across desktop updates and breaking the freshly updated install. (`non_interactive_local_changes: discard` still wins if you've opted into discarding.) To restore parked changes manually:
```bash
cd ~/.hermes/hermes-agent # or your install root
git stash list --format='%gd %H %s' # find the hermes-update-autostash entry
git stash apply stash@{0}
```
You can pass `--keep-stash` to a terminal `hermes update` too if you want the same never-reapply behavior interactively.
### Preview-only: `hermes update --check`
`hermes update --check` compares the checkout with its source-channel target
without applying code, installing dependencies, or restarting gateways. The
comparison can fetch Git metadata; it is not a promise of zero filesystem writes.
Package-owned installs report their external update method.
### Fleet preview: `hermes update --plan`
Before updating a machine that runs several profiles or services, run `hermes update --plan`. It prints the install kind, running Hermes services across profiles, their supervisors and running code versions, and the restart mechanism for each service. Manually launched `hermes serve` / `hermes dashboard` backends appear with their recorded bind endpoint, but restart is deferred to their owner; the updater does not stop or relaunch them. Image- or package-managed installs report the external update command instead. The plan is read-only and safe on a live fleet.
The same inventory is embedded in every real update's receipt (`~/.hermes/logs/update_receipts/`), so after an update you can compare what the updater saw against what it did.
### Update receipts and the fleet version check
Every `hermes update` run writes a machine-readable receipt to `~/.hermes/logs/update_receipts/` (last 20 kept, `latest.json` always points at the most recent): the pre-update fleet plan, each step taken, anything skipped and why, the gateway restart outcome, and the final fleet version matrix. The SQLite runtime repair is one of those steps (`sqlite_runtime_repair`): a failed repair records the actual reason (for example the `uv sync` error) and the SQLite version pair, a deferred or not-applicable repair lands in the skips with its reason. After the restart phase the updater compares each live gateway's running code against the freshly updated checkout and prints a per-profile matrix — a gateway still serving pre-update code is reported loudly with the exact restart command, and the update exits non-zero so automation never treats a mixed-version fleet as healthy. Both `--plan` and the fleet check ask each running gateway directly over its local control socket (`gateway.sock` in the profile's data directory, a named pipe on Windows) when available, so version and supervisor information comes from the gateway itself; gateways from older versions are still discovered through their state files as before.
A multiplexed default gateway is one process serving several profiles, so it appears once in the matrix and vouches for every profile in its `served_profiles` record. The same coverage clears the "A previous `hermes update` pulled new code but did not restart running gateways" hint: once that gateway (or, after a manual `git pull`, every gateway an update restarted) runs the current code, `hermes gateway restart` is enough — the hint no longer waits for the next `hermes update` to write a fresh receipt. The same is true of the restart obligation left by an update that died before recording which gateways it owed (or by an older updater that never recorded them): once every live gateway runs the current checkout, the obligation is retired and the hint stops. On a host that runs no gateway at all (the Desktop app alone), it is retired once no profile has a gateway that went away without a clean stop and every running backend is restarted by its own supervisor or has its own reminder. That obligation is recorded once per HOST, in the cross-profile rendezvous directory (`$HERMES_GATEWAY_LOCK_DIR`, else `$XDG_STATE_HOME/hermes/gateway-locks`) as `host-update-restart.json`, so every profile's CLI sees the same one: `hermes -p coder update` and `hermes -p writer update` restart the shared multiplexed gateway once between them, not once each. An obligation left behind by an older per-profile updater (`fleet_restart_pending` in one profile's Hermes home) is still read and cleared. An update whose pre-update plan found no gateway at all owes nothing and leaves no breadcrumb. A backend supervised by Desktop, systemd or launchd is restarted by its supervisor and never blocks this settlement; only a manual backend whose reminder could not be saved keeps the obligation open.
### Manual backend restart reminders
After updating, ask the owner of each manually launched backend to relaunch `hermes serve` or `hermes dashboard`; reconnect Desktop for an SSH backend. Restarting gateways alone does not refresh these processes.
For a verified live backend, the updater saves a reminder before recording its restart as `deferred`. This allows the update to complete if the remaining checks pass. An unknown process identity cannot qualify for fresh deferral.
Reminders live in `serve_restart_pending/` under the active Hermes home, separately from rotating update receipts. Each reminder identifies a process by PID and creation time. Startup warnings retain it while that process is alive or its liveness is unknown, and remove it only when that exact process is confirmed gone. A later update or a healthy gateway does not remove it.
If saving fails, Hermes warns and carries the unsaved obligation into later receipts for retry. Check storage permissions and free space, and restart the backend as instructed. If both reminder and receipt storage fail, durable retention cannot be guaranteed.
### Automated updates from inside the gateway: `--no-gateway-restart`
An update launched *by* the gateway (a cron job, the Desktop updater, any automation that is a
child of the gateway process) cannot survive its own fleet restart: the gateway drains on
`SIGUSR1` and systemd's `KillMode=mixed` then kills everything left in the cgroup, updater
included. `hermes update --no-gateway-restart` runs the full pipeline (pull, dependencies,
Node workspaces, web UI, maintenance) and skips only the restart and fleet verification. The
pending-restart marker is kept, so the next CLI start warns and the next normal `hermes update`
(or `hermes gateway restart`) catches the fleet up. Pair it with a separate restart step, for
example a timer 10–15 minutes after the update job. The receipt records the deferral; a stale
fleet caused only by the deferral does not make the update `partial`.
### Interrupted gateway restarts
If an earlier update pulled code but did not finish restarting the fleet, the next `hermes update` retries even when the checkout is already current. An empty process scan does not prove recovery: failed systemd units and installed launchd jobs may have no live PID. If discovery or restart fails, or a requested service cannot be verified active, the marker stays pending and the update exits nonzero. Recover the affected services with the printed commands and retry.
A failed historical receipt alone does not prove that gateways are still stale. Startup and gateway-status warnings, as well as update catch-up, check for a live successor on the current checkout for every gateway profile recorded in that receipt. A manual gateway restart can settle receipt-only advice without rewriting the historical outcome. Manual backend obligations must first transfer to their separate reminders.
A pending restart marker records its own pre-update runtime inventory and target commit. Recovery checks that inventory, never an older receipt's inventory. Settlement requires a nonempty verified current fleet, a successor at the target commit for every recorded gateway profile, and successful handling of any identified manual backend obligations. A manual backend that is still alive or whose liveness is unknown needs a saved reminder; a confirmed exited process needs no reminder. Missing gateways, unsupported runtimes, or failed reminder writes keep the marker pending.
Legacy markers without an inventory, and malformed or unsupported inventories, cannot be automatically cleared by startup or catch-up reconciliation. Restarting all visible gateways is not enough to establish what that update owed. The warning remains until a later update completes with a new marker containing its own inventory and settles successfully.
### Full pre-update backup: `--backup`
For high-value profiles (production gateways, shared team installs) you can opt into a full pre-pull backup of `HERMES_HOME` (config, auth, sessions, skills, pairing):
```bash
hermes update --backup
```
Or make it the default for every run:
```yaml
# ~/.hermes/config.yaml
updates:
pre_update_backup: full
```
`updates.pre_update_backup` has three modes:
- `quick` saves the selected state files described above. This is the default.
- `full` adds a zip archive with the [backup exclusions](../reference/faq.md#hermes-backup-vs-hermes-profile-export). Large data directories can take several minutes.
- `off` disables pre-update backups. `--no-backup` selects this mode for one run.
Legacy boolean values remain supported: `true` means `full`, and `false` means `off`.
:::tip Moving to a new machine instead?
Update backups protect an in-place update. If you're migrating your whole setup to different hardware, use `hermes backup` + `hermes import` instead — see [Exporting Hermes to another machine](../reference/faq.md#exporting-hermes-to-another-machine) and [`hermes backup` vs `hermes profile export`](../reference/faq.md#hermes-backup-vs-hermes-profile-export).
:::
### Windows process ownership and dependency changes
Windows can hold executable and native-extension files open while processes run.
PM therefore prepares dependency generations separately instead of replacing
imported libraries in place. A running process keeps its current imports until
it restarts.
Follow any blocker diagnostic for the specific process and installation. Do
not kill every process named Python or Hermes. Source updates and MSIX package
replacement have different owners and shutdown requirements.
The parser retains `--force` and `--force-venv` for Windows compatibility. They
are not normal update instructions or a guarantee that an OS file lock can be
bypassed. Inspect `hermes update --plan`, the update log, and `hermes pm status`
before retrying a failed update.
### Recommended Post-Update Validation
After a source update, run the following diagnostics. For a bundled app, use
its About page and backend health instead of Git commands inside the package.
1. `git status --short` — if the tree is unexpectedly dirty, inspect before continuing
2. `hermes doctor` — checks config, dependencies, and service health
3. `hermes --version` — confirm the version bumped as expected
4. If you use the gateway: `hermes gateway status`
5. `hermes pm status` — inspect dependency preparation and any failed steps.
:::warning Dirty working tree after update
If `git status --short` shows unexpected changes after `hermes update`, stop and inspect them before continuing. This usually means local modifications were reapplied on top of the updated code, or a dependency step refreshed lockfiles.
:::
### If your terminal disconnects mid-update
`hermes update` protects itself against accidental terminal loss:
- The update ignores `SIGHUP`, so closing your SSH session or terminal window no longer kills it mid-install. `pip` and `git` child processes inherit this protection, so the Python environment cannot be left half-installed by a dropped connection.
- All output is mirrored to `~/.hermes/logs/update.log` while the update runs. If your terminal disappears, reconnect and inspect the log to see whether the update finished and whether the gateway restart succeeded:
```bash
tail -f ~/.hermes/logs/update.log
```
- `Ctrl-C` (SIGINT) and system shutdown (SIGTERM) are still honored — those are deliberate cancellations, not accidents.
You no longer need to wrap `hermes update` in `screen` or `tmux` to survive a terminal drop.
### Checking your current version
```bash
hermes --version
```
Compare against the latest release at the [GitHub releases page](https://github.com/NousResearch/hermes-agent/releases).
### Updating from Messaging Platforms
You can also update directly from Telegram, Discord, Slack, WhatsApp, or Teams by sending:
```
/update
```
This pulls the latest code, updates dependencies, and restarts running gateways. The bot will briefly go offline during the restart (typically 5–15 seconds) and then resume.
### Manual source maintenance and rollback
Use the [source-install guide](../user-guide/switching-to-source.md) for an
independent development checkout. Commit or otherwise preserve local work
before changing its Git revision. Stop the services you own before replacing
the code that those services run.
After changing the source revision, run that revision's dependency/bootstrap
procedure. PM-managed checkouts use `python -m pm.cli install` from the selected
source environment. A bare pip install is not equivalent: it does not prepare
the managed tool store or PM's runtime selection.
A code checkout alone is not a data rollback. Newer releases can migrate
configuration or databases in ways older code cannot read. Use a coherent
backup from before the update if an older release requires the earlier data
format. Preserve the current data before attempting a restore.
Package-owned installations use their package manager's rollback or reinstall
procedure. Do not run Git or pip inside a signed app or an immutable image.
### Python 3.14 and older interpreters
Hermes runs only on **Python 3.14**. `pyproject.toml` still declares
`requires-python = ">=3.11,<3.15"`, but every runtime dependency carries a
`python_version >= '3.14'` marker. The wider range exists for one reason: a
source install whose venv predates the PM migration (Python 3.11–3.13) must be
able to check out the new code and run `hermes update` once more. That update
hands off to a fresh completion process, PM provisions the pinned 3.14
interpreter from `pm/lock.json`, builds the dependency environment on it, and
repoints the launchers. After that the old interpreter is no longer used.
What this means outside `hermes update`:
- `pip install .`, `uv pip install .`, `pip install -e .`, or a Homebrew/PyPI
package built on Python 3.11–3.13 installs `hermes-agent` with **no
dependencies** and the package does not import. These install methods are
[unsupported](./platform-support.md#unsupported); use the source installer or
a packaged build.
- A Nix build outside the repo flake sees the same marker-gated dependency set
and needs a 3.14 interpreter.
- A `hermes` launcher that still points at a pre-migration venv should be
repaired with `hermes update` (or `python -m pm.cli install` from the
checkout), not by reinstalling into the old venv.
### Image-managed installs (Docker): the provenance marker
Published Docker images bake a small read-only marker (`/etc/hermes/image-provenance.json`) that authoritatively identifies the filesystem as image-managed. `hermes update`, `hermes update --check`, and the dashboard's Update button all consult it before touching anything: on an image-managed install they refuse cleanly (exit code 2), print the actual update command (`docker pull nousresearch/hermes-agent:latest`), and write a `refused` receipt so fleet tooling can see the attempt happened. The marker wins even when a source checkout is bind-mounted into the container — the refusal is based on what the running filesystem *is*, not what it looks like. A damaged marker still refuses (fail-closed). Nix- and apt-managed installs refuse through the same gate using the existing detection.
### Note for Nix users
Nix is no longer an explicitly supported install path (best-effort only) — see [Nix Setup](./nix-setup.md). If you installed via Nix flake, updates are managed through the Nix package manager:
```bash
# Update the flake input
nix flake update hermes-agent
# Or rebuild with the latest
nix profile upgrade hermes-agent
```
Nix installations are immutable — rollback is handled by Nix's generation system:
```bash
nix profile rollback
```
See [Nix Setup](./nix-setup.md) for more details.
---
## Uninstalling
```bash
hermes uninstall
```
For source installs, review `hermes uninstall --dry-run` before removal.
The default removal can preserve configuration and user data. `--full` also
removes data. `--data` removes user data without deleting package-owned code.
These modes are destructive; make a backup first.
For MSIX/Store, use Windows Settings → Apps → Installed apps. For macOS,
quit Hermes and move its app bundle to Trash. Docker, Nix, and Termux use the
same manager that installed them. Their package files are not removed by the
source uninstaller. Data deletion is separate from package removal.
:::tip Moving to a new machine rather than leaving?
Run `hermes backup` before you remove the installation. The full archive includes
credentials but excludes downloaded runtimes, dependency environments, caches,
and browser profiles. Review its skipped-file report before you delete source data.
`hermes profile export` packs one profile without credentials.
See [`hermes backup` vs `hermes profile export`](../reference/faq.md#hermes-backup-vs-hermes-profile-export).
:::
### Manual Uninstall
```bash
rm -f ~/.local/bin/hermes
rm -rf /path/to/hermes-agent
rm -rf ~/.hermes # Optional — keep if you plan to reinstall
```
:::info
If you installed the gateway as a system service, stop and disable it first:
```bash
hermes gateway stop
# Linux: systemctl --user disable hermes-gateway
# macOS: launchctl remove ai.hermes.gateway
```
:::
---
# Nix & NixOS Setup
# Nix & NixOS Setup
:::warning Tier 2 platform
Nix and NixOS are [Tier 2 platforms](./platform-support.md#tier-2). The flake and NixOS module documented here are maintained on a best-effort basis only. Commits to `main` may break these packages at any point in time.
For a supported setup, use one of the standard [installation](./installation.md) paths - either Docker or an FHS environment.
:::
Hermes Agent ships a Nix flake, a NixOS module, and a Home Manager module.
| Level | Who it's for | What you get |
|-------|-------------|--------------|
| **`nix run` / `nix profile install`** | Any Nix user (macOS, Linux) | Pre-built binary with all deps — then use the standard CLI workflow |
| **Home Manager module** | An agent for one person, on any distribution or on macOS | Declarative configuration and a user service, without root |
| **NixOS module (native)** | NixOS server deployments | Declarative config, hardened systemd service, managed secrets |
| **NixOS module (container)** | Agents that need self-modification | Everything above, plus a persistent Ubuntu container where the agent can `apt`/`pip`/`npm install` |
:::info What's different from the standard install
The `curl | bash` installer manages Python, Node, and dependencies itself. The Nix flake replaces all of that — every Python dependency is a Nix derivation built by [uv2nix](https://github.com/pyproject-nix/uv2nix), and runtime tools (Node.js, git, ripgrep, ffmpeg) are wrapped into the binary's PATH. There is no runtime pip, no venv activation, no `npm install`.
**For non-NixOS users**, this only changes the install step. Everything after (`hermes setup`, `hermes gateway install`, config editing) works identically to the standard install.
**For NixOS module users**, the entire lifecycle is different: configuration lives in `configuration.nix`, secrets go through sops-nix/agenix, the service is a systemd unit, and CLI config commands are blocked. You manage hermes the same way you manage any other NixOS service.
:::
## Runtime pins
PM's tool lock is also a Nix build input. `nix/npm-pinned.nix` reads the npm
pin, and `nix/pm-packages.nix` exposes matching archives as `pm-NAME` derivations:
```bash
nix build .#pm-ripgrep
```
These outputs unpack the pinned archives. They are not the complete Hermes
wrapper or a guarantee that each archive runs without platform integration.
The application still uses the uv2nix environment and Nix wrapper.
`nix/pythonLock.nix` reads Python's major/minor from `pm/lock.json`.
The uv2nix environment, package overrides, plugin packages, and developer shell
use that interpreter family. If the pinned nixpkgs lacks that family, evaluation
stops instead of selecting a different Python.
Native Nix evaluation and builds remain CI gates. Update through Nix. Do not
repair a Nix store path with pip.
## Prerequisites
- **Nix with flakes enabled** — [Determinate Nix](https://install.determinate.systems) recommended (enables flakes by default)
- **API keys** for the services you want to use (at minimum: an OpenRouter or Anthropic key)
---
## Quick Start (Any Nix User)
No clone needed. Nix fetches, builds, and runs everything:
```bash
# Run the desktop app
nix run github:NousResearch/hermes-agent#desktop
# Or install persistently
nix profile install github:NousResearch/hermes-agent#desktop
# run the tui
nix run github:NousResearch/hermes-agent -- setup
nix run github:NousResearch/hermes-agent -- --tui
# or install it in your profile
nix profile install github:NousResearch/hermes-agent
hermes setup
hermes --tui
```
After `nix profile install`, `hermes`, `hermes-agent`, and `hermes-acp` are on your PATH. From here, the workflow is identical to the [standard installation](./installation.md) — `hermes setup` walks you through provider selection, `hermes gateway install` sets up a launchd (macOS) or systemd user service, and config lives in `~/.hermes/`.
:::warning Messaging platforms (Discord, Telegram, Slack)
The default package includes ALL libraries hermes-agent might need. if you want a smaller variant, check the other flake outputs.
The `default` package adds ~700 MB to the closure. If you only need messaging platforms, `#messaging` adds just ~33 MB.
:::
Running from a local clone
```bash
git clone https://github.com/NousResearch/hermes-agent.git
cd hermes-agent
nix develop
hermes setup
```
---
## NixOS Module
The flake exports `nixosModules.default` — a full NixOS service module that declaratively manages user creation, directories, config generation, secrets, documents, and service lifecycle.
:::note
This module needs NixOS. Hermes is an agent for one person. If you want an agent for one person and not a system service, use the [Home Manager module](#home-manager-module). That module runs on NixOS and on each other system that Home Manager supports.
:::
### Add the Flake Input
```nix
# /etc/nixos/flake.nix (or your system flake)
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
hermes-agent.url = "github:NousResearch/hermes-agent";
};
outputs = { nixpkgs, hermes-agent, ... }: {
nixosConfigurations.your-host = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
hermes-agent.nixosModules.default
./configuration.nix
];
};
};
}
```
### Minimal Configuration
```nix
# configuration.nix
{ config, ... }: {
services.hermes-agent = {
enable = true;
settings.model.default = "anthropic/claude-sonnet-4";
environmentFiles = [ config.sops.secrets."hermes-env".path ];
addToSystemPackages = true;
};
}
```
That's it. `nixos-rebuild switch` creates the `hermes` user, generates `config.yaml`, wires up secrets, and starts the gateway — a long-running service that connects the agent to messaging platforms (Telegram, Discord, etc.) and listens for incoming messages.
:::warning Secrets are required
The `environmentFiles` line above assumes you have [sops-nix](https://github.com/Mic92/sops-nix) or [agenix](https://github.com/ryantm/agenix) configured. The file should contain at least one LLM provider key (e.g., `OPENROUTER_API_KEY=sk-or-...`). See [Secrets Management](#secrets-management) for full setup. If you don't have a secrets manager yet, you can use a plain file as a starting point — just ensure it's not world-readable:
```bash
echo "OPENROUTER_API_KEY=sk-or-your-key" | sudo install -m 0600 -o hermes /dev/stdin /var/lib/hermes/env
```
```nix
services.hermes-agent.environmentFiles = [ "/var/lib/hermes/env" ];
```
:::
:::tip addToSystemPackages
Setting `addToSystemPackages = true` does two things: puts the `hermes` CLI on your system PATH **and** sets `HERMES_HOME` system-wide so the interactive CLI shares state (sessions, skills, cron) with the gateway service. Without it, running `hermes` in your shell creates a separate `~/.hermes/` directory.
:::
### Container-aware CLI
:::info
When `container.enable = true` and `addToSystemPackages = true`, **every** `hermes` command on the host automatically routes into the managed container. This means your interactive CLI session runs inside the same environment as the gateway service — with access to all container-installed packages and tools.
- The routing is transparent: `hermes chat`, `hermes sessions list`, `hermes --version`, etc. all exec into the container under the hood
- All CLI flags are forwarded as-is
- If the container isn't running, the CLI retries briefly (5s with a spinner for interactive use, 10s silently for scripts) then fails with a clear error — no silent fallback
- For developers working on the hermes codebase, set `HERMES_DEV=1` to bypass container routing and run the local checkout directly
Set `container.hostUsers` to create a `~/.hermes` symlink to the service state directory, so the host CLI and the container share sessions, config, and memories:
```nix
services.hermes-agent = {
container.enable = true;
container.hostUsers = [ "your-username" ];
addToSystemPackages = true;
};
```
Users listed in `hostUsers` are automatically added to the `hermes` group for file permission access.
**Podman users:** The NixOS service runs the container as root. Docker users get access via the `docker` group socket, but Podman's rootful containers require sudo. Grant passwordless sudo for your container runtime:
```nix
security.sudo.extraRules = [{
users = [ "your-username" ];
commands = [{
command = "/run/current-system/sw/bin/podman";
options = [ "NOPASSWD" ];
}];
}];
```
The CLI auto-detects when sudo is needed and uses it transparently. Without this, you'll need to run `sudo hermes chat` manually.
:::
### Verify It Works
After `nixos-rebuild switch`, check that the service is running:
```bash
# Check service status
systemctl status hermes-agent
# Watch logs (Ctrl+C to stop)
journalctl -u hermes-agent -f
# If addToSystemPackages is true, test the CLI
hermes --version
hermes config # shows the generated config
```
### Choosing a Deployment Mode
The module supports two modes, controlled by `container.enable`:
| | **Native** (default) | **Container** |
|---|---|---|
| How it runs | Hardened systemd service on the host | Persistent Ubuntu container with `/nix/store` bind-mounted |
| Security | `NoNewPrivileges`, `ProtectSystem=strict`, `PrivateTmp` | Container isolation, runs as unprivileged user inside |
| Agent can self-install packages | No — only tools on the Nix-provided PATH | Yes — `apt`, `pip`, `npm` installs persist across restarts |
| Config surface | Same | Same |
| When to choose | Standard deployments, maximum security, reproducibility | Agent needs runtime package installation, mutable environment, experimental tools |
To enable container mode, add one line:
```nix
{
services.hermes-agent = {
enable = true;
container.enable = true;
# ... rest of config is identical
};
}
```
:::info
Container mode auto-enables `virtualisation.docker.enable` via `mkDefault`. If you use Podman instead, set `container.backend = "podman"` and `virtualisation.docker.enable = false`.
:::
:::note Cron on a native install needs a lingering service user
Scheduled cron jobs are launched in a transient `systemd-run --user --scope` so a gateway restart cannot kill a running job. That needs a systemd user manager for the service uid, which a system service only gets when the uid lingers. With `createUser = true` the module sets `users.users..linger = true` (nixpkgs ≥ 25.05), orders the gateway after `linger-users.service`, and waits briefly for `/run/user//bus` before starting. If you declare the user yourself (`createUser = false`), set `linger = true` on it or run `sudo loginctl enable-linger ` once; otherwise cron degrades to unscoped workers (or fails closed under `cron.require_restart_safe_scope: true`).
:::
---
## Configuration
### Declarative Settings
The `settings` option accepts an arbitrary attrset that is rendered as `config.yaml`. It supports deep merging across multiple module definitions (via `lib.recursiveUpdate`), so you can split config across files:
```nix
# base.nix
services.hermes-agent.settings = {
model.default = "anthropic/claude-sonnet-4";
toolsets = [ "all" ];
terminal = { backend = "local"; timeout = 180; };
};
# personality.nix
services.hermes-agent.settings = {
display = { compact = false; personality = "kawaii"; };
memory = { memory_enabled = true; user_profile_enabled = true; };
};
```
Both are deep-merged at evaluation time. Nix-declared keys always win over keys in an existing `config.yaml` on disk, but **user-added keys that Nix doesn't touch are preserved**. This means if the agent or a manual edit adds keys like `skills.disabled` or `streaming.enabled`, they survive `nixos-rebuild switch`.
:::note Model naming
`settings.model.default` uses the model identifier your provider expects. With [OpenRouter](https://openrouter.ai) (the default), these look like `"anthropic/claude-sonnet-4"` or `"google/gemini-3-flash"`. If you're using a provider directly (Anthropic, OpenAI), set `settings.model.base_url` to point at their API and use their native model IDs (e.g., `"claude-sonnet-4-20250514"`). When no `base_url` is set, Hermes defaults to OpenRouter.
:::
:::tip Discovering available config keys
Run `nix build .#configKeys && cat result` to see every leaf config key extracted from Python's `DEFAULT_CONFIG`. You can paste your existing `config.yaml` into the `settings` attrset — the structure maps 1:1.
:::
Full example: all commonly customized settings
```nix
{ config, ... }: {
services.hermes-agent = {
enable = true;
container.enable = true;
# ── Model ──────────────────────────────────────────────────────────
settings = {
model = {
base_url = "https://openrouter.ai/api/v1";
default = "anthropic/claude-opus-4.6";
};
toolsets = [ "all" ];
max_turns = 100;
terminal = { backend = "local"; cwd = "."; timeout = 180; };
compression = {
enabled = true;
threshold = 0.85;
summary_model = "google/gemini-3-flash-preview";
};
memory = { memory_enabled = true; user_profile_enabled = true; };
display = { compact = false; personality = "kawaii"; };
agent = { max_turns = 60; verbose = false; };
};
# ── Secrets ────────────────────────────────────────────────────────
environmentFiles = [ config.sops.secrets."hermes-env".path ];
# ── Documents ──────────────────────────────────────────────────────
# USER.md is memory, so it goes to HERMES_HOME. Workspace files use
# `documents`, and that option needs an explicit `workingDirectory`.
hermesHomeFiles = {
"memories/USER.md" = ./documents/USER.md;
};
# ── MCP Servers ────────────────────────────────────────────────────
mcpServers.filesystem = {
command = "npx";
args = [ "-y" "@modelcontextprotocol/server-filesystem" "/data/workspace" ];
};
# ── Container options ──────────────────────────────────────────────
container = {
image = "ubuntu:24.04";
backend = "docker";
hostUsers = [ "your-username" ];
extraVolumes = [ "/home/user/projects:/projects:rw" ];
extraOptions = [ "--gpus" "all" ];
};
# ── Service tuning ─────────────────────────────────────────────────
addToSystemPackages = true;
extraArgs = [ "--verbose" ];
restart = "always";
restartSec = 5;
};
}
```
### Escape Hatch: Bring Your Own Config
If you'd rather manage `config.yaml` entirely outside Nix, use `configFile`:
```nix
services.hermes-agent.configFile = /etc/hermes/config.yaml;
```
This bypasses `settings` entirely — no merge, no generation. The file is copied as-is to `$HERMES_HOME/config.yaml` on each activation.
### Customization Cheatsheet
Quick reference for the most common things Nix users want to customize:
| I want to... | Option | Example |
|---|---|---|
| Change the LLM model | `settings.model.default` | `"anthropic/claude-sonnet-4"` |
| Use a different provider endpoint | `settings.model.base_url` | `"https://openrouter.ai/api/v1"` |
| Add API keys | `environmentFiles` | `[ config.sops.secrets."hermes-env".path ]` |
| Give the agent an identity | `hermesHomeFiles."SOUL.md"` | `"You are a terse ops assistant."` |
| Add project context to the workspace | `documents."AGENTS.md"` | `./documents/AGENTS.md` |
| Run the backend for the desktop app or the dashboard | `backend.mode` | `"serve"` or `"dashboard"` |
| Add MCP tool servers | `mcpServers.` | See [MCP Servers](#mcp-servers) |
| Enable Discord/Telegram/Slack | `extraDependencyGroups` | `[ "messaging" ]` |
| Mount host directories into container | `container.extraVolumes` | `[ "/data:/data:rw" ]` |
| Pass GPU access to container | `container.extraOptions` | `[ "--gpus" "all" ]` |
| Use Podman instead of Docker | `container.backend` | `"podman"` |
| Share state between host CLI and container | `container.hostUsers` | `[ "sidbin" ]` |
| Make extra tools available to the agent | `extraPackages` | `[ pkgs.pandoc pkgs.imagemagick ]` |
| Use a custom base image | `container.image` | `"ubuntu:24.04"` |
| Override the hermes package | `package` | `inputs.hermes-agent.packages.${system}.default.override { ... }` |
| Change state directory | `stateDir` | `"/opt/hermes"` |
| Set the agent's working directory | `workingDirectory` | `"/home/user/projects"` |
---
## Secrets Management
:::danger Never put API keys in `settings` or `environment`
Values in Nix expressions end up in `/nix/store`, which is world-readable. Always use `environmentFiles` with a secrets manager.
:::
Both `environment` (non-secret vars) and `environmentFiles` (secret files) are merged into `$HERMES_HOME/.env` at activation time (`nixos-rebuild switch`). Hermes reads this file on every startup, so changes take effect with a `systemctl restart hermes-agent` — no container recreation needed.
### sops-nix
```nix
{
sops = {
defaultSopsFile = ./secrets/hermes.yaml;
age.keyFile = "/home/user/.config/sops/age/keys.txt";
secrets."hermes-env" = { format = "yaml"; };
};
services.hermes-agent.environmentFiles = [
config.sops.secrets."hermes-env".path
];
}
```
The secrets file contains key-value pairs:
```yaml
# secrets/hermes.yaml (encrypted with sops)
hermes-env: |
OPENROUTER_API_KEY=sk-or-...
TELEGRAM_BOT_TOKEN=123456:ABC...
ANTHROPIC_API_KEY=sk-ant-...
```
### agenix
```nix
{
age.secrets.hermes-env.file = ./secrets/hermes-env.age;
services.hermes-agent.environmentFiles = [
config.age.secrets.hermes-env.path
];
}
```
### OAuth / Auth Seeding
For platforms requiring OAuth (e.g., Discord), use `authFile` to seed credentials on first deploy:
```nix
{
services.hermes-agent = {
authFile = config.sops.secrets."hermes/auth.json".path;
# authFileForceOverwrite = true; # overwrite on every activation
};
}
```
The file is only copied if `auth.json` doesn't already exist (unless `authFileForceOverwrite = true`). Runtime OAuth token refreshes are written to the state directory and preserved across rebuilds.
---
## Documents
Hermes reads files from two directories. Thus there are two options. Use the option for the directory that the file must go into.
`documents` installs into the **working directory** of the agent, which is `workingDirectory`. The agent reads its project context from that workspace:
```nix
{
services.hermes-agent = {
# documents needs this option. Read the note below.
workingDirectory = "/var/lib/hermes/workspace";
documents = {
"AGENTS.md" = ./documents/AGENTS.md; # path reference, copied from Nix store
"notes/oncall.md" = "Page #infra before restarting anything.";
};
};
}
```
:::warning documents needs an explicit workingDirectory
The module refuses `documents` until you set `workingDirectory`. The default of
that option is different on each module. It is your home directory on Home
Manager, and `${stateDir}/workspace` on NixOS. Thus an unset default puts the
files in a directory that you did not select. A directory with the same path as
the default is a correct selection, and it satisfies the rule.
:::
`hermesHomeFiles` installs into **`HERMES_HOME`**. Hermes reads the identity file and the memory files of the agent from that directory. `SOUL.md` and `memories/` work only from there. A `SOUL.md` in `documents` makes a workspace file. Hermes does not load that file as the identity:
```nix
{
services.hermes-agent.hermesHomeFiles = {
"SOUL.md" = "You are a helpful AI assistant.";
"memories/USER.md" = ./documents/USER.md;
};
}
```
Each value is a string or a path. A key in either option can contain subdirectories, and the module makes the parent directories. Each activation installs the files again.
`hermesHomeFiles` needs no `workingDirectory`, because the module owns the `HERMES_HOME` directory. Most users want `hermesHomeFiles`.
---
## MCP Servers
The `mcpServers` option declaratively configures [MCP (Model Context Protocol)](https://modelcontextprotocol.io) servers. Each server uses either **stdio** (local command) or **HTTP** (remote URL) transport.
### Stdio Transport (Local Servers)
```nix
{
services.hermes-agent.mcpServers = {
filesystem = {
command = "npx";
args = [ "-y" "@modelcontextprotocol/server-filesystem" "/data/workspace" ];
};
github = {
command = "npx";
args = [ "-y" "@modelcontextprotocol/server-github" ];
env.GITHUB_PERSONAL_ACCESS_TOKEN = "\${GITHUB_TOKEN}"; # resolved from .env
};
};
}
```
:::tip
Environment variables in `env` values are resolved from `$HERMES_HOME/.env` at runtime. Use `environmentFiles` to inject secrets — never put tokens directly in Nix config.
:::
### HTTP Transport (Remote Servers)
```nix
{
services.hermes-agent.mcpServers.remote-api = {
url = "https://mcp.example.com/v1/mcp";
headers.Authorization = "Bearer \${MCP_REMOTE_API_KEY}";
timeout = 180;
};
}
```
### HTTP Transport with OAuth
Set `auth = "oauth"` for servers using OAuth 2.1. Hermes implements the full PKCE flow — metadata discovery, dynamic client registration, token exchange, and automatic refresh.
```nix
{
services.hermes-agent.mcpServers.my-oauth-server = {
url = "https://mcp.example.com/mcp";
auth = "oauth";
};
}
```
Tokens are stored in `$HERMES_HOME/mcp-tokens/.json` and persist across restarts and rebuilds.
Initial OAuth authorization on headless servers
The first OAuth authorization requires a browser-based consent flow. In a headless deployment, Hermes prints the authorization URL to stdout/logs instead of opening a browser.
**Option A: Interactive bootstrap** — run the flow once via `docker exec` (container) or `sudo -u hermes` (native):
```bash
# Container mode
docker exec -it hermes-agent \
hermes mcp add my-oauth-server --url https://mcp.example.com/mcp --auth oauth
# Native mode
sudo -u hermes HERMES_HOME=/var/lib/hermes/.hermes \
hermes mcp add my-oauth-server --url https://mcp.example.com/mcp --auth oauth
```
The container uses `--network=host`, so the OAuth callback listener on `127.0.0.1` is reachable from the host browser.
**Option B: Pre-seed tokens** — complete the flow on a workstation, then copy tokens:
```bash
hermes mcp add my-oauth-server --url https://mcp.example.com/mcp --auth oauth
scp ~/.hermes/mcp-tokens/my-oauth-server{,.client}.json \
server:/var/lib/hermes/.hermes/mcp-tokens/
# Ensure: chown hermes:hermes, chmod 0600
```
### Sampling (Server-Initiated LLM Requests)
Some MCP servers can request LLM completions from the agent:
```nix
{
services.hermes-agent.mcpServers.analysis = {
command = "npx";
args = [ "-y" "analysis-server" ];
sampling = {
enabled = true;
model = "google/gemini-3-flash";
max_tokens_cap = 4096;
timeout = 30;
max_rpm = 10;
};
};
}
```
---
## Managed Mode
When hermes runs via the NixOS module, the following CLI commands are **blocked** with a descriptive error pointing you to `configuration.nix`:
| Blocked command | Why |
|---|---|
| `hermes setup` | Config is declarative — edit `settings` in your Nix config |
| `hermes config edit` | Config is generated from `settings` |
| `hermes config set ` | Config is generated from `settings` |
| `hermes gateway install` | The systemd service is managed by NixOS |
| `hermes gateway uninstall` | The systemd service is managed by NixOS |
This prevents drift between what Nix declares and what's on disk. Detection uses two signals:
1. **The `HERMES_MANAGED` environment variable.** The service sets it, and the gateway process reads it.
2. **The `.managed` marker file** in `HERMES_HOME`. The activation script writes it, and an interactive shell reads it. Thus the CLI also blocks a command such as `docker exec -it hermes-agent hermes config set ...`.
Both signals hold the name of the system that manages the install. Thus the refusal names the correct rebuild command. The NixOS module gives `sudo nixos-rebuild switch`. The Home Manager module gives `home-manager switch`.
---
## Home Manager Module
The flake also exports `homeManagerModules.default`. Hermes is an agent for one person. The credentials, the memory, the sessions and the cron jobs all belong to that person. Thus a user service is the correct shape on a personal machine. It runs on each distribution that Home Manager supports, and not only on NixOS.
The option set is the same set that the NixOS module uses. It is `services.hermes-agent`, with the same `settings`, `environmentFiles`, `documents`, `mcpServers`, `extraPlugins` and `backend` options. Each example above works here without a change. Only the necessary parts are different:
| | NixOS module | Home Manager module |
|---|---|---|
| Runs as | a system user that you declare, with `user`, `group` and `createUser` | you |
| State directory | `stateDir` and `/.hermes` | `hermesHome`, set directly. The default is `~/.hermes`. |
| Service | `systemd.services` | `systemd.user.services` on Linux, `launchd.agents` on macOS |
| CLI on the PATH | `addToSystemPackages`, which exports `HERMES_HOME` for the full system | `programs.hermes-agent.enable`, which exports it for your session only |
| Desktop application | not supported, because a system service cannot own a user session | `programs.hermes-agent.desktop.enable` |
| Container mode | supported | not supported, because it needs root and the Docker socket |
### Add the Flake Input
```nix
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
home-manager.url = "github:nix-community/home-manager";
home-manager.inputs.nixpkgs.follows = "nixpkgs";
hermes-agent.url = "github:NousResearch/hermes-agent";
};
}
```
Then import the module into your Home Manager configuration. The configuration can be standalone. It can also be under `home-manager.users.` in a NixOS or nix-darwin configuration:
```nix
{
imports = [ hermes-agent.homeManagerModules.default ];
services.hermes-agent = {
enable = true;
gateway.enable = true;
settings.model.default = "anthropic/claude-sonnet-4";
environmentFiles = [ config.sops.secrets."hermes-env".path ];
};
}
```
`home-manager switch` makes `~/.hermes`, writes `config.yaml`, builds `.env` and starts the gateway as a user service.
:::warning Enable linger, or the service stops at logout
CAUTION: Enable linger for your account. Without linger, systemd stops the user manager when your last session ends, and the gateway stops with it. Home Manager cannot set linger, because linger is a property of the account:
```nix
# NixOS
users.users.your-username.linger = true;
```
```bash
# anywhere else
sudo loginctl enable-linger your-username
```
macOS has no equivalent option. A `launchd` agent with `RunAtLoad` starts at login and continues to run.
:::
### Running the Desktop / Dashboard Backend
`gateway.enable` runs the messaging gateway for Telegram, Discord, Slack and the other platforms. Hermes Desktop and the web dashboard connect to a *different* process, which is `hermes serve` or `hermes dashboard`. `backend.mode` runs that process with the gateway:
```nix
{
services.hermes-agent = {
enable = true;
gateway.enable = true; # messaging platforms
backend.mode = "dashboard"; # + the browser dashboard on 127.0.0.1:9119
backend.port = 9119;
};
}
```
`serve` runs without a user interface. It gives the `/api/ws` and `/api/pty` sockets that Hermes Desktop connects to, and it does not build the web application. `dashboard` gives all of that, and also serves the browser admin panel. Both processes use one `HERMES_HOME` with the gateway. Thus the sessions, the skills, the memory and the cron jobs are the same for all of them. `backend.mode` works in the same way on the NixOS module, but not in container mode.
:::warning Binding to an address other than loopback
The default address is `127.0.0.1`. Each other address starts the authentication gate of the dashboard. The server also refuses each request with a `Host` header that is different from the address that the server bound to. This is a defence against DNS rebinding. Bind to the name or the address that your client uses.
:::
### Verify It Works
```bash
# Linux
systemctl --user status hermes-agent
journalctl --user -u hermes-agent -f
# macOS
launchctl list | grep hermes
tail -f ~/Library/Logs/hermes-agent.log
hermes --version
hermes config # shows the configuration that Nix wrote
```
---
## Container Architecture
:::info
This section is only relevant if you're using `container.enable = true`. Skip it for native mode deployments.
:::
When container mode is enabled, hermes runs inside a persistent Ubuntu container with the Nix-built binary bind-mounted read-only from the host:
```
Host Container
──── ─────────
/nix/store/...-hermes-agent-0.1.0 ──► /nix/store/... (ro)
~/.hermes -> /var/lib/hermes/.hermes (symlink bridge, per hostUsers)
/var/lib/hermes/ ──► /data/ (rw)
├── current-package -> /nix/store/... (symlink, updated each rebuild)
├── .gc-root -> /nix/store/... (prevents nix-collect-garbage)
├── .container-identity (sha256 hash, triggers recreation)
├── .hermes/ (HERMES_HOME)
│ ├── .env (merged from environment + environmentFiles)
│ ├── config.yaml (Nix-generated, deep-merged by activation)
│ ├── .managed (marker file)
│ ├── .container-mode (routing metadata: backend, exec_user, etc.)
│ ├── state.db, sessions/, memories/ (runtime state)
│ └── mcp-tokens/ (OAuth tokens for MCP servers)
├── home/ ──► /home/hermes (rw)
└── workspace/ (agent working directory)
├── AGENTS.md (from the documents option)
└── (agent-created files)
Container writable layer (apt/pip/npm): /usr, /usr/local, /tmp
```
The Nix-built binary works inside the Ubuntu container because `/nix/store` is bind-mounted — it brings its own interpreter and all dependencies, so there's no reliance on the container's system libraries. The container entrypoint resolves through a `current-package` symlink: `/data/current-package/bin/hermes gateway run --replace`. On `nixos-rebuild switch`, only the symlink is updated — the container keeps running.
### What Persists Across What
| Event | Container recreated? | `/data` (state) | `/home/hermes` | Writable layer (`apt`/`pip`/`npm`) |
|---|---|---|---|---|
| `systemctl restart hermes-agent` | No | Persists | Persists | Persists |
| `nixos-rebuild switch` (code change) | No (symlink updated) | Persists | Persists | Persists |
| Host reboot | No | Persists | Persists | Persists |
| `nix-collect-garbage` | No (GC root) | Persists | Persists | Persists |
| Image change (`container.image`) | **Yes** | Persists | Persists | **Lost** |
| Volume/options change | **Yes** | Persists | Persists | **Lost** |
| `environment`/`environmentFiles` change | No | Persists | Persists | Persists |
The container is only recreated when its **identity hash** changes. The hash covers: schema version, image, `extraVolumes`, `extraOptions`, and the entrypoint script. Changes to environment variables, settings, documents, or the hermes package itself do **not** trigger recreation.
:::warning Writable layer loss
When the identity hash changes (image upgrade, new volumes, new container options), the container is destroyed and recreated from a fresh pull of `container.image`. Any `apt install`, `pip install`, or `npm install` packages in the writable layer are lost. State in `/data` and `/home/hermes` is preserved (these are bind mounts).
If the agent relies on specific packages, consider baking them into a custom image (`container.image = "my-registry/hermes-base:latest"`) or scripting their installation in the agent's SOUL.md.
:::
### GC Root Protection
The `preStart` script creates a GC root at `${stateDir}/.gc-root` pointing to the current hermes package. This prevents `nix-collect-garbage` from removing the running binary. If the GC root somehow breaks, restarting the service recreates it.
---
## Plugins
The NixOS module supports declarative plugin installation — no imperative `hermes plugins install` needed.
### Directory Plugins (`extraPlugins`)
For plugins that are just a source tree with `plugin.yaml` + `__init__.py` (e.g., [hermes-lcm](https://github.com/stephenschoettler/hermes-lcm)):
```nix
services.hermes-agent.extraPlugins = [
(pkgs.fetchFromGitHub {
owner = "stephenschoettler";
repo = "hermes-lcm";
rev = "v0.7.0";
hash = "sha256-...";
})
];
```
Plugins are symlinked into `$HERMES_HOME/plugins/` at activation time. Hermes discovers them via its normal directory scan. Removing a plugin from the list and running `nixos-rebuild switch` removes the symlink.
### Entry-Point Plugins (`extraPythonPackages`)
For pip-packaged plugins that register via `[project.entry-points."hermes_agent.plugins"]` (e.g., [rtk-hermes](https://github.com/ogallotti/rtk-hermes)):
```nix
services.hermes-agent.extraPythonPackages = [
(config.services.hermes-agent.package.python.pkgs.buildPythonPackage {
pname = "rtk-hermes";
version = "1.0.0";
src = pkgs.fetchFromGitHub {
owner = "ogallotti";
repo = "rtk-hermes";
rev = "v1.0.0";
hash = "sha256-...";
};
format = "pyproject";
build-system = [ config.services.hermes-agent.package.python.pkgs.setuptools ];
})
];
```
The package's `site-packages` is added to PYTHONPATH in the hermes wrapper. `importlib.metadata` discovers the entry point at session start.
### Optional Dependency Groups (`extraDependencyGroups`)
For optional extras declared in hermes-agent's `pyproject.toml`, use `extraDependencyGroups` to include them in the sealed venv at build time. This is required for any extra not in the default `[all]` set — on Nix, runtime installation into the read-only store is not possible.
```nix
# Enable Discord, Telegram, Slack
services.hermes-agent.extraDependencyGroups = [ "messaging" ];
```
```nix
# Enable a memory provider
services.hermes-agent = {
extraDependencyGroups = [ "honcho" ];
settings.memory.provider = "honcho";
};
```
These groups join the core dependency resolution at build time. Conflicting
requirements can still fail that resolution. The table lists common groups;
`pyproject.toml` is authoritative for the complete list and platform markers.
| Group | What it enables |
|-------|-----------------|
| `messaging` | Discord, Telegram, Slack |
| `matrix` | Matrix/Element (mautrix with encryption; Linux only) |
| `dingtalk` | DingTalk |
| `feishu` | Feishu/Lark |
| `voice` | Local speech-to-text (faster-whisper) |
| `edge-tts` | Edge TTS provider |
| `tts-premium` | ElevenLabs TTS |
| `anthropic` | Native Anthropic SDK (not needed via OpenRouter) |
| `bedrock` | AWS Bedrock (boto3) |
| `azure-identity` | Azure Entra ID auth |
| `honcho` | Honcho memory provider |
| `modal` | Modal terminal backend |
| `daytona` | Daytona terminal backend |
| `exa` | Exa web search |
| `firecrawl` | Firecrawl web search |
| `fal` | FAL image generation |
Memory providers that live in the [plugin catalog](../user-guide/features/plugins.md) rather than in the Hermes tree (e.g. Hindsight) are not extras. Install them like any catalog plugin with `hermes plugins install hindsight`, or declaratively via [`extraPlugins`](#directory-plugins-extraplugins) pointing at the plugin's source tree.
Or use the pre-built `#messaging` or `#full` flake packages instead of per-extra configuration (see [Quick Start](#quick-start-any-nix-user)).
**When to use which:**
| Need | Option |
|------|--------|
| Enable a pyproject.toml optional extra | `extraDependencyGroups` |
| Add an external Python plugin not in pyproject.toml | `extraPythonPackages` |
| Add a system binary (pandoc, jq, etc.) | `extraPackages` |
| Add a directory-based plugin source tree | `extraPlugins` |
### Combining Both
A directory plugin with third-party Python dependencies needs both options:
```nix
services.hermes-agent = {
extraPlugins = [ my-plugin-src ]; # plugin source
extraPythonPackages = [ config.services.hermes-agent.package.python.pkgs.redis ]; # its Python dep
extraPackages = [ pkgs.redis ]; # system binary it needs
};
```
### Using the Overlay
External flakes can override the package directly:
```nix
{
inputs.hermes-agent.url = "github:NousResearch/hermes-agent";
outputs = { hermes-agent, nixpkgs, ... }: {
nixpkgs.overlays = [ hermes-agent.overlays.default ];
# Then:
# pkgs.hermes-agent.override { extraPythonPackages = [...]; }
# pkgs.hermes-agent.override { extraDependencyGroups = [ "honcho" ]; }
};
}
```
### Plugin Configuration
Plugins still need to be enabled in `config.yaml`. Add them via the declarative settings:
```nix
services.hermes-agent.settings.plugins.enabled = [
"hermes-lcm"
"rtk-rewrite"
];
```
:::note
A build-time collision check prevents plugin packages from shadowing core hermes dependencies. If a plugin provides a package already in the sealed venv, `nixos-rebuild` fails with a clear error.
:::
---
## Development
### Dev Shell
The flake provides an editable Python environment with the lock-derived interpreter
and the `dev` dependency group. `HERMES_PYTHON` points to its interpreter. It does not install
Python dependencies into a repository-local `.venv`. The shell also provides
Node.js and runtime tools. Its npm hook refreshes JS workspaces when their inputs change.
```bash
cd hermes-agent
nix develop
"$HERMES_PYTHON" -c "import sys; print(sys.executable); print(sys.version)"
hermes setup
hermes chat
```
### direnv (Recommended)
The included `.envrc` activates the dev shell automatically:
```bash
cd hermes-agent
direnv allow # one-time
# Nix reuses its built Python environment; the npm hook checks JS inputs.
```
### Flake Checks
The flake includes build-time verification that runs in CI and locally:
```bash
# Run all checks
nix flake check
# Individual checks
nix build .#checks.x86_64-linux.package-contents # binaries exist + version
nix build .#checks.x86_64-linux.entry-points-sync # pyproject.toml ↔ Nix package sync
nix build .#checks.x86_64-linux.cli-commands # gateway/config subcommands
nix build .#checks.x86_64-linux.managed-guard # HERMES_MANAGED blocks mutation
nix build .#checks.x86_64-linux.bundled-skills # skills present in package
nix build .#checks.x86_64-linux.config-roundtrip # merge script preserves user keys
```
What each check verifies
| Check | What it tests |
|---|---|
| `package-contents` | `hermes` and `hermes-agent` binaries exist and `hermes --version` runs |
| `entry-points-sync` | Every `[project.scripts]` entry in `pyproject.toml` has a wrapped binary in the Nix package |
| `cli-commands` | `hermes --help` exposes `gateway` and `config` subcommands |
| `managed-guard` | `HERMES_MANAGED=true hermes config set ...` prints the NixOS error |
| `bundled-skills` | Skills directory exists, contains SKILL.md files, `HERMES_BUNDLED_SKILLS` is set in wrapper |
| `config-roundtrip` | 7 merge scenarios: fresh install, Nix override, user key preservation, mixed merge, MCP additive merge, nested deep merge, idempotency |
---
## Options Reference
### Core
| Option | Type | Default | Description |
|---|---|---|---|
| `enable` | `bool` | `false` | Enable the hermes-agent service |
| `package` | `package` | `hermes-agent` | The hermes-agent package to use |
| `user` | `str` | `"hermes"` | System user |
| `group` | `str` | `"hermes"` | System group |
| `createUser` | `bool` | `true` | Auto-create user/group |
| `stateDir` | `str` | `"/var/lib/hermes"` | State directory (`HERMES_HOME` parent) |
| `workingDirectory` | `str` | `"${stateDir}/workspace"` | Agent working directory |
| `addToSystemPackages` | `bool` | `false` | Add `hermes` CLI to system PATH and set `HERMES_HOME` system-wide |
### Configuration
| Option | Type | Default | Description |
|---|---|---|---|
| `settings` | `attrs` (deep-merged) | `{}` | Declarative config rendered as `config.yaml`. Supports arbitrary nesting; multiple definitions are merged via `lib.recursiveUpdate` |
| `configFile` | `null` or `path` | `null` | Path to an existing `config.yaml`. Overrides `settings` entirely if set |
### Secrets & Environment
| Option | Type | Default | Description |
|---|---|---|---|
| `environmentFiles` | `listOf str` | `[]` | Paths to env files with secrets. Merged into `$HERMES_HOME/.env` at activation time |
| `environment` | `attrsOf str` | `{}` | Non-secret env vars. **Visible in Nix store** — do not put secrets here |
| `authFile` | `null` or `path` | `null` | OAuth credentials seed. Only copied on first deploy |
| `authFileForceOverwrite` | `bool` | `false` | Always overwrite `auth.json` from `authFile` on activation |
### Documents
| Option | Type | Default | Description |
|---|---|---|---|
| `documents` | `attrsOf (either str path)` | `{}` | Workspace files. Each key is a path relative to `workingDirectory`. You must set that option to use this one. |
| `hermesHomeFiles` | `attrsOf (either str path)` | `{}` | Files that go into `HERMES_HOME`. `SOUL.md` and `memories/` must be here, or Hermes does not load them. |
### MCP Servers
| Option | Type | Default | Description |
|---|---|---|---|
| `mcpServers` | `attrsOf submodule` | `{}` | MCP server definitions, merged into `settings.mcp_servers` |
| `mcpServers..command` | `null` or `str` | `null` | Server command (stdio transport) |
| `mcpServers..args` | `listOf str` | `[]` | Command arguments |
| `mcpServers..env` | `attrsOf str` | `{}` | Environment variables for the server process |
| `mcpServers..url` | `null` or `str` | `null` | Server endpoint URL (HTTP/StreamableHTTP transport) |
| `mcpServers..headers` | `attrsOf str` | `{}` | HTTP headers, e.g. `Authorization` |
| `mcpServers..auth` | `null` or `"oauth"` | `null` | Authentication method. `"oauth"` enables OAuth 2.1 PKCE |
| `mcpServers..enabled` | `bool` | `true` | Enable or disable this server |
| `mcpServers..timeout` | `null` or `int` | `null` | Tool call timeout in seconds (default: 120) |
| `mcpServers..connect_timeout` | `null` or `int` | `null` | Connection timeout in seconds (default: 60) |
| `mcpServers..tools` | `null` or `submodule` | `null` | Tool filtering (`include`/`exclude` lists) |
| `mcpServers..sampling` | `null` or `submodule` | `null` | Sampling config for server-initiated LLM requests |
### Service Behavior
| Option | Type | Default | Description |
|---|---|---|---|
| `extraArgs` | `listOf str` | `[]` | Extra args for `hermes gateway` |
| `extraPackages` | `listOf package` | `[]` | Extra packages available to the agent. Added to the hermes user's per-user profile so terminal commands, skills, and cron jobs all see them |
| `extraPlugins` | `listOf package` | `[]` | Directory plugin packages to symlink into `$HERMES_HOME/plugins/`. Each must contain `plugin.yaml` |
| `extraPythonPackages` | `listOf package` | `[]` | Python packages added to PYTHONPATH for entry-point plugin discovery. Use the selected package’s `python.pkgs` |
| `extraDependencyGroups` | `listOf str` | `[]` | pyproject.toml optional extras to include in the sealed venv (e.g. `["honcho"]`). Resolved by uv — no collisions |
| `restart` | `str` | `"always"` | The systemd `Restart=` policy. macOS does not use it. |
| `restartSec` | `int` | `5` | The systemd `RestartSec=` value. macOS does not use it. |
### Backend (`hermes serve` / `hermes dashboard`)
This option runs the process that Hermes Desktop and the web dashboard connect to, with the gateway. You cannot use it with `container.enable`.
| Option | Type | Default | Description |
|---|---|---|---|
| `backend.mode` | `enum ["none" "serve" "dashboard"]` | `"none"` | `serve` runs without a user interface and gives `/api/ws` and `/api/pty`. `dashboard` also serves the browser panel. |
| `backend.host` | `str` | `"127.0.0.1"` | The address to bind to. Each address other than loopback starts the authentication gate. |
| `backend.port` | `port` | `9119` | The port to bind to |
| `backend.extraArgs` | `listOf str` | `[]` | More arguments for the backend command |
### Home Manager only
| Option | Type | Default | Description |
|---|---|---|---|
| `hermesHome` | `str` | `"${config.home.homeDirectory}/.hermes"` | `HERMES_HOME` directly. The NixOS module builds it from `stateDir`. |
| `gateway.enable` | `bool` | `false` | Run the messaging gateway. On the NixOS module the gateway is the service, so that module has no such option. |
### `programs.hermes-agent` (Home Manager only)
Home Manager separates "install this application for me" from "run this
daemon". `services.hermes-agent` keeps the state, the configuration and the
daemons. `programs.hermes-agent` installs what you use, and reads
`hermesHome` and the backend address from the services.
| Option | Type | Default | Description |
|---|---|---|---|
| `enable` | `bool` | `false` | Add the `hermes` CLI to `home.packages`, and export `HERMES_HOME` for your shells |
| `package` | `package` | `services.hermes-agent.package` | The package to install. The default applies `extraPythonPackages` and `extraDependencyGroups` from the services, so both are one build. |
| `desktop.enable` | `bool` | `false` | Add the Hermes Desktop application, with a launcher entry on Linux |
| `desktop.package` | `package` | `package.hermesDesktop` | The desktop package. The default follows `package`, so the application and the services run one Hermes runtime. |
```nix
programs.hermes-agent = {
enable = true;
desktop.enable = true;
};
services.hermes-agent = {
enable = true;
backend.mode = "serve";
backend.sessionTokenFile = config.sops.secrets."hermes/desktop-token".path;
};
```
The launcher carries `HERMES_HOME` itself. A desktop menu reads no shell
profile, so the value that `programs.hermes-agent.enable` exports with
`home.sessionVariables` reaches an interactive shell only. Without the
value in the launcher, the application opens `~/.hermes` while the
services use `hermesHome`, and you see no sessions and no keys.
With `backend.sessionTokenFile`, the application connects to the backend
of the service instead of starting one of its own. Both sides read the
file at start time, so the token enters no Nix store path. Without the
option, each side runs its own backend.
`services.hermes-agent.installPackage` was removed by this split. A
configuration that still sets it gets an error that names the
replacement.
### Container (NixOS only)
| Option | Type | Default | Description |
|---|---|---|---|
| `container.enable` | `bool` | `false` | Enable OCI container mode |
| `container.backend` | `enum ["docker" "podman"]` | `"docker"` | Container runtime |
| `container.image` | `str` | `"ubuntu:24.04"` | Base image (pulled at runtime) |
| `container.extraVolumes` | `listOf str` | `[]` | Extra volume mounts (`host:container:mode`) |
| `container.extraOptions` | `listOf str` | `[]` | Extra args passed to `docker create` |
| `container.hostUsers` | `listOf str` | `[]` | Interactive users who get a `~/.hermes` symlink to the service stateDir and are auto-added to the `hermes` group |
---
## Directory Layout
### Native Mode
```
/var/lib/hermes/ # stateDir (owned by hermes:hermes, 0750)
├── .hermes/ # HERMES_HOME
│ ├── SOUL.md # from hermesHomeFiles: the agent identity
│ ├── config.yaml # Nix-generated (deep-merged each rebuild)
│ ├── .managed # Marker: CLI config mutation blocked
│ ├── .env # Merged from environment + environmentFiles
│ ├── auth.json # OAuth credentials (seeded, then self-managed)
│ ├── gateway.pid
│ ├── state.db
│ ├── mcp-tokens/ # OAuth tokens for MCP servers
│ ├── sessions/
│ ├── memories/
│ ├── skills/
│ ├── cron/
│ └── logs/
├── home/ # Agent HOME
└── workspace/ # Agent working directory
├── AGENTS.md # from the documents option
└── (agent-created files)
```
### Home Manager
```
~/.hermes/ # hermesHome (HERMES_HOME), 0700
├── SOUL.md # from hermesHomeFiles
├── config.yaml # written by Nix, merged at each activation
├── .managed # marker: names the system that manages this
├── .env # written again from environment + environmentFiles
├── auth.json # OAuth credentials: seeded, then Hermes owns it
├── memories/ sessions/ skills/ cron/ logs/ plugins/
└── (runtime state)
~/ # workingDirectory, your home by default
└── AGENTS.md # from the documents option
```
### Container Mode
Same layout, mounted into the container:
| Container path | Host path | Mode | Notes |
|---|---|---|---|
| `/nix/store` | `/nix/store` | `ro` | Hermes binary + all Nix deps |
| `/data` | `/var/lib/hermes` | `rw` | All state, config, workspace |
| `/home/hermes` | `${stateDir}/home` | `rw` | Persistent agent home — `pip install --user`, tool caches |
| `/usr`, `/usr/local`, `/tmp` | (writable layer) | `rw` | `apt`/`pip`/`npm` installs — persists across restarts, lost on recreation |
---
## Updating
```bash
# Update the flake input (run from the directory containing flake.nix)
cd /etc/nixos && nix flake update hermes-agent
# Rebuild
sudo nixos-rebuild switch # for the NixOS module
home-manager switch # for the Home Manager module
```
In container mode, the `current-package` symlink is updated and the agent picks up the new binary on restart. No container recreation, no loss of installed packages.
---
## Troubleshooting
:::tip Podman users
All `docker` commands below work the same with `podman`. Substitute accordingly if you set `container.backend = "podman"`.
:::
### Service Logs
```bash
# Both modes use the same systemd unit
journalctl -u hermes-agent -f
# Container mode: also available directly
docker logs -f hermes-agent
```
### Container Inspection
```bash
systemctl status hermes-agent
docker ps -a --filter name=hermes-agent
docker inspect hermes-agent --format='{{.State.Status}}'
docker exec -it hermes-agent bash
docker exec hermes-agent readlink /data/current-package
docker exec hermes-agent cat /data/.container-identity
```
### Force Container Recreation
If you need to reset the writable layer (fresh Ubuntu):
```bash
sudo systemctl stop hermes-agent
docker rm -f hermes-agent
sudo rm /var/lib/hermes/.container-identity
sudo systemctl start hermes-agent
```
### Verify Secrets Are Loaded
If the agent starts but can't authenticate with the LLM provider, check that the `.env` file was merged correctly:
```bash
# Native mode
sudo -u hermes cat /var/lib/hermes/.hermes/.env
# Container mode
docker exec hermes-agent cat /data/.hermes/.env
```
### GC Root Verification
```bash
nix-store --query --roots $(docker exec hermes-agent readlink /data/current-package)
```
### Common Issues
| Symptom | Cause | Fix |
|---|---|---|
| `Cannot save configuration: managed by NixOS` | CLI guards active | Edit `configuration.nix` and `nixos-rebuild switch` |
| `No adapter available for discord` (or telegram/slack) | Messaging deps missing from the sealed Nix venv | Install `#messaging` variant: `nix profile install ...#messaging`. For NixOS module: `extraDependencyGroups = [ "messaging" ]`. Read `journalctl -u hermes-agent` for `InstallError` or `requirements not met` and the underlying cause. |
| Container recreated unexpectedly | `extraVolumes`, `extraOptions`, or `image` changed | Expected — writable layer resets. Reinstall packages or use a custom image |
| `hermes --version` shows old version | Container not restarted | `systemctl restart hermes-agent` |
| Permission denied on `/var/lib/hermes` | State dir is `0750 hermes:hermes` | Use `docker exec` or `sudo -u hermes` |
| `nix-collect-garbage` removed hermes | GC root missing | Restart the service (preStart recreates the GC root) |
| `no container with name or ID "hermes-agent"` (Podman) | Podman rootful container not visible to regular user | Add passwordless sudo for podman (see [Container Mode](#container-mode) section) |
| `unable to find user hermes` | Container still starting (entrypoint hasn't created user yet) | Wait a few seconds and retry — the CLI retries automatically |
| Tool added via `extraPackages` not found in terminal | Requires `nixos-rebuild switch` to update the per-user profile | Rebuild and restart: `nixos-rebuild switch && systemctl restart hermes-agent` |
---
# CLI Interface
# CLI Interface
Hermes Agent's CLI is a full terminal user interface (TUI) — not a web UI. It features multiline editing, slash-command autocomplete, conversation history, interrupt-and-redirect, and streaming tool output. Built for people who live in the terminal.
:::tip First-time setup
One command — `hermes setup --portal` — and you're ready to `hermes chat`. See [Nous Portal](../integrations/nous-portal.md).
:::
:::tip
Hermes also ships a modern TUI with modal overlays, mouse selection, and non-blocking input. Launch it with `hermes --tui` — see the [TUI](tui.md) guide.
:::
## Running the CLI
```bash
# Start an interactive session (default)
hermes
# Single query mode (non-interactive)
hermes chat -q "Hello"
# Single query from a file or stdin — nothing is shell-interpreted, so
# arbitrary text (quotes, $(...), backticks) arrives verbatim
hermes chat --query-file prompt.txt
hermes chat --query-file - < prompt.txt
# With a specific model
hermes chat --model "anthropic/claude-sonnet-4"
# With a specific provider
hermes chat --provider nous # Use Nous Portal
hermes chat --provider openrouter # Force OpenRouter
# With specific toolsets
hermes chat --toolsets "web,terminal,skills"
# Start with one or more skills preloaded
hermes -s hermes-agent-dev,github-auth
hermes chat -s github-pr-workflow -q "open a draft PR"
# Resume previous sessions
hermes --continue # Resume the most recent CLI session (-c)
hermes --resume # Resume a specific session by ID (-r)
hermes --resume latest # Resume the most recent session (same as -c)
hermes --resume latest --in ./dir # Resume ./dir's latest session, staying in ./dir
# Verbose mode (debug output)
hermes chat --verbose
# Isolated git worktree (for running multiple agents in parallel)
hermes -w # Interactive mode in worktree
hermes -w -z "Fix issue #123" # Single query in worktree
```
### Worktree cleanup
`hermes -w` sessions create disposable worktrees under `/.worktrees/`.
A conservative pruner runs automatically at startup (it only removes clean,
fully-merged scratch trees past an age threshold), but preserved trees and
merged local branches still accumulate on busy machines. Reclaim them
explicitly:
```bash
hermes worktree list # audit: age, size, verdict, reason per tree
hermes worktree list --json # machine-readable audit (trees, external trees, branches)
hermes worktree prune # remove safe trees + delete merged branches
hermes worktree prune --dry-run # show the plan without changing anything
hermes worktree prune --older-than 7 # only reap trees idle for 7+ days
hermes worktree prune --trees-only # leave local branches alone
hermes worktree prune --branches-only # leave worktrees alone
```
Worktrees registered **outside** `.worktrees/` (created by hand or by another
tool) are reported read-only in `list` output and are never removed. The one
exception is metadata: registrations whose directory no longer exists are
dropped via `git worktree prune` (no files are touched). `--older-than DAYS`
only ever narrows what gets reaped — a tree carrying real work is kept at any
age regardless of the flag.
Inside a session, `/worktree prune [--dry-run]` does the same (and never
touches the tree the session is running in).
Safety guarantees (all modes, any age):
- Uncommitted **tracked** changes are never deleted.
- **Unique unpushed commits** are never deleted — commits that were
rebase/squash-merged upstream are detected via `git cherry`
patch-equivalence and count as merged, which is what lets the dominant
"merged PR, tree preserved forever" leak finally reclaim.
- **Repositories without a remote** are judged against the local trunk
(`main`/`master`, else the branch checked out in the main worktree): only
trees and branches whose commits are reachable from — or patch-equivalent
to — that trunk are reclaimed. With no trunk to compare against, every tree
and branch is preserved.
- **Pushed open-PR lanes free their disk without losing anything**: when a
clean tree's branch head exactly matches what `origin` holds (checked with
one `git ls-remote` per sweep), the checkout is redundant — the tree is
removed but its **branch ref is kept**, so the lane is one
`git worktree add .worktrees/` away from restored. If the
remote can't be reached, the tree is preserved.
- Trees **in use by a running hermes session** are never touched.
- **Untracked-only scratch** (PR body drafts, notes) is archived to
`~/.hermes/archive/worktree-prune/` before its tree is removed — never
destroyed.
- Branch deletion is content-gated, not name-gated: any local branch whose
commits are all on upstream is safe to delete; branches with unique work,
checked-out branches, and `main`/`master`/`develop` are always kept.
The same conservative pruner also runs from the cron scheduler (at most once
every 6 hours, in the background), so gateway-only machines — where nobody
launches `hermes -w` for days — no longer accumulate merged scratch trees
between CLI sessions.
When `.worktrees/` grows past 10 trees or 5 GB, startup prints a one-line
notice pointing at these commands.
### Plugin management
The `hermes plugins` commands manage native Hermes plugins and portable Agent
Plugins v1 packages through the same opt-in workflow:
```bash
hermes plugins install owner/repository --no-enable
hermes plugins list
hermes plugins enable
hermes plugins disable
hermes plugins update
hermes plugins remove
```
Portable packages remain disabled until explicitly enabled. Hermes currently
loads portable Agent Skills and stdio MCP entries. See the
[plugin developer guide](../developer-guide/plugins/index.md#portable-agent-plugins-v1-packages)
for the exact supported subset and trust boundary.
## Interface Layout
The Hermes CLI banner, conversation stream, and fixed input prompt rendered as a stable docs figure instead of fragile text art.
The welcome banner shows your model, terminal backend, working directory, available tools, and installed skills at a glance.
### Status Bar
A persistent status bar sits above the input area, updating in real time:
```
☤ claude-sonnet-4-20250514 │ 12.4K/200K │ [██████░░░░] 6% │ $0.06 │ 15m
```
| Element | Description |
|---------|-------------|
| Model name | Current model (truncated if longer than 26 chars) |
| Token count | Context tokens used / max context window; `~` marks an estimate |
| Context bar | Visual fill indicator with color-coded thresholds |
| Cost | Estimated session cost (or `n/a` for unknown/zero-priced models). Rates come from Hermes' bundled official price table, then the provider's `/models` listing; on a direct first-party API (OpenAI, xAI, Anthropic, Google, DeepSeek, Xiaomi) a model missing from both is priced at the vendor's list price from models.dev. Proxies, relays and custom endpoints serving the same model id stay `n/a` rather than inherit that price. |
| 🗜️ N | **Context compression count** — how many times the running session has been auto-compressed. Appears once the first compression fires. |
| ▶ N | **Active background tasks** — how many `/bg` prompts are still running in the current session. Appears whenever at least one task is in flight. |
| Duration | Elapsed session time |
| Session title | Once the session has a title, it appears as a gold badge pinned to the far-right edge. Long titles truncate before displacing the essential model and context fields. |
| ⚠ YOLO | **YOLO mode warning** — shown whenever `HERMES_YOLO_MODE` is on (either `hermes --yolo` at launch or `/yolo` toggled mid-session). Mirrors the banner-line warning so you can't forget you're in auto-approve mode. |
A `~` before a context count or percentage means it includes a local estimate. This also applies to gateway `/status` and `/context`, the TUI, and the Desktop context gauge. An unchanged provider-usage reading has no `~`; a provider anchor plus unpriced new messages does. `/context` reports the selected source. Category, free-space, skill, and toolset breakdowns are always local estimates, even when the overall occupancy comes from provider usage. These display labels do not change compaction decisions or make extra provider requests.
The bar adapts to terminal width — full layout at ≥ 76 columns, compact at 52–75, minimal (model + duration, plus the YOLO badge when active) below 52.
**Context color coding:**
| Color | Threshold | Meaning |
|-------|-----------|---------|
| Green | < 50% | Plenty of room |
| Yellow | 50–80% | Getting full |
| Orange | 80–95% | Approaching limit |
| Red | ≥ 95% | Near overflow — consider `/compress` |
Use `/usage` for a detailed breakdown including per-category costs (input vs output tokens).
On the `openai-codex` provider, `/usage` also shows any banked usage-limit resets on your ChatGPT account ("You have N resets banked - use /usage reset to activate"). `/usage reset` redeems one banked reset, fully restoring your 5-hour and weekly limits. Hermes refuses to redeem while your limits aren't exhausted (a banked reset restores the full allowance, so spending it early wastes it) — pass `/usage reset --force` to redeem anyway.
### Session Resume Display
When resuming a previous session (`hermes -c` or `hermes --resume `), a "Previous Conversation" panel appears between the banner and the input prompt, showing a compact recap of the conversation history. See [Sessions — Conversation Recap on Resume](sessions.md#conversation-recap-on-resume) for details and configuration.
## Keybindings
On macOS, `F6`/`F7` mean the physical function keys, not the media/system controls shown on the top row. Hold **Fn** (the **globe** key on newer keyboards) while pressing the function key, or enable **Use F1, F2, etc. keys as standard function keys** in **System Settings → Keyboard → Keyboard Shortcuts → Function Keys**. The reliable terminal fallbacks are **Ctrl+T** for `F6` and **Ctrl+R** for `F7`.
| Key | Action |
|-----|--------|
| `Enter` | Send message |
| `Alt+Enter`, `Ctrl+J`, or `Shift+Enter` | New line (multi-line input). `Shift+Enter` requires a terminal that distinguishes it from `Enter` — see below. On Windows Terminal, `Alt+Enter` is captured by the terminal (fullscreen toggle); use `Ctrl+Enter` or `Ctrl+J` instead. |
| `Alt+V` | Paste an image from the clipboard when supported by the terminal |
| `Ctrl+V` | Paste text and opportunistically attach clipboard images |
| `Ctrl+B` | Start/stop voice recording when voice mode is enabled (`voice.record_key`, default: `ctrl+b`) |
| `Ctrl+G` | Open the current input buffer in `$EDITOR` (vim/nvim/nano/VS Code/etc.). Save and quit to send the edited text as the next prompt — ideal for long, multi-paragraph prompts. |
| `Ctrl+X Ctrl+E` | Emacs-style alternate binding for the external editor (same behavior as `Ctrl+G`). |
| `Ctrl+S` | **Stash the prompt.** Parks the current draft and clears the composer so you can send something else first. Press `Ctrl+S` again on an empty composer to bring the draft back (cursor at the end, attached images restored). Repeated presses build a stack rather than overwriting, so an earlier draft is never silently lost — with two or more stashed, `Ctrl+S` opens a browse panel (`↑`/`↓` to navigate, `Enter` to restore, `D` to discard, `Esc` or `Ctrl+S` to close). A `📌 N` badge in the status bar shows how many drafts are parked. Multi-line drafts round-trip exactly, including blank lines. The stash lives in memory for the session only — nothing is written to disk, since drafts often contain secrets. |
| `Ctrl+C` | Interrupt agent (double-press within 2s to force exit) |
| `Ctrl+T` / `F6` | Open the full-screen live work monitor (subagents and background processes) without losing the composer draft. The live dock appears automatically above the status bar; arrows select a worker or process, `Enter` shows its recent log, `s` steers a worker, and `x` requests stop with confirmation. See [Monitoring subagents](./features/delegation.md#monitoring-running-subagents-agents). |
| `Ctrl+R` / `F7` | Toggle the live work dock between its multi-row preview and a single summary line without moving composer focus. `Ctrl+R` is the reliable fallback when macOS reserves the function-key row. Besides subagents and background processes, the dock shows a standing `/goal` (active, parked or paused, with turns used) on its top row and the prompts waiting in `/queue` on its bottom rows. |
| `Ctrl+D` | Exit |
| `Ctrl+Z` | Suspend Hermes to background (Unix only). Run `fg` in the shell to resume. |
| `Tab` | Accept auto-suggestion (ghost text) or autocomplete slash commands |
| `!` | **Shell mode** — run a shell command yourself without spending a model turn (e.g. `!git status`, `!pytest -x`). See below. |
**Multiline paste preview.** When you paste a multi-line block, the CLI echoes a compact single-line preview (`[pasted: 47 lines, 1,842 chars — press Enter to send]`) instead of dumping the whole payload into the scrollback. The full content is still what gets sent; this is just display polish.
### `!` Shell Mode
Start a line with `!` to run it as a shell command instead of sending it to the agent:
```
> !git status
> !ls -la
> !pytest -x tests/hermes_cli
```
- **Zero cost.** The model is never invoked — no API call, no tokens, no latency.
- **Nothing enters the conversation.** The command and its output are not added to history, so your context stays clean and the prompt cache is untouched.
- **Runs on your machine, in the session working directory.** With the default local terminal backend `!pwd` matches what the agent would see. A remote or sandboxed `terminal.backend` (`ssh`, `docker`, …) is **not** used for `!` commands — they always run on the host where Hermes itself runs, so `!hostname` names your machine while the agent's `terminal` tool names the backend. Ask the agent (or open a shell on the target) to run something *inside* the backend. Path completion in the composer, by contrast, does follow the configured backend and lists the target's filesystem.
- **Approvals still apply.** A dangerous command (`rm -rf`, writes to `~/.hermes/config.yaml`, etc.) goes through the same approval prompt the agent's `terminal` tool uses. `!` is a cost/latency shortcut, not a security bypass.
- **Non-zero exits are shown.** A failing command prints `! exited ` after its output.
- `!` on its own prints a one-line usage reminder.
Shell mode is CLI-only. Gateway platforms (Discord, Telegram, Slack) and cron runs ignore it — those users already have their own shells.
**Markdown stripping in final responses.** The CLI strips the most verbose markdown fences and `**bold**` / `*italic*` wrappers from *final* agent replies so they render as readable terminal prose rather than raw source. Code blocks and lists are preserved. This does not affect gateway platforms or tool results — they keep their markdown for native rendering.
## Slash Commands
Type `/` to see the autocomplete dropdown. Hermes supports a large set of CLI slash commands, dynamic skill commands, and user-defined quick commands.
Common examples:
| Command | Description |
|---------|-------------|
| `/help` | Show command help |
| `/model` | Show or change the current model |
| `/tools` | List currently available tools |
| `/skills browse` | Browse the skills hub and official optional skills |
| `/bg ` | Run a prompt in a separate background session |
| `/btw ` | Ask a side question about the current conversation without interrupting it |
| `/skin` | Show or switch the active CLI skin |
| `/voice on` | Enable CLI voice mode (press `Ctrl+B` to record) |
| `/voice tts` | Toggle spoken playback for Hermes replies |
| `/reasoning high` | Increase reasoning effort |
| `/title My Session` | Name the current session |
| `/status` | Show session info — model/profile/tokens/duration — followed by a local **Session recap** block (recent turn counts, top tools used, files touched, latest user prompt + assistant reply). Pure local compute; no LLM call. |
| `/context [all]` | Visual context-usage breakdown — glyph block grid + per-category token table (system prompt / tools / skills / memory / conversation / free space). `/context all` adds per-skill and per-toolset costs. |
| `/sessions` | Open an interactive session picker right inside the classic CLI (same surface the TUI uses). Type to filter, arrow keys to navigate, Enter to resume. |
For the full built-in CLI and messaging lists, see [Slash Commands Reference](../reference/slash-commands.md).
For setup, providers, silence tuning, and messaging/Discord voice usage, see [Voice Mode](features/voice-mode.md).
:::tip
Commands are case-insensitive — `/HELP` works the same as `/help`. Installed skills also become slash commands automatically.
:::
## Quick Commands
You can define custom commands that run shell commands instantly without invoking the LLM. These work in both the CLI and messaging platforms (Telegram, Discord, etc.).
```yaml
# ~/.hermes/config.yaml
quick_commands:
status:
type: exec
command: systemctl status hermes-agent
gpu:
type: exec
command: nvidia-smi --query-gpu=utilization.gpu,memory.used --format=csv,noheader
restart:
type: alias
target: /gateway restart
```
Then type `/status`, `/gpu`, or `/restart` in any chat. See the [Configuration guide](./configuration.md#quick-commands) for more examples.
## Preloading Skills at Launch
If you already know which skills you want active for the session, pass them at launch time:
```bash
hermes -s hermes-agent-dev,github-auth
hermes chat -s github-pr-workflow -s github-auth
```
Hermes loads each named skill into the session prompt before the first turn. The same flag works in interactive mode and single-query mode.
### Persistent auto-load via config
To have the same skills active at the start of **every** new session — CLI, TUI, gateway, cron and API sessions alike — set `skills.auto_load` in `config.yaml`:
```yaml
skills:
auto_load:
- hermes-agent-dev
- github-pr-workflow
```
Each entry is a skill name. The list is resolved once when a session's system prompt is first built and the rendered bytes are reused for the life of the conversation (model switches, compression), so prompt caching stays intact; config edits take effect in the next session. Missing or disabled skills log a warning and are skipped. `-s` names that overlap the list are loaded once.
`--ignore-rules` (equivalently `HERMES_IGNORE_RULES=1`) skips auto-load together with AGENTS.md, SOUL.md, `.cursorrules` and memory injection; explicit `-s` skills still load. The setting is profile-scoped: each profile's `config.yaml` controls its own list.
## Skill Slash Commands
Every installed skill in `~/.hermes/skills/` is automatically registered as a slash command. The skill name becomes the command:
```
/gif-search funny cats
/axolotl help me fine-tune Llama 3 on my dataset
/github-pr-workflow create a PR for the auth refactor
# Just the skill name loads it and lets the agent ask what you need:
/excalidraw
```
## Personalities
Set a predefined personality to change the agent's tone:
```
/personality pirate
/personality kawaii
/personality concise
```
Built-in personalities include: `helpful`, `concise`, `technical`, `creative`, `teacher`, `kawaii`, `catgirl`, `pirate`, `shakespeare`, `surfer`, `noir`, `uwu`, `philosopher`, `hype`.
To go back to the default (no overlay), use `/personality none` — `default` and `neutral` work too.
You can also define custom personalities in `~/.hermes/config.yaml`:
```yaml
personalities:
helpful: "You are a helpful, friendly AI assistant."
kawaii: "You are a kawaii assistant! Use cute expressions..."
pirate: "Arrr! Ye be talkin' to Captain Hermes..."
# Add your own!
```
## Multi-line Input
There are two ways to enter multi-line messages:
1. **`Alt+Enter`, `Ctrl+J`, or `Shift+Enter`** — inserts a new line
2. **Backslash continuation** — end a line with `\` to continue:
```
❯ Write a function that:\
1. Takes a list of numbers\
2. Returns the sum
```
`Ctrl+J` and backslash continuation are enabled by default, matching Claude Code / Codex / OpenCode multiline shortcuts. On supported terminals such as iTerm2, Hermes also requests extended key reporting so `Shift+Enter` arrives as a distinct newline key. If your terminal sends LF for plain `Enter` and you need the legacy `Ctrl+J`-as-submit fallback, opt out:
```yaml
# ~/.hermes/config.yaml
display:
cli_multiline_shortcuts: false
```
:::info
Pasting multi-line text is supported — use any of the newline keys above, or simply paste content directly.
In terminals using the Kitty keyboard protocol, `Alt+Enter` on the numeric keypad also inserts a newline, including next to a collapsed paste. Modified keypad navigation keys follow their non-keypad equivalents.
:::
### Shift+Enter compatibility
Most terminals send the same byte sequence for `Enter` and `Shift+Enter` by default, so applications cannot distinguish them. Hermes recognises `Shift+Enter` only when the terminal sends a distinct sequence via the [Kitty keyboard protocol](https://sw.kovidgoyal.net/kitty/keyboard-protocol/) or xterm's `modifyOtherKeys` mode.
| Terminal | Status |
|---|---|
| Kitty, foot, WezTerm, Ghostty | Distinct `Shift+Enter` enabled by default |
| iTerm2 (recent), Alacritty, VS Code terminal, Warp | Supported once the Kitty protocol is enabled in settings |
| Windows Terminal Preview 1.25+ | Supported once the Kitty protocol is enabled in settings |
| macOS Terminal.app, stock Windows Terminal (stable) | Not supported — `Shift+Enter` is indistinguishable from `Enter` |
Where the terminal cannot distinguish them, `Alt+Enter` and `Ctrl+J` continue to work by default. **On Windows Terminal specifically, `Alt+Enter` is captured by the terminal (toggles fullscreen) and never reaches Hermes — use `Ctrl+Enter` (delivered as `Ctrl+J`) or `Ctrl+J` directly for a newline.**
## Redirecting the Agent Mid-Turn
While the agent is working, you can send a correction without starting a new turn:
- **Type a new message + Enter** — redirects the active turn using your correction
- **`Ctrl+C`** — interrupt the current operation (press twice within 2s to force exit)
- Completed tool work and reasoning already shown stay in context
- A running tool reaches its safe boundary before the correction is applied
### Busy Input Mode
The `display.busy_input_mode` config key controls what happens when you press Enter while the agent is working:
| Mode | Behavior |
|------|----------|
| `"interrupt"` (default) | Your message redirects the active turn. Model generation restarts with displayed reasoning and completed work preserved. A running foreground terminal command is moved to the background (not killed — you get a completion notification) so your message is read immediately; other running tools finish first |
| `"queue"` | Your message is silently queued and sent as the next turn after the agent finishes |
| `"steer"` | Your message is injected into the current run via `/steer`, arriving at the agent after the next tool call — no interrupt, no new turn |
```yaml
# ~/.hermes/config.yaml
display:
busy_input_mode: "steer" # or "queue" or "interrupt" (default)
```
`"queue"` mode prepares a separate follow-up turn. `"steer"` always waits for the next tool-result boundary. The default `"interrupt"` mode responds sooner during model generation while avoiding cancellation of a running tool; a long foreground `terminal` command (a build, a poller) is handed to the background so the agent sees your message right away instead of after the command exits. Use `/stop` when you want to cancel the turn and its foreground work. Unknown values fall back to `"interrupt"`.
`"steer"` has two automatic fallbacks: if the agent hasn't started yet, or if images are attached, the message falls back to `"queue"` behavior so nothing is lost.
Whatever the mode, `/queue ` queues a follow-up turn explicitly, and `/queue list`, `/queue rm N`, `/queue edit N …` and `/queue move A B` act on the pending queue immediately, even mid-run. The live work dock lists what is waiting.
You can also change it inside the CLI:
```text
/busy queue
/busy steer
/busy interrupt
/busy status
```
:::tip First-touch hint
The first time you press Enter while Hermes is working, Hermes prints a one-line reminder explaining the `/busy` knob. It only fires once per install; `onboarding.seen.busy_input_prompt` in `config.yaml` records that it was shown. Delete that key to see the tip again.
:::
### Suspending to Background
On Unix systems, press **`Ctrl+Z`** to suspend Hermes to the background — just like any terminal process. The shell prints a confirmation:
```
Hermes Agent has been suspended. Run `fg` to bring Hermes Agent back.
```
Type `fg` in your shell to resume the session exactly where you left off. This is not supported on Windows.
## Tool Progress Display
The CLI shows animated feedback as the agent works:
**Thinking animation** (during API calls):
```
◜ (。•́︿•̀。) pondering... (1.2s)
◠ (⊙_⊙) contemplating... (2.4s)
✧٩(ˊᗜˋ*)و✧ got it! (3.1s)
```
**Tool execution feed:**
```
┊ 💻 terminal `ls -la` (0.3s)
┊ 🔍 web_search (1.2s)
┊ 📄 web_extract (2.1s)
```
Cycle through display modes with `/verbose`: `off → new → all → verbose`. This command can also be enabled for messaging platforms — see [configuration](./configuration.md#display-settings).
### Tool Preview Length
The `display.tool_preview_length` config key controls the maximum number of characters shown in tool call preview lines (e.g. file paths, terminal commands). The default is `0`, which means no limit — full paths and commands are shown.
```yaml
# ~/.hermes/config.yaml
display:
tool_preview_length: 80 # Truncate tool previews to 80 chars (0 = no limit)
```
This is useful on narrow terminals or when tool arguments contain very long file paths.
## Session Management
### Resuming Sessions
When you exit a CLI session, a resume command is printed:
```
Resume this session with:
hermes --resume 20260225_143052_a1b2c3
Session: 20260225_143052_a1b2c3
Duration: 12m 34s
Messages: 28 (5 user, 18 tool calls)
```
Resume options:
```bash
hermes --continue # Resume the most recent CLI session
hermes -c # Short form
hermes -c "my project" # Resume a named session (latest in lineage)
hermes --resume 20260225_143052_a1b2c3 # Resume a specific session by ID
hermes --resume "refactoring auth" # Resume by title
hermes --resume latest # Resume the most recent session (same as -c)
hermes --resume latest --in ./my-project # Latest session for ./my-project's workspace
hermes -r 20260225_143052_a1b2c3 # Short form
```
Resuming restores the full conversation history from SQLite. The agent sees all previous messages, tool calls, and responses — just as if you never left.
Use `/title My Session Name` inside a chat to name the current session, or `hermes sessions rename ` from the command line. Use `hermes sessions list` to browse past sessions.
### Session Storage
CLI sessions are stored in Hermes's SQLite state database under `~/.hermes/state.db`. The database keeps:
- session metadata (ID, title, timestamps, token counters)
- message history
- lineage across compressed/resumed sessions
- full-text search indexes used by `session_search`
Some messaging adapters also keep per-platform transcript files alongside the database, but the CLI itself resumes from the SQLite session store.
### Context Compression
Long conversations are automatically summarized when approaching context limits:
```yaml
# In ~/.hermes/config.yaml
compression:
enabled: true
threshold: 0.50 # Compress at 50% of context limit by default
# Summarization model configured under auxiliary:
auxiliary:
compression:
model: "" # Leave empty to use the main chat model (default). Or pin a cheap fast model, e.g. "google/gemini-3-flash-preview".
```
When compression triggers, middle turns are summarized while the first 3 and last 20 turns are always preserved.
## Background Sessions
Run a prompt in a separate background session while continuing to use the CLI for other work:
```
/bg Analyze the logs in /var/log and summarize any errors from today
```
Hermes immediately confirms the task and gives you back the prompt:
```
🔄 Background task #1 started: "Analyze the logs in /var/log and summarize..."
Task ID: bg_143022_a1b2c3
```
### How It Works
Each `/bg` prompt spawns a **completely separate agent session** in a daemon thread:
- **Isolated conversation** — the background agent has no knowledge of your current session's history. It receives only the prompt you provide.
- **Same configuration** — the background agent inherits your model, provider, toolsets, reasoning settings, and fallback model from the current session.
- **Non-blocking** — your foreground session stays fully interactive. You can chat, run commands, or even start more background tasks.
- **Multiple tasks** — you can run several background tasks simultaneously. Each gets a numbered ID.
### Results
When a background task finishes, the result appears as a panel in your terminal:
```
╭─ ☤ Hermes (background #1) ──────────────────────────────────╮
│ Found 3 errors in syslog from today: │
│ 1. OOM killer invoked at 03:22 — killed process nginx │
│ 2. Disk I/O error on /dev/sda1 at 07:15 │
│ 3. Failed SSH login attempts from 192.168.1.50 at 14:30 │
╰──────────────────────────────────────────────────────────────╯
```
If the task fails, you'll see an error notification instead. If `display.bell_on_complete` is enabled in your config, the terminal bell rings when the task finishes.
### Use Cases
- **Long-running research** — "/bg research the latest developments in quantum error correction" while you work on code
- **File processing** — "/bg analyze all Python files in this repo and list any security issues" while you continue a conversation
- **Parallel investigations** — start multiple background tasks to explore different angles simultaneously
:::info
Background sessions do not appear in your main conversation history. They are standalone sessions with their own task ID (e.g., `bg_143022_a1b2c3`).
:::
## Quiet Mode
By default, the CLI runs in quiet mode which:
- Suppresses verbose logging from tools
- Enables kawaii-style animated feedback
- Keeps output clean and user-friendly
For debug output:
```bash
hermes chat --verbose
```
---
# TUI
# TUI
The TUI is the modern front-end for Hermes — a terminal UI backed by the same Python runtime as the [Classic CLI](cli.md). Same agent, same sessions, same slash commands; a cleaner, more responsive surface for interacting with them.
It's the recommended way to run Hermes interactively.
## Launch
```bash
# Launch the TUI
hermes --tui
# Resume the latest TUI session (falls back to the latest classic session)
hermes --tui -c
hermes --tui --continue
hermes --tui --resume latest
# Resume a specific session by ID or title
hermes --tui -r 20260409_000000_aa11bb
hermes --tui --resume "my t0p session"
# Resume the latest session for a specific project directory
hermes --tui --resume latest --in ./my-project
# Run source directly — skips the prebuild step (for TUI contributors)
hermes --tui --dev
```
You can also enable it via env var:
```bash
export HERMES_TUI=1
hermes # now uses the TUI
hermes chat # same
```
Or make it the persistent default in `~/.hermes/config.yaml`:
```yaml
display:
interface: tui # "cli" (default) or "tui"
```
With `display.interface: tui`, a bare `hermes` (and `hermes chat`) launches the TUI. Explicit flags always win — run `hermes --cli` to drop back to the classic REPL for a single invocation, or `hermes --tui` / `HERMES_TUI=1` to force the TUI when the config default is `cli`.
The classic CLI remains the shipped default. Anything documented in [CLI Interface](cli.md) — slash commands, quick commands, skill preloading, personalities, multi-line input, interrupts — works in the TUI identically.
## Why the TUI
- **Instant first frame** — the banner paints before the app finishes loading, so the terminal never feels frozen while Hermes is starting.
- **Non-blocking input** — type and queue messages before the session is ready. Your first prompt sends the moment the agent comes online.
- **Rich overlays** — model picker, session picker, approval and clarification prompts all render as modal panels rather than inline flows.
- **Live session panel** — tools and skills fill in progressively as they initialize.
- **Mouse-friendly selection** — drag to highlight with a uniform background instead of SGR inverse. Copy with your terminal's normal copy gesture.
- **Alternate-screen rendering** — differential updates mean no flicker when streaming, no scrollback clutter after you quit.
- **Composer affordances** — inline paste-collapse for long snippets, `Cmd+V` / `Ctrl+V` text paste with clipboard-image fallback, bracketed-paste safety, and image/file-path attachment normalization.
Same [skins](features/skins.md) and [personalities](features/personality.md) apply. Switch mid-session with `/skin ares`, `/personality pirate`, and the UI repaints live. See [Skins & Themes](features/skins.md) for the full list of customizable keys and which ones apply to classic vs TUI — the TUI honors the banner palette, UI colors, prompt glyph/color, session display, completion menu, selection bg, `tool_prefix`, and `help_header`.
### Collapsible banner sections
The TUI startup banner groups runtime info into four collapsible sections, each rendered with a `▸` / `▾` chevron next to the section title:
| Section | Default state |
|---------|---------------|
| Tools | Open |
| Skills | Collapsed |
| System Prompt | Collapsed |
| MCP Servers | Collapsed |
Click anywhere on a section header (or its chevron) to toggle it. The Tools list opens by default because it's the most-checked section at session start; Skills, System Prompt, and MCP Servers collapse by default so the banner stays compact even when you've installed dozens of skills or wired up many MCP servers. State is local to the banner instance, so the next launch resets to the defaults.
## Requirements
- **Node.js** ≥ 20 — the TUI runs as a subprocess launched from the Python CLI. `hermes doctor` verifies this.
- **TTY** — like the classic CLI, piping stdin or running in non-interactive environments falls back to single-query mode.
On first launch Hermes installs the TUI's Node dependencies into `ui-tui/node_modules` (one-time, a few seconds). Subsequent launches are fast. If you pull a new Hermes version, the TUI bundle is rebuilt automatically when sources are newer than the dist.
:::tip Working across git worktrees?
Contributors who run `hermes --tui --dev` from many worktrees can share one `node_modules` instead of installing per checkout — see [TUI & Desktop from Worktrees](../developer-guide/worktree-ui-dev.md).
:::
### External prebuild
Distributions that ship a prebuilt bundle (Nix, system packages) can point Hermes at it:
```bash
export HERMES_TUI_DIR=/path/to/prebuilt/ui-tui
hermes --tui
```
The directory must contain `dist/entry.js`.
## Keybindings
Keybindings match the [Classic CLI](cli.md#keybindings) exactly. The only behavioral differences:
On macOS, `F7` means the physical F7 function key. MacBook keyboards may use that row for system controls instead; hold **Fn** (the **globe** key on newer keyboards) while pressing **F7**, or enable **Use F1, F2, etc. keys as standard function keys** in **System Settings → Keyboard → Keyboard Shortcuts → Function Keys**. If the terminal still does not forward it, use the equivalent **Ctrl+R** binding below.
- **`Ctrl+T`** expands the automatic live-work dock (subagents, plus a **Processes** block for `terminal(background=true)` spawns) into the full-height `/agents` roster. Select a worker and press **Enter** (or **`t`**) for its live transcript, **`d`** for rich details, **`e`** to steer, or **`x`** to stop it. The dock fits its row count to terminal height and preserves your composer draft. See [Monitoring subagents](./features/delegation.md#monitoring-running-subagents-agents).
- A standing **`/goal`** gets its own row above the live dock (`⊙ goal · 3/20 turns · …`, or `⏳ goal parked` / `⏸ goal paused` with the reason); it leaves once the goal is done or cleared. Queued follow-ups are listed above it.
- **`Ctrl+R`** toggles the live dock between its default preview and one summary line. **`F7`** remains an optional alias where the terminal sends function keys through. This does not open the monitor or move composer focus; the choice lasts for this TUI process without changing config.
- **Mouse drag** highlights text with a uniform selection background.
- **`Cmd+V` / `Ctrl+V`** first tries normal text paste, then falls back to OSC52/native clipboard reads, and finally image attach when the clipboard or pasted payload resolves to an image.
- **`/terminal-setup`** installs local VS Code / Cursor / Windsurf terminal bindings for better `Cmd+Enter` and undo/redo parity on macOS.
- **Slash autocompletion** opens as a floating panel with descriptions, not an inline dropdown.
- **`Ctrl+X`** opens the live session switcher. When a queued message is highlighted (sent while the agent was still running), it still deletes that queued message instead. **`Esc`** cancels editing and unhighlights without deleting.
- **`Ctrl+G` / `Ctrl+X Ctrl+E`** — open the current input buffer in `$EDITOR` for multi-line / long-prompt composition; save-and-exit sends the contents back as the prompt.
## Slash commands
All slash commands work unchanged. A few are TUI-owned — they produce richer output or render as overlays rather than inline panels:
| Command | TUI behavior |
|---------|--------------|
| `/help` | Overlay with categorized commands, arrow-key navigable |
| `/sessions` (alias `/switch`) | Live session switcher — list open TUI sessions, switch between them, close them, or start another one |
| `/model` | Modal model picker grouped by provider, with cost hints |
| `/skin` | Live preview — theme change applies as you browse |
| `/details` | Toggle verbose tool-call details (global or per-section) |
| `/usage` | Rich token / cost / context panel |
| `/agents` (alias `/tasks`) | Observability overlay — live subagent tree with kill/pause controls, per-branch cost / token / file rollups, turn-by-turn history |
| `/reload` | Re-reads `~/.hermes/.env` into the running TUI process so newly added API keys take effect without a restart |
| `/mouse [on\|off\|toggle\|wheel\|buttons\|all]` | Pick a mouse tracking preset at runtime (also persists to `display.mouse_tracking` in `config.yaml`). `wheel` (1000+1006) keeps scroll-wheel scrolling without the hover events that make tmux spam "No image in clipboard" over the prompt row; `buttons` adds drag-to-select; `all` is the default with hover-driven UI. |
Every other slash command (including installed skills, quick commands, and personality toggles) works identically to the classic CLI. See [Slash Commands Reference](../reference/slash-commands.md).
## Live session switcher
Use the live session switcher when you want one terminal to act as a dispatcher for several TUI sessions. It lists only sessions that are currently live in this TUI process; closed sessions remain saved transcripts and can still be reopened with `/resume` or `hermes --tui --resume `.
Open it with any of these:
- `Ctrl+X` from the TUI.
- `/sessions` or `/switch`.
- `/sessions new` to create a fresh live session immediately.
- Click the `N live sessions` count in the status line.
Inside the switcher:
- `↑` / `↓` move the selection; mouse clicks select rows too.
- `Enter` switches to the selected live session.
- `Ctrl+D` closes the selected live session.
- `Ctrl+N` starts a blank live session.
- `Ctrl+R` refreshes the live-session list.
- `Esc` closes the switcher.
- Select `+new`, type a prompt, and press `Enter` to dispatch a new live session. Press `Tab` first if you want to choose a model just for that new session.
## LaTeX math rendering
The TUI's markdown pipeline renders LaTeX math inline: `$E = mc^2$` and `$$\frac{a}{b}$$` render as Unicode-formatted math instead of the raw TeX source. Works for inline and block math; unsupported syntax falls back to showing the literal TeX wrapped in a code span so it remains copyable.
This is always-on — nothing to configure. Classic CLI keeps the raw TeX.
## Light-terminal detection
The TUI auto-detects light terminals and swaps to the light theme accordingly. Detection works in three layers:
1. `HERMES_TUI_THEME` env var — highest priority. Values: `light`, `dark`, or a raw 6-char background hex (e.g. `ffffff`, `1a1a2e`).
2. `COLORFGBG` env var — the classic "what's my background color?" hint used by xterm-derived terminals.
3. Terminal background probe via OSC 11 — works on modern terminals (Ghostty, Warp, iTerm2, WezTerm, Kitty) that don't set `COLORFGBG`.
If you want the light theme permanently regardless of terminal:
```bash
export HERMES_TUI_THEME=light
```
## Busy indicator styles
The status-bar busy indicator is pluggable — the default rotates Hermes' kawaii face palette every 2.5 seconds during agent work. Pick a different style via config or the `/indicator` slash command:
```yaml
display:
tui_status_indicator: kaomoji # kaomoji | emoji | unicode | ascii
```
Or in-session: `/indicator emoji` (etc.). Styles ship with matched glyph widths so the rest of the status bar doesn't jitter on rotation.
## Auto-resume
By default, `hermes --tui` starts a fresh session each launch. To re-attach to the most recent TUI session automatically (useful when your terminal or SSH connection drops unexpectedly), opt in:
```bash
export HERMES_TUI_RESUME=1 # most-recent TUI session
# or:
export HERMES_TUI_RESUME= # specific session
```
Unset the variable or pass `--resume ` explicitly to override on a per-launch basis.
## Status line
The TUI's status line tracks agent state in real time:
After a session is named, its title appears as an accent-colored badge at the far-right edge of the status line. The title takes the workspace label's place and truncates on narrow terminals.
| Status | Meaning |
|--------|---------|
| `starting agent…` | Session ID is live; tools and skills still coming online. You can type — messages queue and send when ready. |
| `ready` | Agent is idle, accepting input. |
| `thinking…` / `running…` | Agent is reasoning or running a tool. |
| `interrupted` | Current turn was cancelled; press Enter to send again. |
| `forging session…` / `resuming…` | Initial connect or `--resume` handshake. |
The per-skin status-bar colors and thresholds are shared with the classic CLI — see [Skins](features/skins.md) for customization.
The status line also shows:
- **Working directory with git branch** — `~/projects/hermes-agent (docs/two-week-gap-sweep)`. The branch suffix updates when you `git checkout` in a side terminal (mtime-cached) so the TUI reflects your actual active branch, not whatever it was at launch.
- **Per-prompt elapsed time** — `⏱ 12s/3m 45s` while the turn is running (live), frozen to `⏲ 32s / 3m 45s` after the turn completes. First number is time since last user message; second is total session duration. Resets on every new prompt.
- **`🗜️ N`** — number of times the running session has been auto-compressed. Appears once the first compression fires.
- **`▶ N`** — number of `/bg` tasks currently running in this session. Appears whenever at least one task is in flight.
- **`⚠ YOLO`** — visible warning whenever YOLO mode is on (`hermes --yolo`, `/yolo`, or `HERMES_YOLO_MODE=1`). The same badge also appears in the startup banner so you cannot launch an auto-approving session without noticing.
## Configuration
The TUI respects all standard Hermes config: `~/.hermes/config.yaml`, profiles, personalities, skins, quick commands, credential pools, memory providers, tool/skill enablement. No TUI-specific config file exists.
A handful of keys tune the TUI surface specifically:
```yaml
display:
skin: default # any built-in or custom skin
personality: helpful
details_mode: collapsed # hidden | collapsed | expanded — global accordion default
sections: # optional: per-section overrides (any subset)
thinking: expanded # always open
tools: expanded # always open
activity: collapsed # opt back IN to the activity panel (hidden by default)
mouse_tracking: all # off | wheel | buttons | all (or true/false for back-compat).
# wheel — 1000+1006 (scroll + click; no drag, no hover —
# recommended inside tmux to silence the prompt-row
# "No image in clipboard" spam from hover events)
# buttons — adds 1002 for terminal-side drag selection
# all — adds 1003 for hover (scrollbar paginate-on-hover,
# link mouseenter, etc.)
```
Runtime toggles:
- `/details [hidden|collapsed|expanded|cycle]` — set the global mode
- `/details [hidden|collapsed|expanded|reset]` — override one section
(sections: `thinking`, `tools`, `subagents`, `activity`)
**Default visibility**
The TUI ships with opinionated per-section defaults that stream the turn as
a live transcript instead of a wall of chevrons:
- `thinking` — **expanded**. Reasoning streams inline as the model emits it.
- `tools` — **expanded**. Tool calls and their results render open.
- `subagents` — falls through to the global `details_mode` (collapsed under
chevron by default — stays quiet until a delegation actually happens).
- `activity` — **hidden**. Ambient meta (gateway hints, terminal-parity
nudges, background notifications) is noise for most day-to-day use. Tool
failures still render inline on the failing tool row; ambient
errors/warnings surface via a floating-alert backstop when every panel
is hidden.
Per-section overrides take precedence over both the section default and the
global `details_mode`. To reshape the layout:
- `display.sections.thinking: collapsed` — put thinking back under a chevron
- `display.sections.tools: collapsed` — put tool calls back under a chevron
- `display.sections.activity: collapsed` — opt the activity panel back in
- `/details ` at runtime
Anything set explicitly in `display.sections` wins over the defaults, so
existing configs keep working unchanged.
## Sessions
Sessions are shared between the TUI and the classic CLI — both write to the same `~/.hermes/state.db`. You can start a session in one, resume in the other. The session picker surfaces sessions from both sources, with a source tag.
See [Sessions](sessions.md) for lifecycle, search, compression, and export.
## How the TUI talks to its gateway
By default the TUI spawns its own in-process gateway, so each TUI instance is self-contained — there's nothing to configure.
You may see a `HERMES_TUI_GATEWAY_URL` env var referenced in the codebase or logs. This is an **internal wiring detail of the web dashboard**, not a user-facing remote-attach knob. When you open the dashboard's "Chat" tab (`hermes dashboard` → `/chat`), the dashboard's web server spawns an embedded TUI child process and injects `HERMES_TUI_GATEWAY_URL` so that child attaches to the dashboard's own in-process `tui_gateway` over a loopback WebSocket (`/api/ws`). The `/api/ws` endpoint exists only inside the dashboard server (`hermes_cli/web_server.py`) and is bound to that process's lifetime and auth.
There is no general "point any TUI at any standalone gateway port" mode. In particular, the OpenAI-compatible API server (`hermes gateway` / the `api_server` platform) does **not** serve `/api/ws` — it's the model-backend surface (`/v1/chat/completions`, `/v1/models`, …) and deliberately does not expose the TUI's JSON-RPC control channel. Setting `HERMES_TUI_GATEWAY_URL` to that port will 404.
### If the connection drops
- **Spawned gateway (default):** when the gateway process dies mid-session the TUI says *Hermes stopped unexpectedly — restarting and reopening your chat*, respawns it (bounded to a few attempts per minute) and reopens the same saved session. The reply that was in flight is lost with the process.
- **Attached gateway (dashboard chat):** when only the WebSocket drops the TUI says *Connection to Hermes lost — reconnecting and reopening your chat…*, reconnects with growing backoff and reattaches to the same session — including a reply that is still streaming on the backend. Nothing is resubmitted.
If you want multiple surfaces to share one set of sessions, use the shared `~/.hermes/state.db` (see [Sessions](sessions.md)) or the web dashboard's embedded chat (see [Web Dashboard](features/web-dashboard.md#chat)) — not a hand-set gateway URL.
## Reverting to the classic CLI
Launching `hermes` (without `--tui`) stays on the classic CLI by default. To make a machine prefer the TUI, set `display.interface: tui` in `~/.hermes/config.yaml` (persistent) or `HERMES_TUI=1` in your shell profile (per-shell). To go back, set `interface: cli` / unset the env var, or pass `hermes --cli` for a one-off.
If the TUI fails to launch (no Node, missing bundle, TTY issue), Hermes prints a diagnostic and falls back — rather than leaving you stuck.
## See also
- [CLI Interface](cli.md) — full slash command and keybinding reference (shared)
- [Sessions](sessions.md) — resume, branch, and history
- [Skins & Themes](features/skins.md) — theme the banner, status bar, and overlays
- [Voice Mode](features/voice-mode.md) — works in both interfaces
- [Configuration](configuration.md) — all config keys
---
# Hermes Agent Configuration
# 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 (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 — 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://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 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
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_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
Every Hermes-managed container is tagged with three labels so subsequent processes (and the orphan reaper) can identify it:
- `hermes-agent=1` — marks it as Hermes-managed
- `hermes-task-id=` — keys the per-task reuse probe
- `hermes-profile=` — scopes reuse and reaping to the active Hermes profile by default; when `docker_shared_container_key` is set, its sanitized value is used instead
On startup, Hermes runs `docker ps --filter label=hermes-task-id= --filter label=hermes-profile=` and **attaches to the existing container** when it finds one. The identity is the active profile unless `docker_shared_container_key` explicitly opts trusted profiles into a common value. If the container is `exited` (e.g. after a Docker daemon restart), it's `docker start`'d and reused — filesystem state and any installed packages survive, but in-container background processes do not.
When a Hermes process exits — `/quit`, closing a TUI session, gateway shutdown, even SIGKILL — the cleanup path is a **no-op for the container in default mode**. The container keeps running. The next Hermes process attaches to it in milliseconds via the label probe. This is the behavior the "one long-lived container shared across sessions" contract requires: it's the only way background processes (npm watchers, dev servers, long-running pytest) survive across sessions.
**The container is only torn down (stopped and `docker rm -f`'d) in these cases:**
| Trigger | When it fires |
|---|---|
| `docker_persist_across_processes: false` | Explicit per-process isolation. Every `cleanup()` does `stop` + `rm -f`. Matches pre-issue-#20561 behavior. |
| Idle reaper (`lifetime_seconds`, default 300s) | Only when the env is `persist_across_processes=false`. Persist-mode envs are no-op'd; container survives the idle sweep. |
| Orphan reaper at next startup | Sweeps **Exited** hermes-labeled containers older than `2 × lifetime_seconds` (default 600s = 10 min), scoped to the current profile. **Running containers are never touched** — sibling-process safety. Set `docker_orphan_reaper: false` to disable. |
| Direct user action | `docker rm -f`, `docker system prune`, Docker Desktop restart. We don't set `--restart=always`, so a host reboot leaves the container `Exited` (its CoW layer survives and gets reused on next startup, but bg processes are gone). |
Edge cases worth knowing:
- **OOM kill of in-container PID 1** transitions the container to `Exited`. Next reuse will `docker start` it; filesystem state survives, bg processes do not.
- **Switching profiles** isolates containers from each other — a container labeled `hermes-profile=work` is invisible to a Hermes process running under `hermes-profile=research`. The orphan reaper is profile-scoped too, so cross-profile containers don't get reaped accidentally, but they also won't get cleaned up automatically until you start Hermes again under their original profile.
- **Explicit cross-profile sharing** — set the same non-empty `docker_shared_container_key` under `terminal:` for profiles that intentionally collaborate in one trusted workspace. This replaces only their container identity label; task, egress, and network compatibility checks still apply. Profiles without the key remain isolated. The identity label is derived from the key with a short digest suffix, so similar-looking keys (`team/workspace` vs `team_workspace`) never collide into one container. **Important: a shared container is created once, by whichever profile starts it first** — that profile's `docker_image`, volumes, shm size, and other immutable Docker settings win, and later profiles attach to it as-is; differing settings in their configs are ignored until the container is removed and recreated. Profiles sharing a key should agree on image and mounts.
Parallel subagents spawned via `delegate_task(tasks=[...])` share this one container — concurrent `cd`, env mutations, and writes to the same path will collide. If a subagent needs an isolated sandbox, it must register a per-task image override via `register_task_env_overrides()`, which RL and benchmark environments (TerminalBench2, HermesSweEnv, etc.) do automatically for their per-task Docker images.
**Security hardening:**
- `--cap-drop ALL` with only `DAC_OVERRIDE`, `CHOWN`, `FOWNER` added back
- `--security-opt no-new-privileges`
- `--pids-limit 256`
- Size-limited tmpfs for `/tmp` (512MB), `/var/tmp` (256MB), `/run` (64MB)
**Credential forwarding:** Env vars listed in `docker_forward_env` are resolved from your shell environment first, then `~/.hermes/.env`. Skills can also declare `required_environment_variables` which are merged automatically.
#### Environment variable overrides
Every key under `terminal:` has an env-var override of the form `TERMINAL_`. The most useful ones for the Docker backend:
| Env var | Maps to | Notes |
|---|---|---|
| `TERMINAL_DOCKER_IMAGE` | `docker_image` | Base image |
| `TERMINAL_DOCKER_FORWARD_ENV` | `docker_forward_env` | JSON array: `'["GITHUB_TOKEN","OPENAI_API_KEY"]'` |
| `TERMINAL_DOCKER_ENV` | `docker_env` | JSON dict: `'{"DEBUG":"1"}'` |
| `TERMINAL_DOCKER_VOLUMES` | `docker_volumes` | JSON array of `"host:container[:ro]"` strings |
| `TERMINAL_DOCKER_EXTRA_ARGS` | `docker_extra_args` | JSON array |
| `TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE` | `docker_mount_cwd_to_workspace` | `true` / `false` |
| `TERMINAL_DOCKER_RUN_AS_HOST_USER` | `docker_run_as_host_user` | `true` / `false` |
| `TERMINAL_DOCKER_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`. This preserves filesystem state, not live processes, PID space, or background jobs.
**Credential files:** Automatically mounted from `~/.hermes/` (OAuth tokens, etc.) and synced before each command.
### Daytona Backend
Runs commands in a [Daytona](https://daytona.io) managed workspace. Supports stop/resume for persistence.
```yaml
terminal:
backend: daytona
container_cpu: 1 # CPU cores
container_memory: 5120 # MB → converted to GiB
container_disk: 10240 # MB → converted to GiB (max 10 GiB)
container_persistent: true # Stop/resume instead of delete
```
**Required:** `DAYTONA_API_KEY` environment variable.
**Persistence:** When enabled, sandboxes are stopped (not deleted) on cleanup and resumed on next session. Sandbox names follow the pattern `hermes-{task_id}`.
**Disk limit:** Daytona enforces a 10 GiB maximum. Requests above this are capped with a warning.
### Vercel Sandbox Backend
Runs commands in a [Vercel Sandbox](https://vercel.com/docs/vercel-sandbox) cloud microVM. Hermes uses the normal terminal and file tool surfaces; there are no Vercel-specific model-facing tools.
```yaml
terminal:
backend: vercel_sandbox
vercel_runtime: node24 # node24 | node22 | python3.13
cwd: /vercel/sandbox # default workspace root
container_persistent: true # Snapshot/restore filesystem
container_disk: 51200 # Shared default only; custom disk is unsupported
```
**Required install:** Install the optional SDK extra:
```bash
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.
**Runtime:** `terminal.vercel_runtime` supports `node24`, `node22`, and `python3.13`. If unset, Hermes defaults to `node24`.
**Persistence:** When `container_persistent: true`, Hermes snapshots the sandbox filesystem during cleanup and restores a later sandbox for the same task from that snapshot. Snapshot contents can include Hermes-synced credentials, skills, and cache files that were copied into the sandbox. This preserves filesystem state only; it does not preserve live sandbox identity, PID space, shell state, or running background processes.
**Background commands:** `terminal(background=true)` uses Hermes' generic non-local background process flow. You can spawn, poll, wait, view logs, and kill processes through the normal process tool while the sandbox is alive. Hermes does not provide native Vercel detached-process recovery after cleanup or restart.
**Disk sizing:** Vercel Sandbox does not currently support Hermes' `container_disk` resource knob. Leave `container_disk` unset or at the shared default `51200`; non-default values fail diagnostics and backend creation instead of being silently ignored.
### Singularity/Apptainer Backend
Runs commands in a [Singularity/Apptainer](https://apptainer.org) container. Designed for HPC clusters and shared machines where Docker isn't available.
```yaml
terminal:
backend: singularity
singularity_image: "docker://nikolaik/python-nodejs:python3.11-nodejs20"
container_cpu: 1 # CPU cores
container_memory: 5120 # MB
container_persistent: true # Writable overlay persists across sessions
```
**Requirements:** `apptainer` or `singularity` binary in `$PATH`.
**Image handling:** Docker URLs (`docker://...`) are automatically converted to SIF files and cached. Existing `.sif` files are used directly.
**Scratch directory:** Resolved in order: `TERMINAL_SCRATCH_DIR` → `TERMINAL_SANDBOX_DIR/singularity` → `/scratch/$USER/hermes-agent` (HPC convention) → `~/.hermes/sandboxes/singularity`.
**Isolation:** Uses `--containall --no-home` for full namespace isolation without mounting the host home directory.
### Common Terminal Backend Issues
If terminal commands fail immediately or the terminal tool is reported as disabled:
- **Local** — No special requirements. The safest default when getting started.
- **Docker** — Run `docker version` to verify Docker is working. If it fails, fix Docker or `hermes config set terminal.backend local`.
- **SSH** — Both `TERMINAL_SSH_HOST` and `TERMINAL_SSH_USER` must be set. Hermes logs a clear error if either is missing.
- **Modal** — Needs `MODAL_TOKEN_ID` env var or `~/.modal.toml`. Run `hermes doctor` to check.
- **Daytona** — Needs `DAYTONA_API_KEY`. The Daytona SDK handles server URL configuration.
- **Singularity** — Needs `apptainer` or `singularity` in `$PATH`. Common on HPC clusters.
When in doubt, set `terminal.backend` back to `local` and verify that commands run there first.
### Remote-to-Host State Sync on Teardown
For the **SSH**, **Modal**, and **Daytona** backends, Hermes pushes your `~/.hermes/` state (credential files, skills, cache) into the remote sandbox during the session, and on teardown **syncs changed state files back** to their original host locations. Files that differ from what was originally pushed (compared by content hash) are applied back in place; new remote files under a synced directory (e.g. a skill the agent created remotely) are mapped back to the corresponding host path. Upload-only credential files are never overwritten on the host.
- The sync-back retries up to 3 times with backoff and refuses to extract remote archives larger than 2 GiB; 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: 256000 # Absolute token cap — 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 **absolute token cap** for the compression trigger. Compression fires at the lower of the ratio-based `threshold` and this absolute count, so large-window models cannot silently defer compaction to hundreds of thousands of tokens. The default is `256000`: it bounds a 1M model's default 50% trigger at 256K, while any lower proportional trigger still wins (including the 272K Codex window). The cap survives model switches and fallback activations and is clamped to the model's context length. Set it to `null` to restore ratio-only behavior, or choose a different positive count for your workload.
`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. That protection is not absolute: every compaction also runs a *pressure* pass that demotes tool results and truncates tool-call arguments **inside** the protected tail when the tail alone exceeds 1.5× its token budget (it is not gated on `proactive_prune_tokens`). Both passes rewrite only the history copy the model re-reads — a tool call is executed from the provider's live response, never from history, so an already-dispatched call's arguments are never altered by either pass. Full outputs stay recoverable from the session store. `proactive_prune_min_result_chars` (default `8000`, clamped to ≥ 200) sets the size below which a tool result is left untouched. `proactive_prune_min_reclaim_tokens` (default `4096`) prevents a prune from committing unless it reclaims at least that many tokens — a committed prune rewrites already-sent history and invalidates the provider's prompt-cache prefix, so this gate keeps those cache breaks episodic and amortized (one meaningful break, like a compression boundary) instead of firing on every tool iteration. This runs only under the built-in `compressor` engine; other context engines inherit a no-op.
:::tip Gateway hot-reload of compression and context length
As of recent releases, editing `model.context_length` or any `compression.*` key in `config.yaml` on a running gateway takes effect on the next message — no gateway restart, no `/reset`, no session rotation required. The cached-agent signature includes these keys, so the gateway transparently rebuilds the agent when it sees a change. API keys and tool/skill config still require the usual reload paths.
:::
### Common setups
**Default (auto-detect) — no configuration needed:**
```yaml
compression:
enabled: true
threshold: 0.50
```
Uses your main provider and main model. Override per-task (e.g. `auxiliary.compression.provider: openrouter` + `model: google/gemini-2.5-flash`) if you want compression on a cheaper model than your main chat model.
**Force a specific provider** (OAuth or API-key based):
```yaml
auxiliary:
compression:
provider: nous
model: gemini-3-flash
```
Works with any provider: `nous`, `openrouter`, `codex`, `anthropic`, `main`, etc.
**Custom endpoint** (self-hosted, Ollama, zai, DeepSeek, etc.):
```yaml
auxiliary:
compression:
model: glm-4.7
base_url: https://api.z.ai/api/coding/paas/v4
```
Points at a custom OpenAI-compatible endpoint. Uses `OPENAI_API_KEY` for auth.
### How the three knobs interact
| `auxiliary.compression.provider` | `auxiliary.compression.base_url` | Result |
|---------------------|---------------------|--------|
| `auto` (default) | not set | Auto-detect best available provider |
| `nous` / `openrouter` / etc. | not set | Force that provider, use its auth |
| any | set | Use the custom endpoint directly (provider ignored) |
### Stream progress timeout (Responses routes)
When the summary runs over a Responses stream (the `openai-codex` provider, or any route the auxiliary client drives through the Responses API), 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 with `Codex auxiliary Responses stream stalled: no new output for Ns`. Default **60s** when unset. 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.
### 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
### The universal config pattern
Every model slot in Hermes — auxiliary tasks, compression, fallback — uses the same three knobs:
| Key | What it does | Default |
|-----|-------------|---------|
| `provider` | Which provider to use for auth and routing | `"auto"` |
| `model` | Which model to request | provider's default |
| `base_url` | Custom OpenAI-compatible endpoint (overrides provider) | not set |
Auxiliary task blocks additionally accept a `reasoning_effort` knob:
| Key | What it does | Default |
|-----|-------------|---------|
| `reasoning_effort` | Thinking level for that task's LLM calls: `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`, `ultra` | not set (provider default) |
This is the per-task counterpart of the global `agent.reasoning_effort`: run compression at `low` or vision at `none` to cut side-task latency and cost when your main model is an expensive reasoning model, without touching your main chat behavior. It applies to auxiliary-client tasks such as `vision`, `compression`, `title_generation`, and `curator`, across all three auxiliary wire formats (chat completions, Codex Responses, Anthropic Messages). An explicit `extra_body.reasoning` on the same task wins over the shorthand. A caller that turns thinking off for its own call (title generation does — a 64-token title has no room for reasoning) wins over both: the task-level effort is dropped for that request instead of being sent beside the provider's thinking-off field.
If the endpoint rejects the reasoning field outright (a chat-only model behind an OpenAI-compatible relay answering `400 Unrecognized request argument supplied: reasoning_effort`, or the reversed wording `400 reasoning_effort 'none' unsupported; use minimal|low|medium|high|xhigh`), the auxiliary call is retried once with every reasoning field omitted, so the task (for example the session title) still completes with the endpoint's default behaviour. The main conversation applies the same recovery: when a route rejects the reasoning-off request Hermes sends for a thinking-only truncated continuation, the disable is dropped for the rest of the session and the request is retried with the route's default.
Some models cannot turn thinking off at all (`400 Reasoning is mandatory for this endpoint and cannot be disabled`). For those, a thinking-off auxiliary call (title generation, or any task set to `reasoning_effort: none`) goes out at the lowest effort (`low`) instead of the disable. Hermes knows ahead of time when the route's model catalog marks the model mandatory (OpenRouter and Nous Portal `/v1/models`, cached in `cache/reasoning_caps.json`), or when the route already answered an earlier disable that way in the same process. So the rejected request is not sent. A fresh install with no cached catalog can still see that 400 once: the lookup fetches the catalog in the background and later calls use it.
**Background review is different:** a same-model review fork always inherits the parent's reasoning effort. `auxiliary.background_review.reasoning_effort` is ignored on that path, including when the parent provider/model is explicitly selected. This preserves byte-identical reasoning settings, system prompt, full conversation snapshot, and tool definitions for prompt-cache parity; there is no independent-effort switch for same-model reviews. See [background review reasoning](./features/memory.md#same-model-review-reasoning). When the review is routed to a different provider/model, `reasoning_effort` applies to that routed fork (unset = the routed provider's default). Hermes prints a one-time warning when the key is set but the review runs on the main model.
**MoA also uses a different configuration:** reasoning depth for Mixture-of-Agents is configured **per slot** in the MoA preset (`moa.presets..reference_models[].reasoning_effort` / `aggregator.reasoning_effort`), not on the `moa_reference`/`moa_aggregator` auxiliary blocks — see [Mixture of Agents](./features/mixture-of-agents.md).
```yaml
auxiliary:
compression:
reasoning_effort: "low" # summaries don't need deep thinking
vision:
reasoning_effort: "none" # disable thinking for image description
```
When `base_url` is set, Hermes ignores the provider and calls that endpoint directly (using `api_key` or `OPENAI_API_KEY` for auth). When only `provider` is set, Hermes uses that provider's built-in auth and base URL.
Available providers for auxiliary tasks: `auto`, `main`, plus any provider in the [provider registry](../reference/environment-variables.md) — `openrouter`, `nous`, `openai-codex`, `copilot`, `copilot-acp`, `anthropic`, `gemini`, `qwen-oauth`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `deepseek`, `nvidia`, `xai`, `xai-oauth`, `ollama-cloud`, `alibaba`, `bedrock`, `huggingface`, `arcee`, `xiaomi`, `kilocode`, `opencode-zen`, `opencode-go`, `commandcode`, `commandcode-anthropic`, `ai-gateway`, `azure-foundry` — or any named custom provider from your `providers:` dict (e.g. `provider: "beans"`).
Local OpenAI-compatible servers work under their own names too: `provider: ollama` (also `vllm`, `llamacpp`, `llama.cpp`) with a `base_url` such as `http://127.0.0.1:11434` and an empty `api_key` routes through the custom endpoint with a placeholder key, and a bare `host:port` base_url gets the `/v1` suffix automatically.
`provider: openai` is a direct-API alias: it routes through the custom endpoint at the block's `base_url`, else `OPENAI_BASE_URL`, else `https://api.openai.com/v1`, authenticated with `api_key` or `OPENAI_API_KEY`. Every auxiliary task resolves it the same way — `compression`/`vision`/`title_generation` as well as `background_review`, `curator` and MoA slots — so removing `base_url` while keeping `provider: openai` moves that task to the public OpenAI endpoint. A `providers.openai` entry in your `providers:` dict takes precedence and keeps its own endpoint and key.
When a routed `auxiliary.` block cannot be resolved (unknown provider, missing endpoint or credentials), the task runs on the main model and Hermes says so: `background_review` emits a one-time user-visible warning naming the provider and reason (plus a `WARNING` line in `agent.log` per review), and `hermes doctor` resolves every routed `auxiliary.` block through the same resolver and reports the ones that fail.
:::tip MiniMax OAuth
`minimax-oauth` logs in via browser OAuth (no API key needed). Run `hermes model` and select **MiniMax (OAuth)** to authenticate. Auxiliary tasks use `MiniMax-M2.7-highspeed` automatically. See the [MiniMax OAuth guide](../guides/minimax-oauth.md).
:::
:::tip xAI Grok OAuth
`xai-oauth` logs in via browser OAuth for SuperGrok and X Premium+ subscribers (no API key needed). Run `hermes model` and select **xAI Grok OAuth (SuperGrok / Premium+)** to authenticate. The same OAuth token is reused for every direct-to-xAI surface (chat, auxiliary tasks, TTS, image gen, video gen, transcription). See the [xAI Grok OAuth guide](../guides/xai-grok-oauth.md), and if Hermes is on a remote host see [OAuth over SSH / Remote Hosts](../guides/oauth-over-ssh.md).
:::
:::warning `"main"` is for auxiliary tasks only
The `"main"` provider option means "use whatever provider my main agent uses" — it's only valid inside `auxiliary:`, `compression:`, and primary fallback entries (`fallback_providers:` or legacy `fallback_model:`). It is **not** a valid value for your top-level `model.provider` setting. If you use a custom OpenAI-compatible endpoint, set `provider: custom` in your `model:` section. See [AI Providers](../integrations/providers.md) for all main model provider options.
:::
### Full auxiliary config reference
```yaml
auxiliary:
# Image analysis (vision_analyze tool + browser screenshots)
vision:
provider: "auto" # "auto", "openrouter", "nous", "codex", "main", etc.
model: "" # e.g. "openai/gpt-4o", "google/gemini-2.5-flash"
base_url: "" # Custom OpenAI-compatible endpoint (overrides provider)
api_key: "" # API key for base_url (falls back to OPENAI_API_KEY)
timeout: 120 # seconds — LLM API call timeout; vision payloads need generous timeout
download_timeout: 30 # seconds — image HTTP download; increase for slow connections
max_concurrency: 8 # max concurrent image encode/resize bursts across the process
# (default: host CPU core count, no ceiling) — bounds only the
# CPU-bound encode step so a video-frame fan-out can't saturate
# every core and starve the event loop; LLM calls stay fully
# concurrent. Minimum 1; values < 1 are ignored.
# Dangerous command approval classifier
approval:
provider: "auto"
model: ""
base_url: ""
api_key: ""
timeout: 30 # seconds
# Gemini 3.1 TTS hidden audio-tag insertion
tts_audio_tags:
provider: "auto"
model: "" # empty = main chat model
base_url: ""
api_key: ""
timeout: 30
# Context compression timeout (separate from compression.* config)
compression:
timeout: 120 # seconds — compression summarizes long conversations, needs more time
# no_progress_timeout: 60 # Responses-stream routes (openai-codex) only: seconds a summary stream
# # may go without a substantive event before the attempt fails fast
# fallback_chain: # Optional — providers to try on rate-limit / connectivity failure
# - provider: nous
# model: deepseek/deepseek-chat
# - provider: openrouter
# model: google/gemini-2.5-flash
# base_url: ""
# api_key: ""
# max_concurrency: 2 # Optional: cap simultaneous compression LLM calls so
# multiple sessions don't pile retries on a degraded provider
# Auto-generated session titles. Empty language follows the conversation;
# set e.g. "English" or "Japanese" to pin titles to one language.
title_generation:
enabled: true # set false to disable auto-title generation
model_upgrade_enabled: true # set false to keep the instant derived title, never call a model
provider: "auto"
model: ""
base_url: ""
api_key: ""
timeout: 30
language: ""
# Skills hub — skill matching and search
skills_hub:
provider: "auto"
model: ""
base_url: ""
api_key: ""
timeout: 30
# MCP tool dispatch
mcp:
provider: "auto"
model: ""
base_url: ""
api_key: ""
timeout: 30
# Auto-generated short session titles after the first exchange
title_generation:
provider: "auto"
model: ""
base_url: ""
api_key: ""
timeout: 30
# max_concurrency: 2 # Optional: cap simultaneous title-generation calls
# Kanban triage specifier — `hermes kanban specify ` (or the
# dashboard's ✨ Specify button on Triage-column cards) uses this
# slot to expand a one-liner into a concrete spec and promote the
# task to `todo`. Cheap fast models work well here; spec expansion
# is short and doesn't need reasoning depth.
triage_specifier:
provider: "auto"
model: ""
base_url: ""
api_key: ""
timeout: 120
```
:::tip
Each auxiliary task has a configurable `timeout` (in seconds). Defaults: vision 120s, approval 30s, compression 120s, title generation 30s, every other task 30s. Increase these if you use slow local models for auxiliary tasks — a reasoning model that emits a thinking block before its answer routinely needs more than 30s for a title, and a request that hits the deadline is logged as `Auxiliary : request to timed out after s (raise auxiliary..timeout …)` before Hermes tries the fallback chain. Title generation, compression and vision give up on the primary route after one full timeout window (no same-provider retry) so a slow model cannot multiply the wait. Vision also has a separate `download_timeout` (default 30s) for the HTTP image download — increase this for slow connections or self-hosted image servers.
:::
:::info
Context compression has its own `compression:` block for thresholds and an `auxiliary.compression:` block for model/provider settings — see [Context Compression](#context-compression) above. The primary fallback chain uses a top-level `fallback_providers:` list — see [Fallback Providers](../integrations/providers.md#fallback-providers). All three follow the same provider/model/base_url pattern.
:::
### Per-task fallback chain for auxiliary tasks
Each auxiliary task can optionally define a `fallback_chain` — a list of provider/model entries that Hermes tries when the primary auxiliary provider fails due to rate limits, connectivity issues, or payment restrictions:
```yaml
auxiliary:
compression:
provider: openrouter
model: openai/gpt-4o-mini
fallback_chain:
- provider: nous
model: deepseek/deepseek-chat
- provider: openrouter
model: google/gemini-2.5-flash
```
When the primary auxiliary provider (`openrouter` / `openai/gpt-4o-mini`) returns a rate-limit, connection timeout, or payment-required error, Hermes walks the `fallback_chain` in order. It skips entries whose provider matches the already-failed provider, and tries each remaining entry until one succeeds or the chain is exhausted. If all fallbacks fail, Hermes falls back to the main agent model as a final safety net.
Each entry supports the same three knobs as any auxiliary task config:
| Key | Description |
|-----|-------------|
| `provider` | Provider name (`nous`, `openrouter`, `anthropic`, `gemini`, `main`, etc.) |
| `model` | Model name for that provider |
| `base_url` | (Optional) Custom OpenAI-compatible endpoint |
`fallback_chain` is available on any auxiliary task — `compression`, `vision`, `approval`, `skills_hub`, `mcp`, etc.
### Native vision embed budgets (top-level `vision:`)
Separate from `auxiliary.vision` (which picks the describer model): when the *main* model is vision-capable, `vision_analyze` and browser screenshots embed real pixels into tool results that are re-sent every later turn. `vision.embed_target_bytes` (default `262144`, clamped 64 KiB..4 MiB) sizes one embed; `vision.max_calls_per_image` caps how often the same image may be embedded per session (unset = 3 inside delegated subagents, unlimited for the main agent; `0` = unlimited). See [Vision → Native embeds ride the session](./features/vision.md#native-embeds-ride-the-session-visionembed_target_bytes-and-visionmax_calls_per_image).
### Limiting auxiliary concurrency
`max_concurrency` caps in-flight LLM calls for auxiliary tasks such as `compression` and `title_generation` across the whole process. `auxiliary.vision.max_concurrency` is excluded: it already controls only vision's CPU-bound image encode/resize workers, not LLM requests. This is most useful when:
- Many sessions can spawn background work simultaneously (Discord/Telegram channels, multiple terminals)
- Your provider is rate-limited or going through an incident and retries would amplify the burst
The default is unlimited. A typical safety cap is `2`:
```yaml
auxiliary:
title_generation:
max_concurrency: 2
compression:
max_concurrency: 2
```
The semaphore wraps the entire call including retries and fallbacks, so a single slow call counts only once toward the limit.
### OpenRouter routing & Pareto Code for auxiliary tasks
When an auxiliary task resolves to OpenRouter (either explicitly or via `provider: "main"` while your main agent is on OpenRouter), the main agent's `provider_routing` and `openrouter.min_coding_score` settings **do not propagate** — by design, each auxiliary task is independent. To set OpenRouter provider preferences or use the [Pareto Code router](../integrations/providers.md#openrouter-pareto-code-router) for a specific aux task, set them per-task via `extra_body`:
```yaml
auxiliary:
compression:
provider: openrouter
model: openrouter/pareto-code # use the Pareto Code router for this task
extra_body:
provider: # OpenRouter provider routing prefs
order: [anthropic, google] # try these providers in order
sort: throughput # or "price" | "latency"
# only: [anthropic] # restrict to a specific provider
# ignore: [deepinfra] # exclude specific providers
plugins: # OpenRouter Pareto Code router knob
- id: pareto-router
min_coding_score: 0.5 # 0.0–1.0; higher = stronger coders
```
The shape mirrors what OpenRouter accepts in the chat completions request body. Hermes forwards the entire `extra_body` verbatim, so any other OpenRouter request-body field documented at [openrouter.ai/docs](https://openrouter.ai/docs) works the same way.
### Changing the Vision Model
To use GPT-4o instead of Gemini Flash for image analysis:
```yaml
auxiliary:
vision:
model: "openai/gpt-4o"
```
Or via environment variable (in `~/.hermes/.env`):
```bash
AUXILIARY_VISION_MODEL=openai/gpt-4o
```
### Provider Options
These options apply to **auxiliary task configs** (`auxiliary:`, `compression:`) and primary fallback entries (`fallback_providers:` or legacy `fallback_model:`), not to your main `model.provider` setting.
| Provider | Description | Requirements |
|----------|-------------|-------------|
| `"auto"` | Best available (default). Vision tries OpenRouter → Nous → Codex. | — |
| `"openrouter"` | Force OpenRouter — routes to any model (Gemini, GPT-4o, Claude, etc.) | `OPENROUTER_API_KEY` |
| `"nous"` | Force Nous Portal | `hermes auth` |
| `"codex"` | Force Codex OAuth (ChatGPT account). Set `model` explicitly (e.g. `gpt-5.4`). | `hermes model` → ChatGPT or Codex Subscription |
| `"minimax-oauth"` | Force MiniMax OAuth (browser login, no API key). Uses MiniMax-M2.7-highspeed for auxiliary tasks. | `hermes model` → MiniMax (OAuth) |
| `"xai-oauth"` | Force xAI Grok OAuth (browser login for SuperGrok or X Premium+ subscribers, no API key). Same OAuth token covers chat, TTS, image, video, and transcription. | `hermes model` → xAI Grok OAuth (SuperGrok / Premium+) |
| `"main"` | Use your active custom/main endpoint. This can come from `OPENAI_BASE_URL` + `OPENAI_API_KEY` or from a custom endpoint saved via `hermes model` / `config.yaml`. Works with OpenAI, local models, or any OpenAI-compatible API. **Auxiliary tasks only — not valid for `model.provider`.** | Custom endpoint credentials + base URL |
Direct API-key providers from the main provider catalog also work here when you want side tasks to bypass your default router. For example, `gmi` is valid once `GMI_API_KEY` is configured, and `fireworks` is valid once `FIREWORKS_API_KEY` is configured:
```yaml
auxiliary:
compression:
provider: "gmi"
model: "anthropic/claude-opus-4.6"
```
For GMI auxiliary routing, use the exact model ID returned by GMI's `/v1/models` endpoint. Fireworks model IDs use the provider's native slash form, for example `accounts/fireworks/models/glm-5p2`.
### Common Setups
**Using a direct custom endpoint** (clearer than `provider: "main"` for local/self-hosted APIs):
```yaml
auxiliary:
vision:
base_url: "http://localhost:1234/v1"
api_key: "local-key"
model: "qwen2.5-vl"
```
`base_url` takes precedence over `provider`, so this is the most explicit way to route an auxiliary task to a specific endpoint. For direct endpoint overrides, Hermes uses the configured `api_key` or falls back to `OPENAI_API_KEY`; it does not reuse `OPENROUTER_API_KEY` for that custom endpoint.
**Using OpenAI API key for vision:**
```yaml
# In ~/.hermes/.env:
# OPENAI_BASE_URL=https://api.openai.com/v1
# OPENAI_API_KEY=sk-...
auxiliary:
vision:
provider: "main"
model: "gpt-4o" # or "gpt-4o-mini" for cheaper
```
**Using OpenRouter for vision** (route to any model):
```yaml
auxiliary:
vision:
provider: "openrouter"
model: "openai/gpt-4o" # or "google/gemini-2.5-flash", etc.
```
**Using Codex OAuth** (ChatGPT Pro/Plus account — no API key needed):
```yaml
auxiliary:
vision:
provider: "codex" # uses your ChatGPT OAuth token
model: "gpt-5.4" # no implicit default on the Codex route
```
**Using MiniMax OAuth** (browser login, no API key needed):
```yaml
model:
default: MiniMax-M2.7
provider: minimax-oauth
base_url: https://api.minimax.io/anthropic
```
Run `hermes model` and select **MiniMax (OAuth)** to log in and set this automatically. For the China region, the base URL will be `https://api.minimaxi.com/anthropic`. See the [MiniMax OAuth guide](../guides/minimax-oauth.md) for the full walkthrough.
**Using a local/self-hosted model:**
```yaml
auxiliary:
vision:
provider: "main" # uses your active custom endpoint
model: "my-local-model"
```
`provider: "main"` uses whatever provider Hermes uses for normal chat — whether that's a named custom provider (e.g. `beans`), a built-in provider like `openrouter`, or a legacy `OPENAI_BASE_URL` endpoint.
:::tip
If you use Codex OAuth as your main model provider, vision works automatically — no extra configuration needed. Codex is included in the auto-detection chain for vision.
:::
:::warning
**Vision requires a multimodal model.** If you set `provider: "main"`, make sure your endpoint supports multimodal/vision — otherwise image analysis will fail.
:::
### Environment Variables (legacy)
Auxiliary models can also be configured via environment variables. However, `config.yaml` is the preferred method — it's easier to manage and supports all options including `base_url` and `api_key`.
| Setting | Environment Variable |
|---------|---------------------|
| Vision provider | `AUXILIARY_VISION_PROVIDER` |
| Vision model | `AUXILIARY_VISION_MODEL` |
| Vision endpoint | `AUXILIARY_VISION_BASE_URL` |
| Vision API key | `AUXILIARY_VISION_API_KEY` |
Compression and fallback model settings are config.yaml-only. (`AUXILIARY_WEB_EXTRACT_*` variables are obsolete — web extraction no longer uses an auxiliary LLM.)
:::tip
Run `hermes config` to see your current auxiliary model settings. Overrides only show up when they differ from the defaults.
:::
## Reasoning Effort
Control how much "thinking" the model does before responding:
```yaml
agent:
reasoning_effort: "" # empty = medium. Options: none, minimal, low, medium, high, xhigh, max, ultra
```
When unset (default), reasoning effort defaults to "medium" — a balanced level that works well for most tasks. Setting a value overrides it — higher reasoning effort gives better results on complex tasks at the cost of more tokens and latency.
### Answer length (`text_verbosity`)
Responses-API models (OpenAI GPT-5 family and later, direct OpenAI, ChatGPT Codex and Azure routes) also accept a separate knob for how long the final natural-language answer is, independent of reasoning depth:
```yaml
agent:
text_verbosity: "" # empty = not sent (provider default). Options: low, medium, high
```
Hermes sends it as the top-level Responses `text: {verbosity: ...}` field only on Responses-family routes; it is never sent on `chat_completions`, Anthropic or xAI requests, and an empty or unknown value sends nothing. Structured-output (`text.format`) set through `request_overrides` is passed through unchanged.
:::note Adaptive-thinking models (Claude 4.6+, Fable/Mythos-class) over OpenRouter
These models use *adaptive* thinking and don't accept the usual `reasoning.effort`
field — OpenRouter ignores it for them. Hermes transparently routes your
`reasoning_effort` to OpenRouter's `verbosity` parameter instead (which maps to
Anthropic's `output_config.effort`), so the same effort knob keeps working with
the levels supported by the selected model. `none` (or unset) leaves the model
on its own adaptive default. The
native Anthropic provider already controls effort directly and is unaffected.
:::
:::note OpenRouter models and supported effort levels
For other models routed through OpenRouter, Hermes reads the live model
catalog's reasoning metadata (`supported_parameters` + per-model
`reasoning.supported_efforts`) to decide whether to send reasoning controls at
all and to clamp your requested effort to the nearest level the route actually
supports (always downward — e.g. `ultra` becomes `high` on a route that stops
at `high`, never a silent escalation). New reasoning-capable vendors work
automatically without waiting for a Hermes update; when the catalog is
unreachable or a model isn't listed, Hermes falls back to its built-in
model-family list and passes your effort through unchanged.
:::
:::note `ultra` is clamped to the strongest level the route accepts
`ultra` is a Hermes-internal ladder step: no provider wire accepts it, so every route clamps it
to its strongest level (`max` on GPT-5.6 Codex and OpenAI-compatible routes, `xhigh` on older
Codex models). The effort pickers and `/reasoning` status show this as
`ultra (sends max on this route)` so the level you see is the level that is sent.
:::
You can also change the reasoning effort at runtime with the `/reasoning` command:
```
/reasoning # Show current effort level and display state
/reasoning high # Set reasoning effort to high (this session only)
/reasoning high --global # Set effort and persist to config.yaml
/reasoning none # Disable reasoning (this session only)
/reasoning show # Show model thinking above each response
/reasoning hide # Hide model thinking
```
Effort changes are session-scoped by default; add `--global` to save the
new level as your `agent.reasoning_effort` default.
#### Per-Model Reasoning Overrides
You can set different reasoning effort levels for different models. This is useful when you want high reasoning for complex models but medium for faster ones:
```yaml
agent:
reasoning_effort: "medium" # global default
reasoning_overrides:
"openrouter/anthropic/claude-opus-4.5": "xhigh"
"openai/gpt-5": "low"
"claude-sonnet-4.6": "high" # bare model name also works
```
The key matching is **spelling-tolerant** — any reasonable spelling will match:
- `claude-opus-4.5`, `claude-opus-4-5`, `claude-opus.4.5` (dots and dashes are interchangeable)
- `anthropic/claude-opus-4.5`, `openrouter/anthropic/claude-opus-4.5` (provider prefix optional)
- A key prefixed with a named custom provider (`ollama-local/qwen3.6:27b-q4_k_m`) also applies when the request carries only the bare model id (`qwen3.6:27b-q4_k_m`), which is what fallback entries and `providers:` routes send
- Exact matches take precedence over variants
#### Custom reasoning tier names
Some OpenAI-compatible endpoints expose thinking tiers outside the standard ladder (a relay serving `fast`/`thinking` instead of `low`…`max`). A bare string outside the ladder is rejected with `Unknown reasoning_effort '', using default (medium)` so a typo can never reach the wire. To request a provider's own tier name, use the explicit dict form — the `effort` value is sent verbatim as the top-level `reasoning_effort` field:
```yaml
agent:
reasoning_effort:
enabled: true
effort: thinking # sent as-is
reasoning_overrides:
"my-relay/lumo-max": # dict form works per model too
enabled: true
effort: fast
```
`enabled: false` in the dict form turns thinking off, the same as `reasoning_effort: none`.
The dict form is set by editing `config.yaml` directly: the `/reasoning` menus, `hermes model`, and the dashboard's auxiliary-model pickers only offer the standard ladder (the TUI status and setup wizard still show the custom tier name once it is configured).
:::note
Model ids contain dots (`claude-opus-4.5`, `qwen3.6:27b`), which `hermes config set` treats as nesting separators. Escape them with a backslash to write the literal key — `hermes config set 'agent.reasoning_overrides.ollama-local/qwen3\.6:27b-q4_k_m' low` — or edit the YAML directly. See [Dots inside key names](../reference/cli-commands.md#dots-inside-key-names).
:::
:::note OpenAI Responses (`openai-api`, `openai-codex`)
`reasoning_effort: none` is sent explicitly as `reasoning.effort: "none"` on models that accept it (GPT-5.x): omitting the field would leave the model's default effort on (GPT-5.6 defaults to `medium`). An unset effort is the only state that omits the field. Chat-era models on `api.openai.com` (`gpt-4o`, `gpt-4.1`, their `-mini` variants and fine-tunes) reject any `reasoning` parameter, so Hermes sends none for them regardless of the configured effort instead of failing with `400 Unsupported parameter: 'reasoning.effort'`. If a model rejects `none`, Hermes warns, drops the disable for the session and retries with the model's default.
:::
:::note Local OpenAI-compatible endpoints
A custom `base_url` (`http://localhost:11434/v1`, a vLLM, SGLang or router endpoint) receives the resolved effort — `agent.reasoning_effort` or the matching per-model override — as the standard top-level `reasoning_effort` request field, clamped to the values the OpenAI-compatible wire accepts (`none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`). An unset effort is sent as `medium` here too, the same default the Nous Portal and OpenRouter routes apply — leaving the field off would hand the choice to the endpoint, and a hosted reasoning model's own default can be its ceiling (kimi-k3 defaults to `max`: about 3x the reasoning tokens and latency of `medium`). The field stays off for a model the catalog or `model_overrides` mark `supports_reasoning: false`, for a local Ollama model pulled without the `thinking` capability, and for the rest of a session after the endpoint answered `400` to the field. The nested `reasoning` object is reserved for endpoints known to accept it (Nous Portal, OpenRouter reasoning-capable models, GitHub Models) because arbitrary servers reject unknown fields with HTTP 400. If your server reads its thinking budget from a different field (Ollama's `think`, vLLM's `chat_template_kwargs`, a router-specific key), set it under the custom provider's [`extra_body`](../integrations/providers.md#named-custom-providers), which is merged into every request routed there.
:::
**Resolution priority:**
1. Session-scoped `/reasoning --session` override (gateway only)
2. Per-model override from `agent.reasoning_overrides` (spelling-tolerant)
3. Global `agent.reasoning_effort`
4. Provider default
The override applies automatically everywhere: CLI startup, `hermes -p` one-shots, messaging gateway, Desktop/TUI, ACP sessions, cron jobs, `/model` mid-session switches (including a switch issued before the first message), session resume (`--resume`, `/resume`), `/new`, and fallback model activation.
## Fast Mode
Fast mode asks the provider for faster output at a premium price: OpenAI [Priority Processing](https://openai.com/api-priority-processing/) (`service_tier: priority`), xAI Priority Processing on Grok 4.6, and Anthropic [Fast Mode](https://platform.claude.com/docs/en/build-with-claude/fast-mode) (`speed: fast`, Opus 4.8 / Opus 5 / Opus 5.5 only). It is **off by default**.
```yaml
agent:
service_tier: "" # "" / normal | fast | auto | cold
fast_auto_seconds: 60 # window for auto / cold
```
| Mode | When fast params are sent | Use it for |
|------|---------------------------|------------|
| `normal` (default, `""`) | Never | Cheapest; standard latency |
| `fast` | Every request | Long interactive sessions where you always want speed |
| `auto` | Requests in the first `fast_auto_seconds` of **every** turn | Snappy first reply; long tool loops fall back to standard pricing |
| `cold` | Same window, but only on the **first turn** of a session (no prior history) | Fast onboarding reply, standard pricing afterwards |
`/fast normal|fast|auto|cold` switches the mode for the session; add `--global` to persist to `config.yaml`. `/fast` alone shows the current mode.
**Cost note:** both providers bill fast requests at a multiplier on standard rates (Anthropic: $8 / $40 per MTok in/out on Opus 5.5, $10 / $50 on Opus 5 and Opus 4.8), stacking with prompt-cache pricing. Hermes prices each Anthropic response from the speed the API reports in `usage.speed`. `auto`/`cold` bound that premium to the window only. Fast params are only sent to the first-party endpoint that supports them (`api.openai.com` / Codex subscription, `api.anthropic.com`, `api.x.ai`); OpenRouter, Nous Portal, Copilot, Azure, Bedrock, and custom `base_url` routes never receive them in any mode.
**Prompt cache:** only the per-request parameter changes between requests; the system prompt, tools, and messages stay byte-identical. Anthropic keeps a separate prompt cache for each speed, so on Anthropic every `auto`/`cold` window boundary re-writes the conversation prefix at the new speed. For long Anthropic sessions, `fast` or `normal` keeps a single warm cache.
Fast mode's speedup is in output tokens per second, so long answers gain the most.
When an Anthropic organization has no fast-mode capacity for a model (the API answers a fast request with a fast-mode limit of 0), Hermes switches that model to standard speed for the rest of the session and retries the request.
### Fast tiers behind a gateway or proxy
The first-party-only rule is deliberate: a fast-tier parameter is a billing instruction, and Hermes only sends it to the endpoint whose price list it knows. If you run an OpenAI-compatible gateway, router, or proxy that exposes its own priority tier (its own `service_tier` value, or a differently named field), request it through that provider's `extra_body` instead of `agent.service_tier`. `extra_body` on a [named custom provider](../integrations/providers.md#named-custom-providers) is merged into **every** chat-completions request routed to that endpoint, survives gateway turns and `/fast` changes, and is dropped again when you `/model` away from the provider:
```yaml
providers:
my-gateway:
api: https://gateway.example.com/v1
key_env: MY_GATEWAY_KEY
default_model: fast-lane-model
extra_body:
service_tier: priority # whatever tier value your gateway documents
```
Differences from `agent.service_tier`: the tier is always on for that provider (no `auto`/`cold` window), `/fast` does not toggle it, and Hermes does not validate the value — the gateway decides what it accepts and what it bills.
## Tool-Use Enforcement
Some models occasionally describe intended actions as text instead of making tool calls ("I would run the tests..." instead of actually calling the terminal). Tool-use enforcement injects system prompt guidance that steers the model back to actually calling tools.
```yaml
agent:
tool_use_enforcement: "auto" # "auto" | true | false | ["model-substring", ...]
```
| Value | Behavior |
|-------|----------|
| `"auto"` (default) | Enabled for models matching: `gpt`, `codex`, `gemini`, `gemma`, `grok`, `glm`, `qwen`, `deepseek`, `muse`. Disabled for all others (e.g. Claude). |
| `true` | Always enabled, regardless of model. Useful if you notice your current model describing actions instead of performing them. |
| `false` | Always disabled, regardless of model. |
| `["gpt", "codex", "qwen", "llama"]` | Enabled only when the model name contains one of the listed substrings (case-insensitive). |
### What it injects
When enabled, two layers of guidance may be added to the system prompt:
1. **General tool-use enforcement** (all matched models) — instructs the model to make tool calls immediately instead of describing intentions, keep working until the task is complete, and never end a turn with a promise of future action.
2. **Google operational guidance** (Gemini and Gemma models only) — conciseness, absolute paths, parallel tool calls, and verify-before-edit patterns.
These are transparent to the user and only affect the system prompt. Models that already use tools reliably (like Claude) don't need this guidance, which is why `"auto"` excludes them.
### When to turn it on
If you're using a model not in the default auto list and notice it frequently describes what it *would* do instead of doing it, set `tool_use_enforcement: true` or add the model substring to the list:
```yaml
agent:
tool_use_enforcement: ["gpt", "codex", "gemini", "grok", "my-custom-model"]
```
## Execution-Discipline Guidance
Separately from tool-use enforcement, Hermes injects an **execution-discipline** block for model families that share a set of agentic failure modes observed in eval traces: doing arithmetic in prose instead of code, skipping read-back verification after external writes, "repairing" malformed identifiers, claiming completeness despite count mismatches, and declaring "done" without verifying every acceptance criterion.
```yaml
agent:
execution_guidance: "auto" # "auto" | true | false | ["model-substring", ...]
```
| Value | Behavior |
|-------|----------|
| `"auto"` (default) | Enabled for models matching: `gpt`, `codex`, `grok`, `deepseek`, `kimi`, `qwen`, `glm`, `minimax`, `mimo`, `mistral`, `muse`. |
| `true` | Always enabled, regardless of model. |
| `false` | Always disabled, regardless of model. |
| `["deepseek", "my-custom-model"]` | Enabled only when the model name contains one of the listed substrings (case-insensitive). |
The injected block covers:
- **Tool persistence** — keep calling tools until the task is complete *and* verified; retry empty, partial, or suspiciously narrow lookup results with a broader or different query before concluding.
- **Mandatory tool use** — arithmetic, hashes, dates, system state, and file facts always come from a tool, never from mental computation.
- **External-write read-back** — after any state-changing write to an external system, read back the exact target before claiming success (internal file edits a tool already confirmed are not re-verified).
- **Count reconciliation** — declared totals (`total`, `reply_count`, `has_more`) are hard assertions; on mismatch, re-fetch or parse programmatically.
- **Literal preservation** — never normalize or "repair" identifiers that fail a stated format; a successful lookup does not validate a malformed source token.
- **Verification-gated completion** — "done" means every named acceptance criterion is verified, never a plausible subset.
The gate is independent of `tool_use_enforcement` — either can be on without the other. The guidance is chosen once at session start keyed on the model name, so the system prompt stays byte-stable (and prompt-cache-friendly) for the life of the conversation. Gemini/Gemma are excluded from the auto list because they receive the more specific Google operational guidance; Claude is excluded because it doesn't exhibit these failure modes — opt any model in with `true` or a substring list.
## Tool-Loop Guardrails
Hermes detects when the agent is stuck in an unproductive tool-calling loop — the same tool call failing repeatedly, the same tool failing over and over, or an idempotent call returning the same result with no progress. By default it injects a **warning** into the tool result so the model self-corrects. Interactive CLI, TUI, Desktop, and ACP sessions remain warning-only because a person can intervene; unattended gateway and cron sessions enable hard stops by default.
The platform-aware default can be disabled for an unattended deployment, or hard stops can be explicitly enabled on every platform:
```yaml
tool_loop_guardrails:
warnings_enabled: true # inject warnings into tool results (default: true)
hard_stop_enabled: false # also BLOCK the call past the hard-stop threshold (default: false)
non_interactive_hard_stop_enabled: true # default hard stops for gateway/cron
warn_after:
exact_failure: 2 # identical failing call repeated N times
same_tool_failure: 3 # same tool failing N times (different args)
idempotent_no_progress: 2 # same result, no progress, N times
hard_stop_after:
exact_failure: 5
same_tool_failure: 8
idempotent_no_progress: 5
loop_caps:
max_web_searches: 50 # max web_search calls per turn (0 = unlimited)
max_subagents: 50 # max subagents spawned per turn (0 = unlimited)
```
`hard_stop_enabled` explicitly enables hard stops on every platform. When it remains `false`, `non_interactive_hard_stop_enabled` still enables them for unattended gateway/cron-style platforms while preserving warning-only behavior for CLI, TUI, Desktop, ACP, subagents, and `api_server` runs (supervised task loops with a live parent or client). Set `non_interactive_hard_stop_enabled: false` to opt an unattended deployment out. See also [Docker / unattended deployments](docker.md).
Hard stops are designed to catch **replays** — the same call, unchanged, with nothing happening in between — not legitimate iteration:
- **Edit → re-run is never a loop.** Any successful mutating call (`write_file`, `patch`, a green `terminal`/`execute_code`, a browser action, a job/message/cron mutation) marks progress for every failing call still being counted. The next identical retry (re-running a red test after a fix, re-snapshotting after a click) starts a fresh streak instead of accumulating toward a block.
- **Distinct red commands are diagnosis, not a loop.** For tools whose non-zero exit is ordinary output (`terminal`, `execute_code`, process pollers, `browser_navigate`, `web_extract`) the `same_tool_failure` threshold only warns and never halts. Only an exact-args replay with no intervening change, or an identical-result streak, can stop them.
- **A halt ends the turn, not the session.** The agent replies with which guardrail fired and why; replying "continue" resumes with fresh per-turn counters.
### Per-turn runaway-loop caps
Separate from the failure-based thresholds above, `loop_caps` sets hard ceilings on how many `web_search` calls and subagent spawns a single agent loop (turn) may make. The counters reset at the start of every turn, so a legitimate multi-turn session is never starved — but a single turn that spirals into an unbounded search or delegation loop is stopped. These are always on and fire regardless of `hard_stop_enabled`. A single turn issuing dozens of web searches or spawning dozens of subagents is already pathological, so the defaults are low. When a cap is reached, the offending tool call is blocked with an explanatory message and the turn stops cleanly instead of burning the rest of the budget. Set either value to `0` to disable that cap entirely.
A single `delegate_task` batch counts each task toward `max_subagents` (a batch of 3 spends 3), so the cap tracks real subagents spawned rather than `delegate_task` invocations.
This mirrors Claude Code's per-session WebSearch and subagent caps (v2.1.212), which also default to 200 and reset on `/clear`.
### Runtime anti-stall guards
Complementing the failure-based guardrails above, `agent.stall_guards` (default `true`) enables two conservative runtime guards against wasted turns. First, an **identical-call loop breaker**: when the same tool is called 3+ consecutive times with identical arguments *and* returns an identical result, a short one-line notice is appended to that tool result telling the model not to repeat the call — in warning-only sessions it never blocks the call, and legitimately-repeatable pollers (`process_manage`, `*_get_result`, `*_poll`) are exempt. When hard stops are active (explicit `hard_stop_enabled`, or an unattended gateway/cron platform), the same streak also becomes a hard stop once it reaches `hard_stop_after.idempotent_no_progress` consecutive identical calls — for **any** tool, not just the read-only ones the `idempotent_no_progress` guardrail tracks — so a model replaying the same successful `terminal` or `skill_view` call is halted instead of running out the iteration budget (`identical_call_streak_halt`). Second, a **continue-intent recovery**: when the model ends a turn with no tool calls but its short reply trails off announcing an action ("Let me now update the file…"), Hermes re-prompts it to act via the same bounded continuation mechanism used for intent-ack recovery (max 2 re-prompts per turn). Both are cache-safe (notices are added at result construction, never retroactively) and can be disabled together:
```yaml
agent:
stall_guards: false
```
The same gate also enables **result-reference stubbing**: when a re-issued identical tool call returns a byte-identical fresh result, the duplicate payload enters context as a short reference stub pointing at the earlier result (tool name, `tool_call_id`, an args summary, and — if the first result was persisted to disk — its spillover path) instead of repeating the full output. The tool still executes every time, so polling semantics are preserved: a changed result always flows through whole. Results under 512 characters, error results, and multimodal results are never stubbed, and pollers *are* stubbed (an unchanged poll is exactly the case where the duplicate payload carries no information).
### Turn liveness watchdog
`agent.turn_liveness` bounds how long a conversation turn may make **no observable progress** before Hermes force-recovers it. The watchdog keys off the activity clock (the same signal that stamps API waits, stream tokens, and tool heartbeats — lease renewal never counts), so a turn that silently wedges mid-flight (observed as issue #95548: no tool execution, no API call, no error, but the session stays "busy" indefinitely) is surfaced loudly, interrupted so it unwinds as a retriable interrupted turn, and — when the interrupt cannot unwind the wedge — its durable turn lease stops renewing so stale-turn cleanup can reclaim the session instead of it hanging until the process is killed.
```yaml
agent:
turn_liveness:
timeout_s: 600.0 # idle bound; <= 0 disables the watchdog
poll_s: 15.0 # sampling interval (seconds)
```
Legitimately slow work is not penalized: streaming responses, tool heartbeats (every 30s while a tool runs), and approval waits all keep touching the clock, so only a turn making *zero* progress for the full bound fires the watchdog. Invalid values (a typo, `NaN`, `Inf`, non-positive `poll_s`) log a warning and fall back to the defaults — they never crash startup or silently disable the watchdog. A fired abort reports the stall as it begins recovery, and publishes the definitive aborted/lease-stopped outcome only once the interrupt has actually committed.
## TTS Configuration
```yaml
tts:
provider: "edge" # "edge" | "elevenlabs" | "openai" | "minimax" | "mistral" | "gemini" | "xai" | "neutts" | "kittentts" | "piper" | "deepinfra"
speed: 1.0 # Global speed multiplier (fallback for all providers)
keep_warm_seconds: 60 # Keep a local engine loaded this long after the last speech toggle turns off (0 = unload at once)
edge:
voice: "en-US-AriaNeural" # 322 voices, 74 languages
speed: 1.0 # Speed multiplier (converted to rate percentage, e.g. 1.5 → +50%)
elevenlabs:
voice_id: "pNInz6obpgDQGcFmaJgB"
model_id: "eleven_multilingual_v2"
openai:
model: "gpt-4o-mini-tts"
voice: "alloy" # alloy, echo, fable, onyx, nova, shimmer
speed: 1.0 # Speed multiplier (clamped to 0.25–4.0 by the API)
base_url: "https://api.openai.com/v1" # Override for OpenAI-compatible TTS endpoints
pcm_sample_rate: 24000 # Streaming PCM rate; overridden by the endpoint's X-Audio-Sample-Rate header
minimax:
speed: 1.0 # Speech speed multiplier
# base_url: "" # Optional: override for OpenAI-compatible TTS endpoints
mistral:
model: "voxtral-mini-tts-2603"
voice_id: "c69964a6-ab8b-4f8a-9465-ec0925096ec8" # Paul - Neutral (default)
gemini:
model: "gemini-2.5-flash-preview-tts" # or gemini-3.1-flash-tts-preview
voice: "Kore" # 30 prebuilt voices: Zephyr, Puck, Kore, Enceladus, etc.
audio_tags: false # Hidden Gemini 3.1 TTS audio-tag insertion
persona_prompt_file: "" # Optional Markdown/text file with Gemini voice direction
xai:
voice_id: "eve" # xAI TTS voice
language: "en" # ISO 639-1
sample_rate: 24000
bit_rate: 128000 # MP3 bitrate
# base_url: "https://api.x.ai/v1"
neutts:
ref_audio: ''
ref_text: ''
model: neuphonic/neutts-air-q4-gguf
device: cpu
```
This controls both the `text_to_speech` tool and spoken replies in voice mode (`/voice tts` in the CLI or messaging gateway).
**Speed fallback hierarchy:** provider-specific speed (e.g. `tts.edge.speed`) → global `tts.speed` → `1.0` default. Set the global `tts.speed` to apply a uniform speed across all providers, or override per-provider for fine-grained control.
## Display Settings
```yaml
display:
tool_progress: all # off | new | all | verbose
tool_progress_command: false # Enable /verbose slash command in messaging gateway
focus_view: false # CLI focus view (/focus) — reduced output, display-only
platforms: {} # Per-platform display overrides (see below)
interim_assistant_messages: true # Gateway: send natural mid-turn assistant updates as separate messages
suppress_warning_notifications: false # Opt-in: hide automatic warning/diagnostic notices (see messaging guide)
show_commentary: true # Codex models: deliver commentary-channel progress narration as visible mid-turn updates
skin: default # Built-in or custom CLI skin (see user-guide/features/skins)
personality: "" # Legacy cosmetic field still surfaced in some summaries
compact: false # Compact output mode (less whitespace)
cli_multiline_shortcuts: true # CLI: Ctrl+J, \ + Enter, and supported Shift+Enter insert newlines (false = legacy c-j submit fallback)
resume_display: full # full (show previous messages on resume) | minimal (one-liner only)
bell_on_complete: false # Play terminal bell when agent finishes (great for long tasks)
bell_on_prompt: false # Play terminal bell when a blocking prompt opens (clarify, approval, sudo password, secret capture) — works over SSH
# Both bell flags also emit an OSC 9 desktop notification (Ghostty, iTerm2, Kitty, WezTerm raise an OS
# notification; other terminals ignore it) and, inside Warp (TERM_PROGRAM=WarpTerminal with the CLI-agent
# protocol advertised), a warp://cli-agent OSC 777 event (`stop` on completion, `permission_request` on
# blocking prompts) so Warp's tab status and notification mailbox track Hermes. No extra keys needed.
show_reasoning: true # Show model reasoning/thinking above each response (default: true; toggle with /reasoning show|hide)
streaming: false # Stream tokens to terminal as they arrive (real-time output)
show_cost: false # Show estimated $ cost in the CLI status bar
vim_mode: false # CLI only: vi/vim keybindings in the input composer (Esc → NORMAL, i → INSERT). The live NORMAL/INSERT/REPLACE mode shows at the right of the status bar. Config-only, read at startup.
timestamps: false # When true, prefixes user and assistant labels with timestamps in the CLI / TUI transcript
timestamp_format: "%H:%M" # strftime format for those timestamps (e.g. "%b-%d %H:%M" for month-day)
tool_preview_length: 0 # Max chars for tool call previews (0 = no limit, show full paths/commands)
turn_summary: true # CLI only: print a one-line post-turn accounting footer after each interactive turn
spinner_token_flow: true # CLI only: append live cumulative turn tokens to the spinner timer
runtime_footer: # Gateway: append a runtime-context footer to final replies
enabled: false
fields: ["model", "context_pct", "cwd"]
status_bar: # CLI/TUI: choose which status-bar fields are visible
fields: [] # empty = show the default set; see below
file_mutation_verifier: true # Append an advisory footer when write_file/patch calls failed this turn
credits_notices: true # Nous credits status-bar notices (usage bands, grant-spent, depleted). false = silence them; /usage still works
cli_rebuild_scrollback_on_redraw: false # Classic CLI: also wipe terminal scrollback (CSI 3J) on /redraw / Ctrl+L / width-change resize recovery. Enable when a terminal/tmux stack stamps stale prompt chrome into scrollback on maximize/restore.
language: en # UI language for static messages (approval prompts, some gateway replies). en | zh | zh-hant | ja | de | es | fr | tr | uk | af | ko | it | ga | pt | ru | hu
```
### Per-turn summary and spinner token flow
`display.turn_summary` (default `true`) prints one dim accounting line after each **interactive CLI** turn, summarising what that turn actually did:
```
⋯ 12.4s · edited 2 files +18 -3 · read 4 files · ran 3 commands
```
The tally is observed from the tool-progress feed the CLI already receives, so it costs nothing extra. Details:
- Wall time is the turn's real duration (`2m05s` past the one-minute mark).
- Tool calls are grouped by verb (`edited`, `read`, `ran`, `searched`, …) with correct pluralisation; plugin/MCP tools without a curated verb collapse into `called N tools`.
- `+X -Y` line deltas appear only when the tool result already reports a diff (currently `patch`). Hermes never shells out to git to compute them, so a `write_file` edit is counted without a delta.
- **Failed tool calls are not counted** — a denied write never renders as a successful edit (see the [file-mutation verifier](#file-mutation-verifier) for the complementary warning).
- Long turns cap at four verb segments plus a `+N more` tail so the line never wraps.
- A fast turn with no tool calls prints nothing at all.
`display.spinner_token_flow` (default `true`) appends the running turn's cumulative output tokens to the CLI spinner's live timer:
```
⚡ Reading cli.py ( 2.3s · ↓ 1.2k tok)
```
The count is per-turn (session totals are baselined at turn start) and updates as each API call in the turn reports usage. Nothing renders before the first usage report lands, so you never see a misleading `↓ 0 tok`.
Both keys are display-only and CLI-only: they are suppressed in quiet mode, when `display.tool_progress` is `off`, in single-query/`-Q` batch runs, and in gateway/messaging surfaces (those use `display.runtime_footer` instead). Set either key to `false` to turn it off.
### File-mutation verifier
When `display.file_mutation_verifier` is `true` (default), Hermes appends a one-line advisory to the assistant's final response whenever a `write_file` or `patch` call failed during the turn and the target file was neither written successfully afterwards (under any spelling of its path) nor otherwise changed on disk before the turn ended. This catches the "batch of parallel patches, half silently fail, model summarises success" class of over-claim without requiring you to manually run `git status` after every edit.
Example footer:
```
⚠️ File-mutation verifier: 3 file edit(s) FAILED this turn despite any wording above that may suggest otherwise. Run `git status` or `read_file` to confirm what actually landed.
• concepts/automatic-organization.md — [patch] Could not find match for old_string
• concepts/lora.md — [patch] Could not find match for old_string
• concepts/rag-pipeline.md — [patch] Could not find match for old_string
```
Set `file_mutation_verifier: false` (or `HERMES_FILE_MUTATION_VERIFIER=0`) to suppress the footer. The verifier only fires when real failures are outstanding at turn end — a model that retries a failed patch and succeeds within the same turn will not trigger it for that file.
**Trust the verifier over the model's summary.** The footer means the listed edit calls **failed** and Hermes saw no later change to those files, even if the assistant's closing message says the task is done. It only tracks `write_file`/`patch` receipts plus a modification-time check at turn end, so run `git status` or `read_file` to confirm what actually landed. Common causes:
- **Write denied** — path is on the credential denylist or outside `HERMES_WRITE_SAFE_ROOT` (see [File write safety](./security.md#file-write-safety))
- **Patch mismatch** — `old_string` did not match the file on disk
- **Syntax gate** — candidate content failed JSON/YAML/TOML validation before write
Example footer when writes are blocked:
```
⚠️ File-mutation verifier: 2 file edit(s) FAILED this turn despite any wording above that may suggest otherwise. Run `git status` or `read_file` to confirm what actually landed.
• ~/.hermes/cron/jobs.json — [patch] Write denied: '…' is outside HERMES_WRITE_SAFE_ROOT (/path/to/project)
• ~/.hermes/scripts/monitor.py — [write_file] Write denied: '…' is outside HERMES_WRITE_SAFE_ROOT (/path/to/project)
```
If writes to Hermes state (cron jobs, skills, scripts under `~/.hermes/`) are failing, check whether `HERMES_WRITE_SAFE_ROOT` is set in your environment. For cron changes, use the `cronjob_manage` tool or `hermes cron edit` instead of patching `jobs.json` directly.
### UI language for static messages
The `display.language` setting translates a small set of static user-facing messages — the CLI approval prompt, a handful of gateway slash-command replies (e.g. restart-drain notices, "approval expired", "goal cleared"). It does **not** translate agent responses, log lines, tool output, error tracebacks, or slash-command descriptions — those stay in English. If you want the agent itself to reply in another language, just tell it in your prompt or system message.
Supported values: `en` (default), `zh` (Simplified Chinese), `zh-hant` (Traditional Chinese), `ja` (Japanese), `de` (German), `es` (Spanish), `fr` (French), `tr` (Turkish), `uk` (Ukrainian), `af` (Afrikaans), `ko` (Korean), `it` (Italian), `ga` (Irish), `pt` (Portuguese), `ru` (Russian), `hu` (Hungarian). Unknown values fall back to English.
You can also set this per-session with the `HERMES_LANGUAGE` env var, which overrides the config value.
```yaml
display:
language: zh # CLI approval prompts appear in Chinese
```
| Mode | What you see |
|------|-------------|
| `off` | Silent — just the final response |
| `new` | Tool indicator only when the tool changes |
| `all` | Every tool call with a short preview (default) |
| `verbose` | Full args, results, and debug logs |
In the CLI, cycle through these modes with `/verbose`. To use `/verbose` in messaging platforms (Telegram, Discord, Slack, etc.), set `tool_progress_command: true` in the `display` section above. The command will then cycle the mode and save to config.
Tool progress requires a gateway adapter that can display progress updates safely. Platforms without message editing support, including Signal, suppress tool-progress bubbles even if `/verbose` saves a non-`off` mode.
`off` hides tool-call *chrome* only. Application state that has its own surface in the Desktop app and TUI — the task list (`todo_list`), subagent progress, clarify questions, and MCP consent cards — keeps flowing regardless of this setting.
### Focus view (`/focus`, CLI + TUI)
`display.focus_view: true` enables **focus view** — a reduced-output display mode for when you want the answer, not the play-by-play. It is a thin layer over the same `tool_progress` machinery rather than a second suppression path:
- turning it on pins `tool_progress` to `off` and stashes your previous mode in `display.focus_saved_tool_progress`;
- `/focus off` restores that mode exactly, so a `/verbose verbose` setup survives a round trip;
- each completed turn ends with a dim recovery line — `⋯ 7 tool lines hidden · /focus off to show` — counted against your *pre-focus* mode, so it never claims to have hidden lines you had already turned off;
- a persistent `◉ focus` badge sits in the status bar (both the prompt_toolkit CLI and the Ink TUI) so the reduced mode is never invisible;
- cycling `/verbose` while focus is on hands the mode back to `/verbose` and clears the badge.
Focus view is **display-only**. It never edits conversation history, the system prompt, tool schemas, or any request payload — hidden detail is suppressed on screen, never discarded, and prompt caching is completely unaffected.
### Status-bar field selection (CLI/TUI)
The interactive status bar at the bottom of the CLI/TUI shows the model, context usage, compression count, background-activity counters, timers, and mode badges. `display.status_bar.fields` chooses which of those are visible — useful for a minimal bar (just model + duration) or for surfacing the opt-in session token total:
```yaml
display:
status_bar:
fields: ["model", "duration", "total_tokens"] # visibility only; built-in order is preserved
```
Supported fields: `model`, `context_detail` (used/total tokens), `context_pct` (percent + meter), `cache_hit` (prompt cache hit ratio — resets on model switch and compression), `latency` (rolling mean API latency, last 10 calls), `tps` (rolling output tokens/sec, last 10 calls), `compressions`, `bg_tasks`, `bg_processes`, `bg_subagents`, `goal`, `git_branch` (⎇ current git branch of the working directory — opt-in only, never shown by default; detached HEAD shows the abbreviated commit), `duration`, `prompt_elapsed`, `idle_since`, `focus`, `yolo`, `stash`, `battery`, `title` (right-aligned session badge), and `total_tokens` (session Σ — opt-in only, never shown by default).
Notes:
- An empty list (the default) keeps the standard set — everything except `total_tokens` and `git_branch`.
- The config controls **visibility, not order**; fields render in their built-in positions.
- Narrow terminals still drop wide-mode-only fields (`context_detail`, `cache_hit`, `latency`, `tps`, `prompt_elapsed`, `idle_since`) regardless of config (`cache_hit` also shows in the medium ≥52-col tier).
- `latency`/`tps` stay hidden until API calls have been recorded (e.g. the Codex app-server backend reports no latency).
- `battery` and `title` visibility here compose with their own toggles (`/battery`, `/title`) — both must be on for the segment to show.
- The same key also filters the **Ink TUI** status rule (`hermes tui`), where `cache_hit`, `latency`, and `tps` render as width-budgeted tail segments (◎ / ◷ / ↑) on terminals ≥96/104/110 columns respectively.
- Display-only: no effect on prompt caching or request payloads. Changes take effect on the next session start.
### Runtime-metadata footer (gateway only)
When `display.runtime_footer.enabled: true`, Hermes appends a small runtime-context footer to the **final** message of each gateway turn. The current footer can show the model, context-window percentage, and current working directory. Off by default; opt in per-gateway if your team wants every reply to include this provenance.
```yaml
display:
runtime_footer:
enabled: true
fields: ["model", "context_pct", "cwd"] # order shown; drop any to hide
```
Supported fields:
| Field | Renders | Example |
| --- | --- | --- |
| `model` | Bare model id, vendor prefix dropped | `gpt-5.4` |
| `context_pct` | Last-call context occupancy as a percent | `5%` |
| `latency` | Wall-clock duration of the turn | `22s`, `1m05s` |
| `served_model` | The model that actually answered, when it differs from the one you configured: the deployment a routing proxy reported in its `x-litellm-model-id` (or `x-litellm-model-api-base`) response header, or the fallback model Hermes switched to for the turn | `hermes-router → gpt-4o-2024-11-20` |
| `cwd` | Home-relative working directory | `~` |
The default field set is `["model", "context_pct", "cwd"]`. `latency` and `served_model` are opt-in — add them to `fields` to use them. `served_model` renders nothing when the served model is the configured one (or when the proxy sends no such header), so behind a routing proxy or an active fallback it is the field that makes the switch visible. Fields whose data is unavailable are skipped silently rather than rendering an empty slot.
The `/footer` slash command toggles this at runtime in any session.
Example footer appended to a Telegram/Discord/Slack reply:
```
— claude-opus-4.7 · 12 tool calls · 2m 14s · $0.042
```
Only the **final** message of a turn gets the footer; interim updates stay clean.
### Per-platform progress overrides
Different platforms have different verbosity needs. Use `display.platforms` to set per-platform modes:
```yaml
display:
tool_progress: all # global default
platforms:
signal:
tool_progress: 'off' # Signal cannot currently display tool-progress bubbles
telegram:
tool_progress: verbose # detailed progress on Telegram
slack:
tool_progress: 'off' # quiet in shared Slack workspace
```
From the CLI, use the canonical path — `hermes config set display.platforms.telegram.streaming false`. The shorthand `hermes config set platforms.telegram.streaming false` is accepted too: because per-platform *display* settings (`streaming`, `show_reasoning`, `tool_progress`, …) are only ever read from `display.platforms`, `config set`/`get`/`unset` redirect that shorthand to the canonical key and print a note. Connection keys under the top-level `platforms.` block (`token`, `enabled`, `reply_to_mode`, `extra`) are not redirected. Writing them under the nested prefix (`hermes config set gateway.platforms.telegram.enabled true`) is redirected to the top-level `platforms.telegram.enabled` with a note: the gateway reads both blocks, but the top-level one wins on shared keys, so a nested write would be silently shadowed by an existing top-level value.
Platforms without an override fall back to the global `tool_progress` value. Valid platform keys: `telegram`, `discord`, `slack`, `signal`, `whatsapp`, `matrix`, `mattermost`, `email`, `sms`, `homeassistant`, `dingtalk`, `feishu`, `wecom`, `weixin`, `bluebubbles`, `qqbot`. The legacy `display.tool_progress_overrides` key still loads for backward compatibility but is deprecated and migrated into `display.platforms` on first load.
Signal is listed as a valid platform key because the setting can be saved per platform, but the current Signal adapter cannot edit sent messages and does not render tool-progress bubbles. Keep Signal `tool_progress` set to `off`; use the CLI or an editing-capable messaging platform if you need to watch each tool call live.
`interim_assistant_messages` is gateway-only. When enabled, Hermes sends completed mid-turn assistant updates as separate chat messages. This is independent from `tool_progress` and does not require gateway streaming.
`show_commentary` (default `true`) controls Codex Responses models' commentary channel — the polished progress narration these models produce alongside their private reasoning. When enabled, each completed commentary message is delivered as a visible mid-turn update (on the gateway this also requires `interim_assistant_messages`). Set it to `false` if the extra narration annoys you: commentary then falls back to the reasoning channel and is only shown when `show_reasoning` is enabled.
## Privacy
```yaml
privacy:
redact_pii: false # Strip PII from LLM context (gateway only)
```
When `redact_pii` is `true`, the gateway redacts personally identifiable information from the system prompt before sending it to the LLM on supported platforms:
| Field | Treatment |
|-------|-----------|
| Phone numbers (user ID on WhatsApp/Signal) | Hashed to `user_<12-char-sha256>` |
| User IDs | Hashed to `user_<12-char-sha256>` |
| Chat IDs | Numeric portion hashed, platform prefix preserved (`telegram:`) |
| Home channel IDs | Numeric portion hashed |
| User names / usernames | **Not affected** (user-chosen, publicly visible) |
**Platform support:** Redaction applies to WhatsApp, Signal, and Telegram. Discord and Slack are excluded because their mention systems (`<@user_id>`) require the real ID in the LLM context.
Hashes are deterministic — the same user always maps to the same hash, so the model can still distinguish between users in group chats. Routing and delivery use the original values internally.
### OpenAI Codex request identity
OpenAI requires third-party Codex harnesses to identify themselves.
ChatGPT-authenticated requests to the official Codex endpoint automatically
send `originator: hermes-agent` and `User-Agent: HermesAgent/`.
The existing ChatGPT account header is preserved. No additional prompt content
or telemetry request is sent.
Direct OpenAI API requests and custom proxy endpoints are unchanged.
## Speech-to-Text (STT)
```yaml
stt:
enabled: true # Auto-transcribe inbound voice messages (default: true)
echo_transcripts: true # Post raw transcripts back to the chat as 🎙️ "..." (default: true)
provider: "local" # "local" | "groq" | "openai" | "mistral" | "xai" | "elevenlabs" | "deepinfra" | ...
language: "en" # GLOBAL language hint for every provider (per-provider language wins); set "" for auto-detect
cloud_trim_silence: true # trim long pauses with ffmpeg before uploading to a cloud provider (default: true)
cloud_trim_threshold_db: -40 # audio quieter than this counts as silence
cloud_trim_keep_ms: 300 # how much of each pause survives the trim (keeps natural pacing)
# prompt: "Hermes, Teknium, Nous Research, kanban" # Static vocabulary hint (see below)
local:
model: "base" # tiny, base, small, medium, large-v3
language: "" # per-provider override of stt.language
initial_prompt: "" # optional whisper prompt to bias vocabulary/script (e.g. Simplified Chinese)
vad: true # Silero VAD filter (default on) — silence never reaches whisper; false = raw behavior (music/ambient)
vad_min_silence_ms: 500 # min silence (ms) that splits speech chunks when vad is on
no_speech_prob_threshold: 0.6 # drop a segment only when no_speech_prob > this...
logprob_threshold: -1.0 # ...AND avg_logprob < this (both must hit — quiet real speech survives)
unload_after_idle_seconds: 0 # 0=never unload (default); e.g. 300 = release the model after 5min idle
groq:
language: "" # per-provider override of stt.language
openai:
model: "whisper-1" # whisper-1 | gpt-4o-mini-transcribe | gpt-4o-transcribe | gpt-transcribe
language: "" # per-provider override of stt.language
timeout: 60 # seconds per transcription request; raise for self-hosted model cold starts
max_retries: 1 # SDK transport retries (connection errors, 408/409/429/5xx); 0 = single attempt
# model: "whisper-1" # Legacy fallback key still respected
```
Language resolution is the same for **every** STT provider (local, groq, openai, mistral, xai, elevenlabs, deepinfra, command providers, and plugins): `stt..language` → `stt.language` → `HERMES_LOCAL_STT_LANGUAGE` env var → provider auto-detect. **The default is `stt.language: "en"`** — Whisper auto-detection frequently misidentifies short or accented clips, which shows up as voice notes transcribed in the wrong language. Non-English speakers should set `stt.language` to their language code once (e.g. `"es"`, `"zh"`, `"uk"`); set it to `""` to restore auto-detection for multilingual use.
`stt.openai.timeout` and `stt.openai.max_retries` shape the OpenAI-SDK transcription client that the `openai`, `groq` and `deepinfra` providers share (there are no per-provider siblings yet, and the SDK reads no environment variables for these). The defaults are `60` / `1` rather than the previous fixed 30 s / no retries because a self-hosted OpenAI-compatible endpoint can take longer than 30 s to load its model on the first request, which used to lose that voice message outright. The trade-off: an unreachable backend now holds a voice message for up to two attempts × the timeout before Hermes gives up; set `timeout: 30` and `max_retries: 0` for the old shape.
Set `stt.echo_transcripts: false` when the gateway should transcribe voice notes for the agent but must not post the raw transcript back to the chat (for example, customer-facing WhatsApp bots).
Provider behavior:
- `local` uses `faster-whisper` running on your machine. Install it separately with `python -c "import pm; pm.sync_venv(['stt-whisper'], explicit=True)"`. Silence-hallucination hardening is on by default: a Silero VAD filter keeps silence/noise from ever reaching Whisper, cross-window conditioning is disabled, and segments the model itself flags as probably-not-speech *and* low-confidence are dropped. Set `stt.local.vad: false` to transcribe non-speech audio (music, ambient) with the raw behavior. The model stays loaded in memory between voice messages for low-latency transcription; set `stt.local.unload_after_idle_seconds` (e.g. `300` for 5 minutes) to automatically release the model when idle. This frees GPU memory on CUDA hosts (the main win when a local LLM shares the GPU); on CPU the memory becomes reusable by the process, though the OS-visible footprint may not shrink until the process needs the space for something else. The next voice message reloads the model transparently.
- `groq` uses Groq's Whisper-compatible endpoint and reads `GROQ_API_KEY`. Pass `stt.groq.language` (or the global `HERMES_LOCAL_STT_LANGUAGE` env var) to skip auto-detection and reduce latency.
- `openai` uses the OpenAI speech API and reads `VOICE_TOOLS_OPENAI_KEY`.
Cloud providers (groq, openai, mistral, xai, elevenlabs, deepinfra) get a **pre-upload silence trim** by default when `ffmpeg` is installed: long pauses in a voice note are collapsed client-side before the file uploads, keeping `cloud_trim_keep_ms` of each pause so natural pacing survives. Shorter audio means faster uploads, lower per-audio-minute billing, and fewer silence hallucinations from the remote model. Clips shorter than 12 seconds skip the trim entirely (savings can't matter there, and several providers bill a per-request minimum anyway). The trim is best-effort — if ffmpeg is missing, the trim fails, the clip is mostly silence, or trimming would save less than ~10%, the original file is uploaded untouched. Set `stt.cloud_trim_silence: false` to always upload the original (e.g. when transcribing music or ambient audio through a cloud provider). Command-type and plugin providers never get trimmed audio.
An explicitly selected `stt.provider` is honored strictly — if it's unavailable, transcription errors with guidance to run `hermes tools` rather than switching providers. Only when no provider has ever been selected does Hermes auto-detect in this order: `local` → `groq` → `openai`.
Groq and OpenAI model overrides are environment-driven:
```bash
STT_GROQ_MODEL=whisper-large-v3-turbo
STT_OPENAI_MODEL=whisper-1
GROQ_BASE_URL=https://api.groq.com/openai/v1
STT_OPENAI_BASE_URL=https://api.openai.com/v1
```
### Transcription prompt (vocabulary hints)
`stt.prompt` is an optional static hint passed to prompt-capable STT backends. Use it for proper nouns, product names, and jargon that Whisper-family models otherwise mis-hear:
```yaml
stt:
provider: "local"
prompt: "Hermes, Teknium, Nous Research, kanban, Ollama"
```
**Composition.** The config value is the base. Plugins that register the [`pre_transcription`](./features/hooks.md#pre_transcription) hook mutate on top of it, last-writer-wins per field. Multiple plugins' hints compose deterministically: plugin discovery loads plugins in sorted order by plugin id, and each plugin's callbacks run in its own registration order, so the same set of plugins always produces the same final prompt. A hook returning an empty string for `prompt` clears the config prompt for that request. Hooks may also override `language` and `model`; `file_path` is read-only and any attempt to change it is logged and dropped. With no hook registered and no `stt.prompt` set, the outgoing request is identical to previous releases.
**Provider support.**
| Provider | Prompt parameter | Behavior |
|----------|-----------------|----------|
| `local` (faster-whisper) | `initial_prompt` | Forwarded unchanged to the local model |
| `openai` | `prompt` | Forwarded unchanged in the transcription request |
| `groq` | `prompt` | Forwarded unchanged in the transcription request |
| `mistral` | `prompt` | Forwarded unchanged in the transcription request |
| `deepinfra` | `prompt` | OpenAI-compatible path, forwarded unchanged |
| `xai` | not supported | Logged at DEBUG, the request proceeds without the prompt |
| `elevenlabs` | not supported | Logged at DEBUG, the request proceeds without the prompt |
| `local_command` | not supported | Logged at DEBUG, the request proceeds without the prompt |
| `stt.providers.` with `type: command` | not supported | Logged at DEBUG, the request proceeds without the prompt |
| Plugin-registered providers | `prompt` in the `transcribe(**extra)` kwargs | Only sent when a prompt is set, so providers that predate this key see unchanged calls |
**Length.** Whisper-family models only condition on the final ~224 prompt tokens. For the whisper-family backends (`local`, `openai`, `groq`, `deepinfra`) Hermes enforces that cap client-side: an over-long final prompt is truncated to its tail with a logged warning — the request never errors because of prompt length. Other backends (`mistral`, plugin providers) receive the prompt unchanged and own their own validation. Keep hints short and specific either way.
:::warning Prompts are uploaded with your audio
The final prompt is sent to the configured STT provider alongside the audio file. Keep secrets and session-derived context out of `stt.prompt` and out of anything a `pre_transcription` hook returns, especially when the provider is a hosted API rather than local `faster-whisper`.
:::
## Voice Mode (CLI)
```yaml
voice:
record_key: "ctrl+b" # Push-to-talk key inside the CLI
max_recording_seconds: 120 # Hard stop for long recordings
auto_tts: false # Enable spoken replies automatically when /voice on
beep_enabled: true # Play record start/stop beeps in CLI voice mode
beep_volume: 0.3 # Beep amplitude (0.0-1.0); raise it on quiet systems / headphones
silence_threshold: 200 # RMS threshold for speech detection
silence_duration: 3.0 # Seconds of silence before auto-stop
```
Use `/voice on` in the CLI to enable microphone mode, `record_key` to start/stop recording, and `/voice tts` to toggle spoken replies. See [Voice Mode](./features/voice-mode.md) for end-to-end setup and platform-specific behavior.
## Streaming
Stream tokens to the terminal or messaging platforms as they arrive, instead of waiting for the full response.
### CLI Streaming
```yaml
display:
streaming: true # Stream tokens to terminal in real-time
show_reasoning: true # Also stream reasoning/thinking tokens (optional)
```
When enabled, responses appear token-by-token inside a streaming box. Tool calls are still captured silently. If the provider doesn't support streaming, it falls back to the normal display automatically.
### Gateway Streaming (Telegram, Discord, Slack)
```yaml
streaming:
enabled: true # Enable progressive message editing (default: false)
transport: auto # "auto" (default) | "edit" (progressive message editing) | "off"
edit_interval: 0.8 # Seconds between message edits (default: 0.8)
buffer_threshold: 24 # Characters before forcing an edit flush (default: 24)
cursor: " ▉" # Cursor shown during streaming
fresh_final_after_seconds: 0 # Opt in to fresh final (Telegram) when preview is this old
```
When enabled, the bot sends a message on the first token, then progressively edits it as more tokens arrive. Platforms that don't support message editing (Signal, Email, Home Assistant) are auto-detected on the first attempt — streaming is gracefully disabled for that session with no flood of messages.
For separate natural mid-turn assistant updates without progressive token editing, set `display.interim_assistant_messages: true`.
**Overflow handling:** If the streamed text exceeds the platform's message length limit (~4096 chars), the current message is finalized and a new one starts automatically.
**Fresh final (Telegram):** Telegram's `editMessageText` preserves the original message timestamp, so a long-running streamed reply would keep the first-token timestamp even after completion. Set `fresh_final_after_seconds > 0` to opt in to delivering old previews as brand-new final messages with best-effort preview deletion. The default is `0`, which always finalizes streamed replies in place and avoids the brief duplicate-message/delete sequence on clients that show both operations.
:::note Per-platform streaming defaults
The master `streaming.enabled` switch is `false` by default — nothing streams until you flip it. Once enabled, streaming is decided **per platform**: Telegram ships with `display.platforms.telegram.streaming: true` (streams) and Discord with `display.platforms.discord.streaming: false` (does not). So after enabling streaming, Telegram streams out of the box and Discord stays on whole-message replies until you change its toggle. You can adjust these per-platform switches from the dashboard's **Channels** toggles or directly in `~/.hermes/config.yaml`.
:::
## Group Chat Session Isolation
Limit how many chat sessions can actively be open across CLI, TUI/dashboard,
and messaging gateway:
```yaml
max_concurrent_sessions: null # null/0 = unlimited; positive integer = active session cap
```
A slot is taken when a session runs its **first turn**, not when a chat window
is opened. Opening, resuming or reconnecting to a chat costs nothing until you
send a message, so idle desktop tabs (and the background resumes a flaky
websocket triggers) cannot starve the messaging gateway that shares this cap.
When the cap is reached, Hermes returns a direct limit message naming which
surfaces hold the slots. Existing active sessions keep their normal behavior.
Run `hermes status` to see the current slot usage and every holder.
The canonical key is top-level `max_concurrent_sessions`. Hermes also accepts
`gateway.max_concurrent_sessions` as a fallback, but the top-level key wins when
both are set.
The cap is enforced with a local runtime lease file and is best-effort: Hermes
fails open if the registry cannot be read or locked so users are not stranded.
It is intended for a single host/profile runtime, not a shared `$HERMES_HOME`
mounted across multiple machines. A lease whose owning process exists but whose
liveness cannot be proved (for example an unreadable `/proc` entry inside a
container after `hermes update` restarts the backend) still counts toward the
cap and still fences its own session id, but it no longer blocks claiming or
releasing a different session.
Control whether shared chats keep one conversation per room or one conversation per participant:
```yaml
group_sessions_per_user: true # true = per-user isolation in groups/channels, false = one shared session per chat
```
- `true` is the default and recommended setting. In Discord channels, Telegram groups, Slack channels, and similar shared contexts, each sender gets their own session when the platform provides a user ID.
- `false` reverts to the old shared-room behavior. That can be useful if you explicitly want Hermes to treat a channel like one collaborative conversation, but it also means users share context, token costs, and interrupt state.
- Direct messages are unaffected. Hermes still keys DMs by chat/DM ID as usual.
- Threads stay isolated from their parent channel either way; with `true`, each participant also gets their own session inside the thread.
For the behavior details and examples, see [Sessions](./sessions.md) and the [Discord guide](./messaging/discord.md).
## Unauthorized DM Behavior
Control what Hermes does when an unknown user sends a direct message:
```yaml
unauthorized_dm_behavior: pair
whatsapp:
unauthorized_dm_behavior: ignore
```
- `pair` is the default for chat-style DM platforms. Hermes denies access, but replies with a one-time pairing code in DMs.
- `ignore` silently drops unauthorized DMs.
- `decline` sends one short, polite decline instead of a pairing code, then stays silent toward that sender for 24 hours. Override the default text:
```yaml
unauthorized_dm_behavior: decline
unauthorized_dm_decline_message: "Sorry, this assistant is private."
```
`hermes gateway setup` offers this as "Politely decline unknown senders" when you leave the allowlist empty; it writes `platforms..unauthorized_dm_behavior: decline`.
- Email defaults to `ignore` unless `platforms.email.unauthorized_dm_behavior: pair` is set, because inboxes can contain unrelated unread mail.
- Platform sections override the global default, so you can keep pairing enabled broadly while making one platform quieter.
## Quick Commands
Define custom commands that either run shell commands without invoking the LLM, or alias one slash command to another. Exec quick commands are zero-token and useful from messaging platforms (Telegram, Discord, etc.) for quick server checks or utility scripts.
```yaml
quick_commands:
status:
type: exec
command: systemctl status hermes-agent
disk:
type: exec
command: df -h /
update:
type: exec
command: hermes update
gpu:
type: exec
command: nvidia-smi --query-gpu=name,utilization.gpu,memory.used,memory.total --format=csv,noheader
restart:
type: alias
target: /gateway restart
```
Usage: type `/status`, `/disk`, `/update`, `/gpu`, or `/restart` in the CLI or any messaging platform. `exec` commands run locally on the host and return the output directly — no LLM call, no tokens consumed. `alias` commands rewrite to the configured slash command target.
- **30-second timeout** — long-running commands are killed with an error message
- **Priority** — quick commands are checked before skill commands, so you can override skill names
- **Autocomplete** — quick commands are resolved at dispatch time and are not shown in the built-in slash-command autocomplete tables
- **Type** — supported types are `exec` and `alias`; other types show an error
- **Works everywhere** — CLI, Telegram, Discord, Slack, WhatsApp, Signal, Email, Home Assistant
String-only prompt shortcuts are not valid quick commands. For reusable prompt workflows, create a skill or alias to an existing slash command.
## Human Delay
Simulate human-like response pacing in messaging platforms:
```yaml
human_delay:
mode: "off" # off | natural | custom
min_ms: 800 # Minimum delay (custom mode)
max_ms: 2500 # Maximum delay (custom mode)
```
Each profile's own `config.yaml` is read, so multiplexed profiles keep independent pacing; there is no process-environment override. In `custom` mode a non-integer, negative or inverted `min_ms`/`max_ms` pair is rejected with a warning naming the key and the `natural` range (800–2500 ms) is used instead.
## Code Execution
Configure the `execute_code` tool:
```yaml
code_execution:
mode: project # project (default) | strict
timeout: 300 # Max execution time in seconds
max_tool_calls: 50 # Max tool calls within code execution
```
**`mode`** controls the working directory and Python interpreter for scripts:
- **`project`** (default) — scripts run in the session's working directory with the active virtualenv/conda env's python. Project deps (`pandas`, `torch`, project packages) and relative paths (`.env`, `./data.csv`) resolve naturally, matching what `terminal()` sees.
- **`strict`** — scripts run in a temp staging directory with `sys.executable` (Hermes's own python). Maximum reproducibility, but project deps and relative paths won't resolve.
Environment scrubbing (strips `*_API_KEY`, `*_TOKEN`, `*_SECRET`, `*_PASSWORD`, `*_CREDENTIAL`, `*_PASSWD`, `*_AUTH`) and the tool whitelist apply identically in both modes — switching mode does not change the security posture.
## Web Search Backends
The `web_search` and `web_extract` tools support five backend providers. Configure the backend in `config.yaml` or via `hermes tools`:
```yaml
web:
backend: firecrawl # firecrawl | searxng | parallel | tavily | perplexity | keenable | exa
# Or use per-capability keys to mix providers (e.g. free search + paid extract):
search_backend: "searxng"
extract_backend: "firecrawl"
# Keyless free-tier fallback (default: true). With no backend configured
# and no API keys present, web tools rotate across the Exa/Parallel/
# Firecrawl/Keenable free tiers. Set false to disable.
keyless_fallback: true
# One-shot keyless rescue (default: true). When the chosen/keyed backend
# fails a call, that single call retries on the keyless ring; the next
# call attempts the chosen backend again (never sticky).
keyless_rescue: true
# Pin Exa/Parallel to a tier (set by the hermes tools Free/Paid rows).
# free = always the anonymous endpoint; paid = always the keyed SDK path;
# unset = auto (key present -> paid, otherwise free).
provider_tier:
parallel: free
exa: paid
```
| Backend | Env Var | Search | Extract |
|---------|---------|--------|---------|
| **Firecrawl** (default) | `FIRECRAWL_API_KEY` | ✔ | ✔ |
| **SearXNG** | `SEARXNG_URL` | ✔ | — |
| **Parallel** | `PARALLEL_API_KEY` (optional — keyless free tier) | ✔ | ✔ |
| **Tavily** | `TAVILY_API_KEY` (optional — keyless when selected) | ✔ | ✔ |
| **Perplexity** | `PERPLEXITY_API_KEY` | ✔ | ✔ (query-relevant snippets) |
| **Exa** | `EXA_API_KEY` (optional — keyless free tier) | ✔ | ✔ |
**Backend selection:** The runtime always uses the stored `web.backend` selection (set via `hermes tools`; `nous` routes through the managed Tool Gateway). Only if no web backend has ever been selected is one auto-detected from available API keys: if only `SEARXNG_URL` is set, SearXNG is used; if only `EXA_API_KEY` is set, Exa; if only `TAVILY_API_KEY` is set, Tavily; if only `PERPLEXITY_API_KEY` is set, Perplexity; if only `PARALLEL_API_KEY` is set, Parallel; if only `KEENABLE_API_KEY` is set, Keenable. With **no selection and no credentials at all**, requests rotate round-robin across the keyless free-tier ring (Exa / Parallel / Firecrawl / Keenable) with automatic next-in-line failover on rate limits — see the [Web Search guide](./features/web-search.md) for details. Once a selection exists, adding a key to `.env` does not change the route. Selecting Tavily, Firecrawl, or Keenable in `hermes tools` also works without a key.
**SearXNG** is a free, self-hosted, privacy-respecting metasearch engine that queries 70+ search engines. No API key needed — just set `SEARXNG_URL` to your instance (e.g., `http://localhost:8080`). SearXNG is search-only; `web_extract` requires a separate extract provider (set `web.extract_backend`). See the [Web Search setup guide](./features/web-search.md) for Docker setup instructions.
**Self-hosted Firecrawl:** Set `FIRECRAWL_API_URL` to point at your own instance. When a custom URL is set, the API key becomes optional (set `USE_DB_AUTHENTICATION=*** on the server to disable auth).
**Parallel search modes:** Set `PARALLEL_SEARCH_MODE` to control search behavior — `fast`, `one-shot`, or `agentic` (default: `agentic`).
**Exa:** Set `EXA_API_KEY` in `~/.hermes/.env`. Supports `category` filtering (`company`, `research paper`, `news`, `people`, `personal site`, `pdf`) and domain/date filters.
## Browser
Configure browser automation behavior:
```yaml
browser:
inactivity_timeout: 120 # Seconds before auto-closing idle sessions
command_timeout: 30 # Timeout in seconds for browser commands (screenshot, navigate, etc.)
record_sessions: false # Auto-record browser sessions as WebM videos to ~/.hermes/browser_recordings/
# Optional CDP override — when set, Hermes attaches directly to your own
# Chromium-family browser (via /browser connect) rather than starting a headless browser.
cdp_url: ""
# Dialog supervisor — controls how native JS dialogs (alert / confirm / prompt)
# are handled when a CDP backend is attached (Browserbase, local Chromium-family
# browser via /browser connect). Ignored on Camofox and default local agent-browser mode.
dialog_policy: must_respond # must_respond | auto_dismiss | auto_accept
dialog_timeout_s: 300 # Safety auto-dismiss under must_respond (seconds)
camofox:
managed_persistence: false # When true, Camofox sessions persist cookies/logins across restarts
user_id: "" # Optional externally managed Camofox userId
session_key: "" # Optional session key sent when Hermes creates a tab
adopt_existing_tab: false # Reuse an existing tab for this identity before creating one
```
**Dialog policies:**
- `must_respond` (default) — capture the dialog, surface it in `browser_snapshot.pending_dialogs`, and wait for the agent to call `browser_dialog(action=...)`. After `dialog_timeout_s` seconds with no response, the dialog is auto-dismissed to prevent the page's JS thread from stalling forever.
- `auto_dismiss` — capture, dismiss immediately. The agent still sees the dialog record in `browser_snapshot.recent_dialogs` with `closed_by="auto_policy"` after the fact.
- `auto_accept` — capture, accept immediately. Useful for pages with aggressive `beforeunload` prompts.
See the [browser feature page](./features/browser.md#browser_dialog) for the full dialog workflow.
The browser toolset supports multiple providers. See the [Browser feature page](./features/browser.md) for details on Browserbase, Browser Use, and local Chromium-family CDP setup.
## Timezone
Override the server-local timezone with an IANA timezone string. Affects timestamps in logs, cron scheduling, and system prompt time injection.
```yaml
timezone: "America/New_York" # IANA timezone (default: "" = server-local time)
```
Supported values: any IANA timezone identifier (e.g. `America/New_York`, `Europe/London`, `Asia/Kolkata`, `UTC`). Leave empty or omit for server-local time.
`hermes doctor` (and the startup config check) reports a value the runtime cannot load — a typo such as `Asia/Tokio` would otherwise silently put the agent clock and every cron schedule on server-local time. `HERMES_TIMEZONE` overrides this key when set.
The agent clock, cron schedules and time-aware tools follow this zone on every OS. Code run through `execute_code` also inherits it as `TZ` on Linux and macOS; on Windows those children keep the OS-configured zone instead (the Windows C runtime only parses POSIX-form `TZ` strings, and an IANA name there produces a wrong UTC offset), so set the Windows zone itself when child scripts must render local time in this zone.
## Discord
Configure Discord-specific behavior for the messaging gateway:
```yaml
discord:
require_mention: true # Require @mention to respond in server channels
free_response_channels: "" # Comma-separated channel IDs where bot responds without @mention
auto_thread: true # Auto-create threads on @mention in channels
free_response_auto_thread: false # Free-response channels also auto-thread (default: reply inline)
```
- `require_mention` — when `true` (default), the bot only responds in server channels when mentioned with `@BotName`. DMs always work without mention.
- `free_response_channels` — comma-separated list of channel IDs where the bot responds to every message without requiring a mention.
- `auto_thread` — when `true` (default), mentions in channels automatically create a thread for the conversation, keeping channels clean (similar to Slack threading).
- `free_response_auto_thread` — when `true`, channels in `free_response_channels` also auto-create a thread per top-level message. Default `false`: free-response channels reply inline. Requires `auto_thread: true`.
## Security
Pre-execution security scanning and secret redaction:
```yaml
security:
redact_secrets: true # Redact API key patterns in tool output and logs (on by default)
tirith_enabled: true # Enable Tirith security scanning for terminal commands
tirith_path: "tirith" # Path to tirith binary (default: "tirith" in $PATH)
tirith_timeout: 5 # Seconds to wait for tirith scan before timing out
tirith_fail_open: true # Allow command execution if tirith is unavailable
website_blocklist: # See Website Blocklist section below
enabled: false
domains: []
shared_files: []
```
- `redact_secrets` — when `true`, automatically detects and redacts patterns that look like API keys, tokens, and passwords in tool output before it enters the conversation context and logs. **On by default**. Set to `false` explicitly only when you need raw credential-like strings for debugging or redactor development. Reading a secret-bearing file (`.env`-style files, shell rc/profile files, the Hermes `config.yaml` under `HERMES_HOME` and its `backups/config/` copies) with `read_file`, `search_files` or a terminal `cat`/`grep` also masks credential-shaped assignments (`SOME_API_TOKEN: …`) with a non-reusable `«redacted-secret»` marker, whatever the value looks like; ordinary source and project config files keep only the vendor-prefix patterns so fixtures such as `MAX_TOKENS: 100` are never mangled.
- `tirith_enabled` — when `true`, terminal commands are scanned by [Tirith](https://github.com/sheeki03/tirith) before execution to detect potentially dangerous operations.
- `tirith_path` — path to the tirith binary. Set this if tirith is installed in a non-standard location.
- `tirith_timeout` — maximum seconds to wait for a tirith scan. Commands proceed if the scan times out.
- `tirith_fail_open` — when `true` (default), commands are allowed to execute if tirith is unavailable or fails. Set to `false` to block commands when tirith cannot verify them.
## Website Blocklist
Block specific domains from being accessed by the agent's web and browser tools:
```yaml
security:
website_blocklist:
enabled: false # Enable URL blocking (default: false)
domains: # List of blocked domain patterns
- "*.internal.company.com"
- "admin.example.com"
- "*.local"
shared_files: # Load additional rules from external files
- "/etc/hermes/blocked-sites.txt"
```
When enabled, any URL matching a blocked domain pattern is rejected before the web or browser tool executes. This applies to `web_search`, `web_extract`, `browser_navigate`, and any tool that accesses URLs.
Domain rules support:
- Exact domains: `admin.example.com`
- Wildcard subdomains: `*.internal.company.com` (blocks all subdomains)
- TLD wildcards: `*.local`
Shared files contain one domain rule per line (blank lines and `#` comments are ignored). Missing or unreadable files log a warning but don't disable other web tools.
The policy is cached for 30 seconds, so config changes take effect quickly without restart.
## Smart Approvals
Control how Hermes handles potentially dangerous commands:
```yaml
approvals:
mode: smart # smart | manual | off
```
| Mode | Behavior |
|------|----------|
| `smart` (default) | Use an auxiliary LLM to assess whether a flagged command is actually dangerous. Low-risk commands are auto-approved for that command only. Genuinely risky commands are denied; uncertain decisions escalate to the user. |
| `manual` | Prompt the user before executing any flagged command. In the CLI, shows an interactive approval dialog. In messaging, queues a pending approval request. |
| `off` | Skip all approval checks. Equivalent to `HERMES_YOLO_MODE=true`. **Use with caution.** |
Smart mode is particularly useful for reducing approval fatigue — it lets the agent work more autonomously on safe operations while still catching genuinely destructive commands.
:::warning
Setting `approvals.mode: off` disables all safety checks for terminal commands. Only use this in trusted, sandboxed environments.
:::
### Denial circuit breaker
`approvals.denial_breaker_threshold` (default `3`) guards against the agent retrying variations of a command the smart-approval reviewer keeps denying — each retry burns another guardian LLM call. After that many consecutive denials in a session, the deny message escalates to a hard-stop instruction telling the agent to stop, report the blocked operation, and ask you to run it manually or `/approve`. Any approval resets the count; set `0` to disable:
```yaml
approvals:
denial_breaker_threshold: 3 # 0 disables the breaker
```
### Deny rules
`approvals.deny` is a list of glob patterns that block matching terminal commands unconditionally — even under `--yolo`, `/yolo`, or `mode: off`. It's the user-editable counterpart to the built-in hardline blocklist:
```yaml
approvals:
deny:
- "git push --force*"
- "*curl*|*sh*"
```
Patterns are case-insensitive fnmatch globs and must be quoted in YAML (a bare leading `*` is a parse error). See [Security — User-Defined Deny Rules](./security.md#user-defined-deny-rules-approvalsdeny) for details.
### Custom smart-approval policy
`approvals.smart_policy` lets you append your own rules to the smart-approval reviewer's instructions. When set, the text is added to the guardian LLM's system prompt (the trusted channel — never alongside the untrusted command text), so you can tighten or relax its judgment for your environment without editing code:
```yaml
approvals:
smart_policy: |
Always ESCALATE commands that modify anything under /etc.
APPROVE docker compose restarts in ~/deploys — they are routine here.
```
## Checkpoints
Automatic filesystem snapshots before destructive file operations. See the [Checkpoints & Rollback](./checkpoints-and-rollback.md) for details.
```yaml
checkpoints:
enabled: false # Enable automatic checkpoints (also: hermes chat --checkpoints). Default: false (opt-in).
max_snapshots: 20 # Max checkpoints to keep per directory (default: 20)
```
## Delegation
Configure subagent behavior for the delegate tool:
```yaml
delegation:
# model: "google/gemini-3-flash-preview" # Override model (empty = inherit parent)
# provider: "openrouter" # Override provider (empty = inherit parent)
# base_url: "http://localhost:1234/v1" # Direct OpenAI-compatible endpoint (takes precedence over provider)
# api_key: "local-key" # API key for base_url (falls back to OPENAI_API_KEY)
# api_mode: "" # Wire protocol for base_url: "chat_completions", "codex_responses", or "anthropic_messages". Empty = auto-detect from URL (e.g. /anthropic suffix → anthropic_messages). Set explicitly for non-standard endpoints the heuristic can't detect.
compression_threshold_tokens: 0 # Optional absolute cap on a subagent's compaction trigger (>= 16000); 0 = off, children use the ratio threshold
# request_overrides: # Per-child request settings sent on every subagent API call (all resolution branches).
# extra_body: # Merged into the request's extra_body — e.g. OpenRouter routing hints:
# provider:
# sort: throughput
max_concurrent_children: 3 # Parallel children per batch (floor 1, no ceiling). Also via DELEGATION_MAX_CONCURRENT_CHILDREN env var.
worktree_isolation: false # Give each child its own git worktree branched from HEAD (local backend + git repos only; inspired by Muse Code). See Subagent Delegation → Worktree Isolation.
max_spawn_depth: 1 # Delegation tree depth cap (1-3, clamped). 1 = flat (default): parent spawns leaves that cannot delegate. 2 = orchestrator children can spawn leaf grandchildren. 3 = three levels.
orchestrator_enabled: true # Global kill switch. When false, role="orchestrator" is ignored and every child is forced to leaf regardless of max_spawn_depth.
oneshot_max_children: 2 # Total subagents a one-shot run (hermes chat -q / --oneshot) may spawn; 0 = unlimited. Interactive and gateway sessions are never capped by this.
```
**Subagent provider:model override:** By default, subagents inherit the parent agent's provider and model. Set `delegation.provider` and `delegation.model` to route subagents to a different provider:model pair — e.g., use a cheap/fast model for narrowly-scoped subtasks while your primary agent runs an expensive reasoning model.
**Subagent fallback chain:** Set `delegation.fallback_providers` to give workers their own chain (same entry shape as the top-level list). An explicitly pinned child (by provider, endpoint, or model) uses that chain only when it is declared; otherwise it fails loudly instead of borrowing the parent agent's route. For an unpinned child, an absent or `null` setting preserves parent-chain inheritance. Use `fallback_providers: []` under `delegation:` to disable child fallback entirely.
**Direct endpoint override:** If you want the obvious custom-endpoint path, set `delegation.base_url`, `delegation.api_key`, and `delegation.model`. That sends subagents directly to that OpenAI-compatible endpoint and takes precedence over `delegation.provider`. If `delegation.api_key` is omitted, Hermes falls back to `OPENAI_API_KEY` only. When `delegation.provider` is set alongside `delegation.base_url`, the explicit endpoint and key still win, but that provider's request settings (`extra_body` overrides and max output tokens from your `custom_providers` entry) are carried into the subagent.
**Per-child request settings (`request_overrides`):** `delegation.request_overrides` is a dict of request settings sent on every subagent API call. Top-level keys are API kwargs (e.g. `service_tier`); an `extra_body` sub-dict is merged into the request's `extra_body`. It is honored on **all three** resolution branches — direct `base_url`, named `provider`, and pure inherit — so the key always takes effect. Precedence: explicit `request_overrides` values merge **over** any runtime- or parent-derived overrides — top-level explicit keys win, and `extra_body` is deep-merged one level so runtime `extra_body` keys (e.g. a provider's `thinking: {type: disabled}` personality) survive unless your key redefines them. The canonical use case is OpenRouter routing hints for delegation children:
```yaml
delegation:
model: "deepseek/deepseek-v4-flash-0731"
base_url: "https://openrouter.ai/api/v1"
api_key: "sk-or-..."
request_overrides:
extra_body:
provider:
sort: throughput # route children to the fastest OpenRouter provider
```
**Wire protocol (`api_mode`):** Hermes auto-detects the wire protocol from `delegation.base_url` (e.g. paths ending in `/anthropic` → `anthropic_messages`; Codex / native Anthropic / Kimi-coding hostnames keep their existing detection). For endpoints the heuristic can't classify — for example Azure AI Foundry, MiniMax, Zhipu GLM, or LiteLLM proxies fronting an Anthropic-shaped backend — set `delegation.api_mode` explicitly to one of `chat_completions`, `codex_responses`, or `anthropic_messages`. Leave it empty (the default) to keep auto-detection.
The delegation provider uses the same credential resolution as CLI/gateway startup. All configured providers are supported: `openrouter`, `nous`, `copilot`, `zai`, `kimi-coding`, `minimax`, `minimax-cn`. When a provider is set, the system automatically resolves the correct base URL, API key, and API mode — no manual credential wiring needed.
**Precedence:** `delegation.base_url` in config → `delegation.provider` in config → parent provider (inherited). `delegation.model` in config → parent model (inherited). Setting just `model` without `provider` changes only the model name while keeping the parent's credentials (useful for switching models within the same provider like OpenRouter).
**One-shot runs:** a finite `hermes chat -q` / `--oneshot` session has no later turn to consume delegated results and no later session to learn for, so it runs a smaller footprint: `skill_manage` is not offered (skills are still listed and loadable with `skill_view`), the skills prompt asks for domain skills only rather than process skills, and `oneshot_max_children` caps the total subagents the run may spawn (default `2`, `0` = unlimited). Past the cap `delegate_task` returns a tool error telling the agent to finish inline.
**Width and depth:** `max_concurrent_children` caps how many subagents run in parallel per batch (default `3`, floor of 1, no ceiling). Can also be set via the `DELEGATION_MAX_CONCURRENT_CHILDREN` env var. When the model submits a `tasks` array longer than the cap, `delegate_task` returns a tool error explaining the limit rather than silently truncating. `max_spawn_depth` controls the delegation tree depth (clamped to 1-3). At the default `1`, delegation is flat: children cannot spawn grandchildren, and passing `role="orchestrator"` silently degrades to `leaf`. Raise to `2` so orchestrator children can spawn leaf grandchildren; `3` for three-level trees. The agent opts into orchestration per call via `role="orchestrator"`; `orchestrator_enabled: false` forces every child back to leaf regardless. Cost scales multiplicatively — at `max_spawn_depth: 3` with `max_concurrent_children: 3`, the tree can reach 3×3×3 = 27 concurrent leaf agents. See [Subagent Delegation → Depth Limit and Nested Orchestration](features/delegation.md#depth-limit-and-nested-orchestration) for usage patterns.
**Child process notifications:** background processes started by subagents route their completion/watch notifications to the parent conversation, but those are **suppressed** there by default — the child's consolidated result is the deliverable. Set `delegation.surface_child_process_notifications: true` to deliver them (with subagent attribution). Delegation results themselves are never suppressed. See [Subagent Delegation → Child background-process notifications](features/delegation.md#child-background-process-notifications).
## Clarify
Configure how long Hermes waits for a response to a clarifying question. One value covers every surface — the classic CLI modal, the TUI/Desktop card, and the messaging gateway. The canonical key is `agent.clarify_timeout` (default `3600` seconds; `0` or less = unlimited); a legacy top-level `clarify.timeout` is still honored if explicitly set:
```yaml
agent:
clarify_timeout: 3600 # Seconds to wait for user clarification response (0 or less = unlimited)
```
When the timeout expires, the agent unblocks with a "user did not respond" sentinel and continues on its own. A clarify prompt is never cut by the generic per-tool deadline (`timeouts.tools.sequential_call`); only `agent.clarify_timeout` bounds the wait.
## Context Files (SOUL.md, AGENTS.md)
Hermes uses two different context scopes:
| File | Purpose | Scope |
|------|---------|-------|
| `SOUL.md` | **Primary agent identity** — defines who the agent is (slot #1 in the system prompt) | `~/.hermes/SOUL.md` or `$HERMES_HOME/SOUL.md` |
| `.hermes.md` / `HERMES.md` | Project-specific instructions (highest priority) | Walks to git root |
| `AGENTS.md` | Project-specific instructions, coding conventions | Recursive directory walk |
| `CLAUDE.md` | Claude Code context files (also detected) | Working directory only |
| `.cursorrules` | Cursor IDE rules (also detected) | Working directory only |
| `.cursor/rules/*.mdc` | Cursor rule files (also detected) | Working directory only |
- **SOUL.md** is the agent's primary identity. It occupies slot #1 in the system prompt, completely replacing the built-in default identity. Edit it to fully customize who the agent is.
- If SOUL.md is missing, empty, or cannot be loaded, Hermes falls back to a built-in default identity.
- **Project context files use a priority system** — only ONE type is loaded (first match wins): `.hermes.md` → `AGENTS.md` → `CLAUDE.md` → `.cursorrules`. SOUL.md is always loaded independently.
- **AGENTS.md** is hierarchical: if subdirectories also have AGENTS.md, all are combined.
- Hermes automatically seeds a default `SOUL.md` if one does not already exist.
- All loaded context files are capped at `context_file_max_chars` characters (default 20,000) with smart truncation.
See also:
- [Personality & SOUL.md](./features/personality.md)
- [Context Files](./features/context-files.md)
## Working Directory
| Context | Default |
|---------|---------|
| **CLI (`hermes`)** | Current directory where you run the command |
| **Messaging gateway** | `terminal.cwd` from `~/.hermes/config.yaml`; if unset, home directory `~` |
| **Docker / Singularity / Modal / SSH** | User's home directory inside the container or remote machine |
Override the working directory:
```yaml
# In ~/.hermes/config.yaml:
terminal:
cwd: /home/myuser/projects
```
`MESSAGING_CWD` and direct `TERMINAL_CWD` entries in `~/.hermes/.env` are legacy compatibility fallbacks. New configurations should use `terminal.cwd`.
## Network
Connectivity workarounds for outbound HTTP:
```yaml
network:
force_ipv4: false # Force IPv4 for outbound connections (default: false)
```
`force_ipv4` — on servers with broken or unreachable IPv6, Python resolves AAAA records first and can hang for the full TCP timeout before falling back to IPv4. Hermes already races IPv6 and IPv4 for every outbound connection it makes (Happy Eyeballs, RFC 8305: the IPv4 attempt starts 250 ms after IPv6 and whichever connects first wins), so an advertised-but-blackholed IPv6 route costs about a quarter second per connection instead of the full timeout. This covers the gateway's WebSocket dials (relay connector, platform adapters) as well as HTTP. Set this to `true` only when you want to skip IPv6 entirely and connect over IPv4 directly. `hermes doctor` runs an `IPv6 route` check that detects a dead IPv6 path and points at this setting.
## Onboarding
First-touch onboarding hints and the structured profile-build offer:
```yaml
onboarding:
profile_build: "ask" # "ask" (default) | "off"
seen: {} # internal latch — leave empty
```
- `profile_build` — controls the profile-build path offered on the very first gateway message ever. `"ask"` (default) offers to build a user profile; the offer is **opt-in and consent-gated** — the agent asks before any lookup and never reads connected accounts silently. `"off"` shows a plain intro only. The offer fires at most once.
- `seen` — internal state. Hermes latches each shown hint here so it never fires again; the profile-build offer is also recorded here once shown. Don't hand-edit it — wipe the whole `onboarding` section if you want to re-see all hints.
## Dashboard
Configuration for the [web dashboard](./features/web-dashboard.md) — visual theme, public URL, and authentication providers. The auth providers (OAuth, basic password, drain) are documented in detail on the web-dashboard page; this is the `config.yaml` shape.
```yaml
dashboard:
theme: "default" # "default" | "midnight" | "ember" | "mono" | "cyberpunk" | "rose"
show_token_analytics: false # Re-enable the (local-estimate-only) token/cost analytics surfaces
public_url: "" # Full public authority for OAuth redirect_uri (env: HERMES_DASHBOARD_PUBLIC_URL)
trusted_proxies: [] # Proxy IPs/CIDRs allowed to supply X-Forwarded-* headers
oauth: # Portal OAuth gate (engaged with --host and not --insecure)
client_id: "" # agent:{instance_id} — Portal provisions this
portal_url: "" # blank → plugin default (production Portal)
basic_auth: # Self-hosted username/password gate (dashboard_auth/basic plugin)
username: "" # blank → plugin no-op
password_hash: "" # scrypt$... (preferred — no plaintext at rest)
password: "" # plaintext fallback (hashed in-memory at load)
secret: "" # token-signing key; blank → random per-process
session_ttl_seconds: 0 # 0 → plugin default (12h)
drain_auth: # Drain-control service-credential gate (dashboard_auth/drain plugin)
scope: "drain" # capability label on the verified principal
min_secret_chars: 43 # entropy bar (url-safe-b64 chars; 43 ≈ 256 bits)
ws_ping_interval: 20.0 # Non-loopback WebSocket keepalive ping interval (seconds)
ws_ping_timeout: 20.0 # Non-loopback WebSocket keepalive pong timeout (seconds)
ws_orphan_reap_grace_s: 20.0 # Grace before a WS-detached session is reaped (seconds)
ssh_isolated_idle_grace_s: 900.0 # Desktop-over-SSH backend exits after this long with no client and no running turn
ws_orphan_activity_stale_s: 600.0 # Activity idle bound before a detached RUNNING turn is interrupted (seconds)
startup_orphan_sweep: true # Close session rows orphaned by a dead gateway process at boot
```
- `theme` — dashboard visual theme.
- `show_token_analytics` — off by default. The Analytics page and token/cost figures are a **local lower-bound estimate** (they exclude auxiliary calls, retries, fallbacks, and cache writes), so they can read far below the provider bill. Set `true` only if you understand they're not billing.
- `public_url` — when set, this is the complete authority (scheme + host + optional path prefix) the OAuth `redirect_uri` is built from. Set it for deploys behind reverse proxies that don't reliably forward `X-Forwarded-*` headers. Leave empty to use proxy-header reconstruction.
- `trusted_proxies` — IP addresses or bounded CIDR networks allowed to supply `X-Forwarded-Proto` and `X-Forwarded-For`. Loopback remains trusted automatically. Configure this when the TLS reverse proxy connects from another container or host. Prefer the proxy's exact IP; use a small dedicated network only when its address is dynamic. Wildcards and `/0` networks are rejected.
- `oauth` / `basic_auth` / `drain_auth` — auth provider config read by the bundled dashboard-auth plugins. The drain secret itself is **not** set here; it's provisioned via the `HERMES_DASHBOARD_DRAIN_SECRET` env var. See [Web Dashboard](./features/web-dashboard.md) for full auth setup.
- `ws_ping_interval` / `ws_ping_timeout` — WebSocket keepalive tuning for non-loopback binds (loopback connections never ping). Raise these on high-latency links (Tailscale, distant SSH tunnels) where the 20 s defaults can manufacture spurious 1006 disconnects.
- `ssh_isolated_idle_grace_s` (default `900`) — a Desktop-owned `hermes serve --isolated` backend reached over SSH is detached from the SSH session on purpose, so a laptop that sleeps mid-connection cannot tear it down; each dark-wake reconnect used to leave another backend holding `state.db`. The backend now retires itself once no client WebSocket has been connected for this long and no agent turn is running (a turn keeps it alive; an unreadable turn state keeps it alive too). Set high if you rely on a detached backend finishing long work after the laptop sleeps. Such backends also send a slow WebSocket ping (60 s, 10 min timeout) so a half-open tunnel is noticed.
- `ws_orphan_reap_grace_s` — how long a WS-detached session waits before the orphan reaper collects it. Raise alongside the keepalive values if clients reconnect slowly. Periodic session maintenance also completes cleanup for closed sockets and re-arms a missing orphan timer, so a detached chat cannot keep its ownership lease solely because its initial cleanup or timer was lost. Reconnecting cancels that timer; active delegated work and healthy running turns remain protected by the normal orphan-reaper checks. (`HERMES_TUI_WS_ORPHAN_REAP_GRACE_S` remains as an internal override.)
- `ws_orphan_activity_stale_s` (default `600`) — how long a detached **running** turn's activity clock (the same clock the `agent.turn_liveness` watchdog samples: API waits, stream tokens, tool heartbeats) must be idle before the orphan reaper interrupts it. A client-absent turn that is still actively producing keeps running to completion detached — closing the laptop, backgrounding the mobile app, or a desktop update no longer cancels healthy long turns; only a genuinely wedged turn is interrupted. Set `0` to interrupt at the grace window regardless of activity (old behavior).
- `startup_orphan_sweep` (default `true`) — the WS-orphan reap timer above is in-process, so a gateway restart (update, crash, systemd) before it fires leaves the session row open forever — phantom "active" work in `/resume` and dashboards. On every gateway boot — both the stdio TUI (`entry.main`) and the desktop/dashboard WebSocket sidecar (`handle_ws`) — rows with source `tui` / `desktop` / `subagent` / `unknown` (a row the token-accounting guard had to materialize itself) whose start time **and** newest message are both older than the session TTL (`HERMES_TUI_SESSION_TTL_S`, default 6 hours) are closed with `end_reason: startup_orphan_reap`. Messaging-platform sessions (Telegram, Discord, …) are never touched, live in-memory sessions (a client that already resumed) are excluded, and swept sessions remain resumable.
---
# Configuring Models
# Configuring Models
Hermes uses two kinds of model slots:
- **Main model** — what the agent thinks with. Every user message, every tool-call loop, every streamed response goes through this model.
- **Auxiliary models** — smaller side-jobs the agent offloads. Context compression, vision (image analysis), web-page summarization, approval scoring, MCP tool routing, session-title generation, and skill search. Each has its own slot and can be overridden independently.
This page covers configuring both from the dashboard. If you prefer config files or the CLI, jump to [Alternative methods](#alternative-methods) at the bottom. To run models on your own machine instead of a cloud provider, see [Local Models](./local-models.md).
:::tip Fastest path: Nous Portal
[Nous Portal](./features/tool-gateway.md) provides 300+ models under one subscription. On a fresh install, run `hermes setup --portal` to log in and set Nous as your provider in one command. Inspect what's wired up with `hermes portal info`.
- Portal subscribers also get **10% off token-billed providers**.
:::
:::note `model:` schema — empty string vs. mapping
On a brand-new install the bundled default config has `model: ""` (an empty string sentinel meaning "not configured yet"). The first time you run `hermes setup` or `hermes model`, that key is upgraded in-place to a mapping with `provider`, `default`, `base_url`, and `api_mode` sub-keys — the shape shown throughout this page and in [`profiles.md`](./profiles.md) / [`configuration.md`](./configuration.md). If you ever see an empty string in `config.yaml`, run `hermes model` (or click **Change** in the dashboard) and Hermes will write the dict form for you.
:::
## The Models page
Open the dashboard and click **Models** in the sidebar. You get two sections:
1. **Model Settings** — the top panel, where you assign models to slots.
2. **Usage analytics** — ranked cards showing every model that ran a session in the selected period, with token counts, cost, and capability badges.

The top card is the **Model Settings** panel. The main row always shows what the agent will spin up for new sessions. Click **Change** to open the picker.
## Setting the main model
Click **Change** on the Main model row:

The picker has two columns:
- **Left** — authenticated providers. Only providers you've set up (API key set, OAuth'd, or defined as a custom endpoint) show up here. If a provider is missing, head to **Keys** and add its credential.
- **Right** — the curated model list for the selected provider. These are the agentic models Hermes recommends for that provider, not the raw `/models` dump (which on OpenRouter includes 400+ models including TTS, image generators, and rerankers).
Type in the filter box to narrow by provider name, slug, or model ID.
Pick a model, hit **Switch**, and Hermes writes it to `~/.hermes/config.yaml` under the `model` section. **This applies to new sessions only** — any chat tab you already have open keeps running whatever model it started with. To hot-swap the current chat, use the `/model` slash command inside it.
### Mid-session switches and context warnings
When you switch models **inside an active session** (Herm TUI model picker, `hermes` CLI, or `/model` on Telegram/Discord), Hermes estimates whether your **next message** will run **preflight context compression** against the new model's window. If the session is already near or above that model's compression threshold (see [Context Compression](./configuration.md#context-compression)), the switch reply includes a warning — the same `warning_message` path used for expensive-model notices. The switch still applies immediately; compression runs on the **first user message after the switch**, before the model answers.
:::warning Mid-session switches reset the prompt cache
Prompt caches are keyed to the model serving the request, so any mid-conversation model change — an explicit `/model` switch, an [automatic fallback](./features/fallback-providers.md), or a [credential-pool](./features/credential-pools.md) rotation onto a different account — means the next message re-reads the entire conversation at full input-token price instead of the cached (~75–90% discounted) rate. On a long session this one-time re-read can dwarf the per-token difference between the two models. Switch when you need to, but prefer doing it early in a conversation or right after starting a fresh session.
:::
Because of that one-time re-read cost, Hermes asks for **explicit confirmation** before applying a mid-session switch when the live session already holds a large context (default: **100,000 tokens**, measured from the latest provider-billed prompt size). The confirmation renders through the same selection-guard prompt as the expensive-model and data-training warnings wherever a live session is switching: the CLI and TUI `/model` command and picker, and a typed gateway `/model` in a chat with an active agent. Tune or disable it in `config.yaml`:
```yaml
model:
# Ask before mid-session switches when the session exceeds this many
# context tokens (the next reply re-reads them uncached). 0 disables.
switch_context_confirm_tokens: 100000
```
Re-selecting the model you're already on never prompts (the cache stays warm), and sessions with no measured context (fresh sessions, non-live surfaces) are exempt.
### Unattended data-training tiers
Models with a `-contributor` suffix (e.g. `muse-spark-1.2-contributor`, `muse-spark-1.3-contributor`) are discounted because the vendor may train on your prompts and completions. Interactive model selection always shows a confirmation prompt. Non-interactive startup paths such as Kanban workers and cron agents fail closed because they cannot ask that question.
If training on the unattended workload's data is acceptable, record a persistent acknowledgement:
```bash
hermes config set security.allow_data_training_tiers_noninteractive true
```
Hermes still prints the full data-policy warning and the acknowledgement key on every unattended startup, so worker logs retain an audit trail. This setting does not approve expensive-model or provider-routing warnings, and it does not replace the interactive confirmation prompt. Revoke it with `hermes config unset security.allow_data_training_tiers_noninteractive`.
## Setting auxiliary models
Click **Show auxiliary** to reveal the 11 task slots:

Every auxiliary task defaults to `auto` — meaning Hermes tries your main model for that job too. If that route is unavailable or hits a capacity-style failure, `auto` follows any task-specific `auxiliary..fallback_chain`, then the main `fallback_providers` / `fallback_model` chain. It never guesses a provider you did not configure: with a main provider selected and no fallback declared, the side task is skipped with a warning rather than billed to another account you happen to be logged into. (Hermes' built-in discovery chain only runs when no main provider is selected at all.) Override a specific task when you want a cheaper or faster model for a side-job.
### Common override patterns
| Task | When to override |
|---|---|
| **Title Gen** | When title latency or cost matters more than matching the main model. Pin a known-good flash model, or set `auxiliary.title_generation.prefer_fast_model: true` to let Hermes choose the provider's fast tier. |
| **Vision** | When your main model lacks vision support. Point it at `google/gemini-2.5-flash` or `gpt-4o-mini`. |
| **Compression** | When you're burning reasoning tokens on Opus/M2.7 just to summarize context. A fast chat model does the job at 1/50th the cost. |
| **Approval** | For `approval_mode: smart` — a fast/cheap model (haiku, flash, gpt-5-mini) decides whether to auto-approve low-risk commands. Expensive models here are waste. |
| **Web Extract** | When you use `web_extract` heavily. Same logic as compression — summarization doesn't need reasoning. |
| **Skills Hub** | `hermes skills search` uses this. Usually fine at `auto`. |
| **MCP** | MCP tool routing. Usually fine at `auto`. |
| **Triage Specifier** | Routes the Kanban triage specifier (`hermes kanban specify`) that expands a rough one-liner into a concrete spec. A cheap, capable model works well. |
| **Kanban Decomposer** | Routes Kanban task decomposition — splits a triage task into a graph of child tasks for specialist profiles. |
| **Profile Describer** | Routes profile-description generation (`hermes profile describe --auto` / the dashboard auto-generate button). Short, cheap call. |
| **Curator** | Routes the curator skill-usage review pass. Can run for minutes on reasoning models, so a cheaper aux model is often worthwhile. |
### Per-task override
Click **Change** on any auxiliary row. Same picker opens, same behavior — pick provider + model, hit Switch. The row updates to show `provider · model` instead of `auto (use main model)`.
### Reset all to auto
If you've over-tuned and want to start over, click **Reset all to auto** at the top of the auxiliary section. Every slot goes back to using your main model.
## The "Use as" shortcut
Every model card on the page has a **Use as** dropdown. This is the fast path — pick a model you see in your analytics, click **Use as**, and assign it to the main slot or any specific auxiliary task in one click:

The dropdown has:
- **Main model** — same as clicking Change on the main row.
- **All auxiliary tasks** — assigns this model to all 11 aux slots at once. Useful when you just want every side-job on a cheap flash model.
- **Individual task options** — Vision, Web Extract, Compression, etc. The currently-assigned model for each task is marked `current`.
Cards are badged with `main` or `aux · ` when they're currently assigned to something — so you can see at a glance which of your historical models are wired in where.
## What gets written to `config.yaml`
When you save via the dashboard, Hermes writes to `~/.hermes/config.yaml`:
**Main model:**
```yaml
model:
provider: openrouter
default: anthropic/claude-opus-4.7
base_url: '' # cleared on provider switch
api_mode: chat_completions
```
**Auxiliary override (example — vision on gemini-flash):**
```yaml
auxiliary:
vision:
provider: openrouter
model: google/gemini-2.5-flash
base_url: ''
api_key: ''
timeout: 120
extra_body: {}
download_timeout: 30
```
**Auxiliary on auto (default):**
```yaml
auxiliary:
compression:
provider: auto
model: ''
base_url: ''
# ... other fields unchanged
```
`provider: auto` with `model: ''` tells Hermes to use the main model for that task, while still honoring fallback policy if the main route cannot serve the auxiliary call.
Optional task-specific fallback chains live under the same auxiliary task:
```yaml
auxiliary:
title_generation:
provider: auto
model: ''
fallback_chain:
- provider: openrouter
model: inclusionai/ring-2.6-1t:free
```
When `fallback_chain` is absent, `auto` uses the top-level `fallback_providers` chain. If that is also absent and the main provider cannot serve the call, the task is skipped with a warning — Hermes does not fall through to other logged-in providers.
## Per-provider request options
Provider entries (`providers.` in the `providers:` dict, or items in the legacy `custom_providers` list) accept knobs that shape how Hermes talks to the endpoint:
**`extra_headers`** — a mapping of extra HTTP headers attached to every LLM request routed to that provider's base URL. They are applied last, after URL/profile defaults and user header overrides, so they survive credential swaps and client rebuilds. Useful for Cloudflare Access service tokens, proxy auth, or custom bearer schemes:
```yaml
providers:
my-gateway:
api: https://llm.internal.example.com/v1
api_key: sk-...
extra_headers:
CF-Access-Client-Id: "xxxx.access"
CF-Access-Client-Secret: "yyyy"
```
Header values routinely carry credentials — Hermes never logs them. `extra_headers` applies to OpenAI-compatible routes and to `anthropic_messages` routes (the main client, `/model` switches, rebuilds and auxiliary clients alike); `bedrock_converse` does not use it. A relay behind a WAF that rejects the SDK's default `User-Agent` (403 "Your request was blocked" or a browser-challenge page) is the typical reason to set one — Hermes reports such a 403 as a firewall/CDN block rather than an API-key rejection.
**`session_affinity_header`** — the NAME of a header that carries Hermes' conversation id on every request to that provider (main turn on `chat_completions`, `anthropic_messages` and `codex_responses`, plus auxiliary calls such as compression and titles). Off unless set — Hermes never sends a session identifier to an endpoint that did not ask for one. Session-aware proxies fronting a stateful backend (LiteLLM's `x-litellm-session-id`, self-hosted Claude/OpenAI gateways) otherwise have nothing to correlate an agent loop on and treat nearly every request as a new conversation, re-sending the whole history upstream on each turn. The value is opaque, stable across the turns of one conversation (including compaction), and different for every conversation:
```yaml
providers:
my-proxy:
api: http://127.0.0.1:4000/v1
api_key: sk-...
session_affinity_header: x-litellm-session-id
```
**`discover_models`** — set to `false` (default `true`) to skip querying the endpoint's `/models` listing and use only the `models` you configured on the entry. Handy for gateways whose model listing is slow, unreliable, or noisy:
```yaml
providers:
my-gateway:
api: https://llm.internal.example.com/v1
discover_models: false
models:
- my-finetune-v2
- my-finetune-v1
```
With discovery off, the model picker (`hermes model`, `/model`) shows the configured list instead of a live probe.
**`openai_native_compaction`** — set this capability to `true` only for an OpenAI-compatible endpoint that you trust with conversation content. Native compaction sends its payload to that provider's configured `base_url`:
```yaml
providers:
trusted-proxy:
api: https://llm.internal.example.com/v1
capabilities:
openai_native_compaction: true
```
For a gateway that resolves a bare model alias only after receiving the
request, opt the alias into prompt-cache markers with the per-model
`prompt_caching` capability:
```yaml
providers:
model-proxy:
api: https://gateway.example.com/v1
transport: openai_chat # or anthropic_messages
models:
fable:
context_length: 1000000
prompt_caching: true
```
Hermes matches this declaration to the exact provider route and runtime model
id, without rewriting the alias or inferring support from its provider name,
host, or model family. The marker layout follows the configured transport:
`openai_chat` uses the OpenAI-compatible envelope layout and
`anthropic_messages` uses the native inner-block layout. Set
`prompt_caching: false` to explicitly disable cache markers for a model; when
omitted, Hermes keeps its normal provider and model capability detection.
:::note Legacy format
Older configs used a top-level `custom_providers:` list (with `base_url` instead of `api`). It still works and is auto-migrated to the `providers:` dict on `hermes update` (config v12).
:::
### Nous Portal: which wire carries Claude
Nous Portal serves its `anthropic/*` models on two routes: OpenAI-compatible `/v1/chat/completions` and the native Anthropic Messages wire `/v1/messages`. `nous.anthropic_wire` picks one:
```yaml
nous:
anthropic_wire: chat # default. "native" = the Anthropic Messages wire; "auto" = decide per session
```
`chat` is the default for now. The native wire is the better transport (signed thinking blocks pass through unchanged, native `cache_control` scopes), but on the Portal's OpenRouter-served path it currently re-writes the previous turn's prompt cache on 14–20% of consecutive calls in concurrent tool loops, which is 15–20% of a fan-out's cache-write bill; the chat route measured 0 on the same test. Set `native` to opt back in (for example once the portal-side fix has shipped). Only `anthropic/*` models are affected; everything else on Nous already uses chat/completions.
`auto` is for when the Portal serves the same model from more than one upstream. A session starts on chat, Hermes reads which upstream answered the first call, and switches that session to native only when the upstream is one where native is known to be clean (the switch happens between calls, so no in-flight response and no warm cache is lost). Today no upstream is cleared, so `auto` behaves exactly like `chat`; it exists so the flip can be made from a measurement rather than a config change.
## When does it take effect?
- **CLI** (`hermes chat`): next `hermes chat` invocation.
- **Gateway** (Telegram, Discord, Slack, etc.): next *new* session. Existing sessions keep their model. Restart the gateway (`hermes gateway restart`) if you want to force all sessions to pick up the change.
- **Dashboard chat tab** (`/chat`): next new PTY. The currently-open chat keeps its model — use `/model` inside it to hot-swap.
Changes never invalidate prompt caches on running sessions. That's deliberate: swapping the main model inside a session requires a cache reset (the system prompt contains model-specific content), and we reserve that for the explicit `/model` slash command inside chat.
## Troubleshooting
### "No authenticated providers" in the picker
Hermes lists a provider only if it has a working credential. Check **Keys** in the sidebar — you should see one of: an API key, a successful OAuth, or a custom endpoint URL. If the provider you want isn't there, run `hermes setup` to wire it up, or go to **Keys** and add the env var.
### Main model didn't change in my running chat
Expected. The dashboard writes `config.yaml`, which new sessions read. The currently-open chat is a live agent process — it keeps whatever model it was spawned with. Use `/model ` inside the chat to hot-swap that specific session.
### Auxiliary override "didn't take effect"
Three things to check:
1. **Did you start a new session?** Existing chats don't re-read config.
2. **Is `provider` set to something other than `auto`?** If the field shows `auto`, the task is still using your main model. Click **Change** and pick a real provider.
3. **Is the provider authenticated?** If you assigned `minimax` to a task but don't have a MiniMax API key, that task falls back to the openrouter default and logs a warning in `agent.log`.
### I picked a model but Hermes switched providers on me
On OpenRouter (or any aggregator), bare model names resolve *within* the aggregator first. So `claude-sonnet-4` on OpenRouter becomes `anthropic/claude-sonnet-4.6`, staying on your OpenRouter auth. But if you typed `claude-sonnet-4` on a native Anthropic auth, it would stay as `claude-sonnet-4-6`. If you see an unexpected provider switch, check that your current provider is what you expect — the picker always shows the current main at the top of the dialog.
## Alternative methods
### CLI slash command
Inside any `hermes chat` session:
```
/model gpt-5.4 --provider openrouter # session-only
/model gpt-5.4 --provider openrouter --global # also persists to config.yaml
/model claude-opus-4.6 --once # next turn only, then auto-restores
```
`--global` does the same thing the dashboard's **Change** button does, plus it switches the running session in-place.
`--once` switches for a single turn and restores the previous model afterward — on success, error, or interrupt alike. Nothing is persisted: a gateway restart mid-turn comes back on the original model. Useful for escalating one hard question to an expensive model ("ask Opus just this once") or dropping to a cheap model for a throwaway query.
:::note Prompt-cache cost
A one-turn switch breaks the provider's prompt-cache prefix twice (switching out and back). In a long session on a cached-prefix provider (Anthropic, OpenAI), the next turn re-pays full input cost — `--once` wins for short sessions or cheap→expensive escalation, but a quick side question inside a long expensive session can cost more than it saves.
:::
### Custom aliases
Define your own short names for models you reach for often, then use `/model ` in a running session or `hermes chat --model ` at startup. There are two equivalent formats — pick whichever fits your workflow.
**Canonical (top-level `model_aliases:`)** — full control over provider + base_url:
```yaml
# ~/.hermes/config.yaml
model_aliases:
fav:
model: claude-sonnet-4.6
provider: anthropic
grok:
model: grok-4
provider: x-ai
```
An alias that points at its own endpoint can also carry that endpoint's
credential, with either `api_key` (a literal, or a `"${VAR}"` reference) or
`key_env` (the name of an environment variable). If both are set, `api_key`
wins:
```yaml
model_aliases:
theta:
model: theta-1
provider: custom
base_url: "https://theta.example.com/v1"
key_env: THETA_API_KEY # or: api_key: "${THETA_API_KEY}"
```
When an alias sets neither, the key is resolved from the alias **host** —
`OLLAMA_API_KEY` for an `ollama.com` endpoint, `DEEPSEEK_API_KEY` for
`api.deepseek.com`, and so on. It is never inherited from whichever provider
happened to be active before the switch, so switching to an alias cannot send
one provider's secret to another provider's host.
**Short string form (`model.aliases.: provider/model`)** — convenient from the shell because `hermes config set` writes scalars and now also parses inline list/mapping literals, though this short alias form still can't carry a custom `base_url`:
```bash
hermes config set model.aliases.fav anthropic/claude-opus-4.6
hermes config set model.aliases.grok x-ai/grok-4
```
> `hermes config set` also accepts inline **list/mapping literals** (JSON/YAML flow style). Quote them so your shell passes them through intact:
>
> ```bash
> hermes config set platform_toolsets.line '["clarify", "file", "web"]'
> hermes config set display.tool_progress_overrides '{"terminal": "off"}'
> ```
Both paths feed the same loader (`hermes_cli/model_switch.py`). Entries declared in `model_aliases:` take precedence over `model.aliases:` entries with the same name.
Then `/model fav` or `/model grok` in chat. User aliases shadow built-in short names (`sonnet`, `kimi`, `opus`, etc.). See [Custom model aliases](../reference/slash-commands.md#custom-model-aliases) for the full reference.
### `hermes model` subcommand
```bash
hermes model # Interactive provider + model picker (the canonical way to switch defaults)
```
`hermes model` walks you through picking a provider, authenticating (OAuth flows open a browser; API-key providers prompt for the key), and then choosing a specific model from that provider's curated catalog. The choice is written to `model.provider` and `model.default` in `~/.hermes/config.yaml`. After a new model is saved, a reasoning-effort step follows (`minimal` … `ultra`, **Disable reasoning**, or **Skip** to keep the current value) and writes `agent.reasoning_effort`; the step is skipped for models the catalog marks as having no reasoning control. The provider list also has a **Reasoning effort for the current model...** row to change only the effort.
**Configure auxiliary models...** opens the per-task side-model picker (vision, compression, approval, delegation, …). Each task's provider → model pick ends with the same effort step, stored as `auxiliary..reasoning_effort` (or `delegation.reasoning_effort`), with an extra **Provider default** row that leaves the level up to the provider. Tasks whose block has no `reasoning_effort` key by design (MoA slots, memory query rewrite) skip the step.
To list providers/models without launching the picker, use the dashboard or the REST endpoints below. To inspect what the CLI will actually use right now: `hermes config get model --json` and `hermes status`.
### Direct config edit
Edit `~/.hermes/config.yaml` and restart whatever reads it. See the [Configuration reference](./configuration.md) for the full schema.
### REST API
The dashboard uses three endpoints. Useful for scripting:
```bash
# List authenticated providers + curated model lists
curl -H "X-Hermes-Session-Token: $TOKEN" http://localhost:PORT/api/model/options
# Read current main + auxiliary assignments
curl -H "X-Hermes-Session-Token: $TOKEN" http://localhost:PORT/api/model/auxiliary
# Set the main model
curl -X POST -H "Content-Type: application/json" -H "X-Hermes-Session-Token: $TOKEN" \
-d '{"scope":"main","provider":"openrouter","model":"anthropic/claude-opus-4.7"}' \
http://localhost:PORT/api/model/set
# Override a single auxiliary task
curl -X POST -H "Content-Type: application/json" -H "X-Hermes-Session-Token: $TOKEN" \
-d '{"scope":"auxiliary","task":"vision","provider":"openrouter","model":"google/gemini-2.5-flash"}' \
http://localhost:PORT/api/model/set
# Assign one model to every auxiliary task
curl -X POST -H "Content-Type: application/json" -H "X-Hermes-Session-Token: $TOKEN" \
-d '{"scope":"auxiliary","task":"","provider":"openrouter","model":"google/gemini-2.5-flash"}' \
http://localhost:PORT/api/model/set
# Reset all auxiliary tasks to auto
curl -X POST -H "Content-Type: application/json" -H "X-Hermes-Session-Token: $TOKEN" \
-d '{"scope":"auxiliary","task":"__reset__","provider":"","model":""}' \
http://localhost:PORT/api/model/set
```
The session token is injected into the dashboard HTML at startup and rotates on every server restart. Grab it from the browser devtools (`window.__HERMES_SESSION_TOKEN__`) if you're scripting against a running dashboard.
---
# Sessions
import useBaseUrl from '@docusaurus/useBaseUrl';
# Sessions
Hermes Agent automatically saves every conversation as a session. Sessions enable conversation resume, cross-session search, and full conversation history management.
## How Sessions Work
Every conversation — whether from the CLI, Telegram, Discord, Slack, WhatsApp, Signal, Matrix, Teams, or any other messaging platform — is stored as a session with full message history. Sessions are tracked in:
1. **SQLite database** (`~/.hermes/state.db`) — structured session metadata with FTS5 full-text search, plus full message history
The SQLite database stores:
- Session ID, source platform, user ID
- **Session title** (unique, human-readable name)
- Model name and configuration
- System prompt snapshot
- Full message history (role, content, tool calls, tool results)
- Token counts (input/output)
- Timestamps (started_at, ended_at)
- Parent session ID (for compression-triggered session splitting)
### What Counts Toward Context
Hermes stores session history so it can resume conversations, but it does not
keep re-sending every byte it has ever handled. On each turn, the model sees
the selected system prompt, the current conversation window, and any content
Hermes explicitly injects for that turn.
Media attachments are handled as turn-scoped inputs:
- Images may be attached natively to the next model call, or pre-analyzed into
a text description when the active model does not support native vision.
- Audio is transcribed into text when speech-to-text is configured.
- Text documents can have their extracted text included; other document types
are usually represented by a saved local path and a short note.
- Attachment paths and extracted/derived text can appear in the transcript, but
the raw image, audio, or binary file bytes are not repeatedly copied into
future prompts.
For example, if a user sends an image and asks Hermes to make a meme from it,
Hermes may inspect that image once with vision and run an image-processing
script. Future turns do not automatically carry the original JPEG in context.
They carry only whatever was written into the conversation, such as the user's
request, a short image description, a local cache path, or the final assistant
response.
The most common cause of context growth is not the media file itself. It is
verbose text: pasted transcripts, full logs, large tool outputs, long diffs,
repeated status reports, and detailed proof dumps. Prefer summaries, file
paths, focused excerpts, and tool-backed lookups over copying large artifacts
into chat.
:::tip
Use `/compress` when a session gets long, `/new` for a fresh thread, and
`hermes sessions prune` only when you want to delete old ended sessions from
storage. If `state.db` has simply grown large, start with the non-destructive
option first: `hermes sessions optimize` merges FTS5 index segments and
VACUUMs the database without touching any session data. Both `optimize` and `prune` refuse
while another Hermes process (gateway, Desktop, dashboard, cron) holds `state.db` — stop it
first, or pass `--force`; see [Session storage recovery](session-storage-recovery.md).
Compression reduces the active context; it is not a privacy delete.
Pass a name to `/new` (e.g. `/new payments-refactor`) to set the new session's
initial title up front — useful for finding it later with `/resume ` or
in the `/sessions` picker.
:::
### Session Sources
Each session is tagged with its source platform:
| Source | Description |
|--------|-------------|
| `cli` | Interactive CLI (`hermes` or `hermes chat`) |
| `oneshot` | Finite non-interactive runs: `hermes chat --oneshot -q`, `-Q`, `hermes -z`, and `-q` on non-TTY stdio. Hidden from the TUI, Desktop and dashboard session pickers (like `kanban` and `tool`), even when launched from inside a TUI or Desktop session — the run inherits that transport's environment but is not that conversation. Still counts as CLI history: `hermes -c` / `--resume latest` continue the last one-shot, and `hermes sessions list` shows it. An explicit `--source ` always wins (`hermes chat -q --source tui` is stored as `tui`). |
| `telegram` | Telegram messenger |
| `discord` | Discord server/DM |
| `slack` | Slack workspace |
| `whatsapp` | WhatsApp messenger |
| `signal` | Signal messenger |
| `matrix` | Matrix rooms and DMs |
| `mattermost` | Mattermost channels |
| `email` | Email (IMAP/SMTP) |
| `sms` | SMS via Twilio |
| `dingtalk` | DingTalk messenger |
| `feishu` | Feishu/Lark messenger |
| `wecom` | WeCom (WeChat Work) |
| `weixin` | Weixin (personal WeChat) |
| `bluebubbles` | Apple iMessage via BlueBubbles macOS server |
| `qqbot` | QQ Bot (Tencent QQ) via Official API v2 |
| `homeassistant` | Home Assistant conversation |
| `webhook` | Incoming webhooks |
| `api-server` | API server requests |
| `acp` | ACP editor integration |
| `cron` | Scheduled cron jobs |
| `batch` | Batch processing runs |
| `kanban` | Kanban dispatcher workers (read on the board, hidden from session pickers) |
| `tool` | Third-party integrations (`--source tool`), hidden from session pickers |
A session compressed mid-conversation continues under the same source: the compression child of a `--source tool` or `oneshot` run is tagged the same way, so it inherits the same picker visibility.
## CLI Session Resume
Resume previous conversations from the CLI using `--continue` or `--resume`:
### Continue Last Session
```bash
# Resume the most recent CLI session
hermes --continue
hermes -c
# Or with the chat subcommand
hermes chat --continue
hermes chat -c
```
This looks up the most recent `cli` session from the SQLite database and loads its full conversation history.
#### Per-Terminal Continue
A bare `-c` is terminal-aware: each CLI session drops a small breadcrumb file under `~/.hermes/terminal-sessions/` keyed by the terminal it runs in (tty device, tmux pane, kitty window, wezterm pane, Zellij pane, Windows Terminal session, ...). When you run `hermes -c` again in the *same* terminal, Hermes resumes that terminal's own session — so two panes side by side each continue their own conversation instead of both grabbing the globally most-recent one. If there's no breadcrumb for the terminal (first use, deleted session, or a stale breadcrumb older than 30 days), `-c` falls back to the most-recent-session behavior. `-c "name"` and `--resume` are unaffected. Disable with `session.terminal_continue: false` in `config.yaml`.
### Resume by Name
If you've given a session a title (see [Session Naming](#session-naming) below), you can resume it by name:
```bash
# Resume a named session
hermes -c "my project"
# If there are lineage variants (my project, my project #2, my project #3),
# this automatically resumes the most recent one
hermes -c "my project" # → resumes "my project #3"
```
### Resume Specific Session
```bash
# Resume a specific session by ID
hermes --resume 20250305_091523_a1b2c3d4
hermes -r 20250305_091523_a1b2c3d4
# Resume by title
hermes --resume "refactoring auth"
# Resume the most recent session — same lookup as -c
hermes --resume latest
# Or with the chat subcommand
hermes chat --resume 20250305_091523_a1b2c3d4
```
Session IDs are shown when you exit a CLI session, and can be found with `hermes sessions list`.
:::note
`latest` is a reserved keyword for `--resume`. A session literally titled "latest" is still reachable by its ID or via `-c latest` (title match).
:::
### Resume in a Specific Directory
Pass `--in ` to change into a directory before starting or resuming. Combined with `--resume latest` (or `-c`), the most recent session for that directory's workspace is picked — no need to `cd` first or remember session IDs:
```bash
# Resume the latest session that belongs to ./my-project
hermes --resume latest --in ./my-project
# Works with the TUI too
hermes --tui --resume latest --in ./my-project
```
`--in` also pins the session to that directory: the resumed session's recorded working directory is not restored (as if `--no-restore-cwd` were passed).
### Resume Restores the Working Directory
Resuming a CLI session also `cd`s back into the session's recorded working directory (its git repo root or project dir), so the conversation picks up in the workspace it belonged to. If you'd rather stay where you are, pass `--no-restore-cwd`:
```bash
hermes --resume 20250305_091523_a1b2c3 --no-restore-cwd
```
A `↪ restored workspace dir: …` line confirms the switch. Restore failures never break the resume itself.
### Filtering Sessions by Workspace
`hermes sessions list` accepts `--workspace ` to show only sessions whose workspace key (git repo root, else cwd) matches — by path substring or exact directory basename:
```bash
hermes sessions list --workspace my-project
hermes sessions list --workspace ~/code/hermes-agent
```
### Conversation Recap on Resume
When you resume a session, Hermes displays a compact recap of the previous conversation in a styled panel before the input prompt:
Resume mode shows a compact recap panel with recent user and assistant turns before returning you to the live prompt.
The recap:
- Shows **user messages** (gold `●`) and **assistant responses** (green `◆`)
- **Truncates** long messages (300 chars for user, 200 chars / 3 lines for assistant)
- **Collapses tool calls** to a count with tool names (e.g., `[3 tool calls: terminal, web_search]`)
- **Hides** system messages, tool results, and internal reasoning
- **Caps** at the last 10 exchanges with a "... N earlier messages ..." indicator
- Uses **dim styling** to distinguish from the active conversation
To disable the recap and keep the minimal one-liner behavior, set in `~/.hermes/config.yaml`:
```yaml
display:
resume_display: minimal # default: full
```
:::tip
Session IDs follow the format `YYYYMMDD_HHMMSS_` — CLI/TUI sessions use a 6-char hex suffix (e.g. `20250305_091523_a1b2c3`), gateway sessions use an 8-char suffix (e.g. `20250305_091523_a1b2c3d4`). You can resume by ID (full or unique prefix) or by title — both work with `-c` and `-r`.
:::
## Cross-Platform Handoff
Use `/handoff ` from a CLI session to transfer the live conversation to a messaging platform's home channel. The agent picks up exactly where the CLI left off — same session id, full role-aware transcript, tool calls and all.
```bash
# Inside a CLI session
/handoff telegram
```
What happens:
1. The CLI validates that `` is enabled and has a home channel set (run `/sethome` from the destination chat once to configure it).
2. The CLI marks the session pending and **block-polls the gateway**. It refuses if the agent is mid-turn — wait for the current response to finish first.
3. The gateway watcher claims the handoff and asks the destination adapter for a fresh thread:
- **Telegram** — opens a new forum topic (DM topics if the bot owner has enabled Threaded Mode via BotFather, or a forum supergroup topic).
- **Discord** — creates a 1440-min auto-archive thread under the home text channel.
- **Slack** — posts a seed message and uses its `ts` as the thread anchor.
- **Matrix** — posts a seed message and uses its event id as the thread root (`m.thread` relation).
- **WhatsApp / Signal / SMS** — no native threads, falls back to the home channel directly.
4. The gateway re-binds the destination key to your existing CLI session id, then forges a synthetic user turn asking the agent to confirm and summarize. The reply lands in the new thread.
5. When the gateway acknowledges success, the CLI prints a `/resume` hint and exits cleanly:
```
↻ Handoff complete. The session is now active on telegram.
Resume it on this CLI later with: /resume my-session-title
```
6. From that point, the conversation lives on the platform. Reply in the new thread — anyone authorized in that channel shares the same session, and any later real user message in the thread joins seamlessly because thread sessions key without `user_id`.
**Resume back to CLI:** when you want to come back to a desktop, just run `/resume ` (or `hermes -r ""` from the shell) and pick up where the platform left off.
**Failure modes:**
- No home channel configured → CLI refuses with a `/sethome` hint.
- Gateway not running (nothing ever claims the request) → CLI times out at 60s with a clear message and your CLI session stays intact.
- Slow transfer: once the gateway claims the handoff it replays your full session through a real agent turn, which can take a few minutes on long sessions. The CLI shows "Still transferring..." heartbeats and waits up to 15 minutes — it never misreports a slow transfer as "gateway not running".
- Thread creation fails (permissions, topics-mode off) → falls back to the home channel directly and still completes; no thread isolation but the handoff itself works.
- `adapter.send` fails (rate limit, transient API error) → handoff marked failed with the reason; the row clears so you can retry.
**Limitation worth knowing:** for non-thread-capable platforms with multi-user group home channels, the synthetic turn keys as a DM-style session. This works for self-DM home channels (the typical setup) but isn't ideal for genuinely shared group chats. Threading covers Telegram / Discord / Slack / Matrix — by far the common case — so most setups never hit this.
## Session Naming
Give sessions human-readable titles so you can find and resume them easily.
### Auto-Generated Titles
Hermes automatically generates a short descriptive title (3–7 words) for each session after the first exchange. This runs in a background thread using a fast auxiliary model, so it adds no latency. You'll see auto-generated titles when browsing sessions with `hermes sessions list` or `hermes sessions browse`.
Auto-titling only fires once per session and is skipped if you've already set a title manually.
### Setting a Title Manually
Use the `/title` slash command inside any chat session (CLI or gateway):
```
/title my research project
```
The title is applied immediately. If the session hasn't been created in the database yet (e.g., you run `/title` before sending your first message), it's queued and applied once the session starts.
You can also rename existing sessions from the command line:
```bash
hermes sessions rename 20250305_091523_a1b2c3d4 "refactoring auth module"
```
### Title Rules
- **Unique** — no two sessions can share the same title
- **Max 100 characters** — keeps listing output clean
- **Sanitized** — control characters, zero-width chars, and RTL overrides are stripped automatically
- **Normal Unicode is fine** — emoji, CJK, accented characters all work
### Auto-Lineage on Compression
When a session's context is compressed (manually via `/compress` or automatically), Hermes creates a new continuation session. If the original had a title, the new session automatically gets a numbered title:
```
"my project" → "my project #2" → "my project #3"
```
When you resume by name (`hermes -c "my project"`), it automatically picks the most recent session in the lineage.
### /title in Messaging Platforms
The `/title` command works in all gateway platforms (Telegram, Discord, Slack, WhatsApp):
- `/title My Research` — set the session title
- `/title` — show the current title
## Session Management Commands
Hermes provides a full set of session management commands via `hermes sessions`:
### List Sessions
```bash
# List recent sessions (default: last 20)
hermes sessions list
# Filter by platform
hermes sessions list --source telegram
# Show more sessions
hermes sessions list --limit 50
```
When more sessions exist than `--limit` allows, the listing ends with a `… more not shown (use --limit N to see more)` footer, so a capped page is never mistaken for the full list.
When sessions have titles, the output shows titles, previews, and relative timestamps:
```
Title Preview Last Active ID
────────────────────────────────────────────────────────────────────────────────────────────────
refactoring auth Help me refactor the auth module please 2h ago 20250305_091523_a
my project #3 Can you check the test failures? yesterday 20250304_143022_e
— What's the weather in Las Vegas? 3d ago 20250303_101500_f
```
When no sessions have titles, a simpler format is used:
```
Preview Last Active Src ID
──────────────────────────────────────────────────────────────────────────────────────
Help me refactor the auth module please 2h ago cli 20250305_091523_a
What's the weather in Las Vegas? 3d ago tele 20250303_101500_f
```
### Export Sessions
`hermes sessions export` is one surface for every export format, selected with `--format`:
| Format | Output | Use it for |
|--------|--------|------------|
| `jsonl` (default) | one JSON object per session | backups, machine round-trip |
| `md` / `qmd` | one Markdown/Quarto file per session + manifest | readable archives, notes |
| `html` | single self-contained page (sidebar for multi-session) | sharing, browsing |
| `trace` | Claude Code JSONL | HF Agent Trace Viewer, `--upload` |
Plus `--only user-prompts` for a prompts-only view (jsonl or md).
All formats share the same selection knobs: `--session-id` for one session, or the full `prune`/`archive` filter set for bulk — `--older-than` / `--newer-than` / `--before` / `--after` (durations like `5h`/`2d`/`1w`, bare days, or ISO timestamps), `--source`, `--title`, `--model`, `--provider`, `--cwd`, `--min/--max-messages`, `--min/--max-tokens`, `--min/--max-cost`, `--min/--max-tool-calls`, `--user`, `--chat-id`, `--chat-type`, `--branch`, `--end-reason`. `--dry-run` previews the match set without writing. `--redact` scrubs secrets (API keys, tokens, credentials) from exported content on any format — recommended for anything you plan to share. Note: bulk filters match *ended* sessions; unfiltered `export` dumps everything, including active ones.
#### JSONL (default)
```bash
# Export all sessions to a JSONL file
hermes sessions export backup.jsonl
# Export sessions from a specific platform
hermes sessions export telegram-history.jsonl --source telegram
# Export a single session
hermes sessions export session.jsonl --session-id 20250305_091523_a1b2c3d4
# Point at a directory (existing, or ending in /) and the file is named for you:
# ~/exports/hermes_session_20250305_091523_a1b2c3d4.jsonl
hermes sessions export ~/exports/ --session-id 20250305_091523_a1b2c3d4
# Redact API keys/tokens/credentials from the exported content
hermes sessions export backup.jsonl --redact
```
Exported files contain one JSON object per line with full session metadata and all messages.
Each record also carries a `timings` block derived from the message timestamps, so a reader of an export attached to a bug report can tell a single long model gap from many small tool round-trips without reconstructing it by hand. It holds only ids, roles, counts and durations — `wall_clock_ms`, `largest_gap_ms`, `role_counts`, `tool_calls_emitted` and per-message `intervals` — never prompt text, tool arguments or results, so it survives `--redact` unchanged. Hermes does not persist a model/tool stopwatch, so `complete` is always `false`; when a session has no timestamped messages, `available` is `false` and `unavailable_reason` says why. The block is rebuilt on every export and ignored (and not counted toward size limits) on import.
#### HTML
`--format html` writes a single self-contained HTML file — no remote dependencies — with styled message bubbles, collapsible tool output, and (for multi-session exports) a sidebar to switch between sessions:
```bash
# One session as a standalone HTML page
hermes sessions export --format html --session-id 20250305_091523_a1b2c3d4 transcript.html
# All Telegram sessions from the last week in one file, secrets redacted
hermes sessions export --format html --newer-than 1w --source telegram --redact archive.html
```
#### Prompts Only
`--only user-prompts` exports just the prompts you wrote — no assistant replies, tool output, or system context. Useful for building prompt libraries or reviewing what you asked:
```bash
# One JSONL record per prompt (session id, index, timestamp, text)
hermes sessions export prompts.jsonl --session-id 20250305_091523_a1b2c3d4 --only user-prompts
# Markdown, straight to stdout
hermes sessions export - --session-id 20250305_091523_a1b2c3d4 --only user-prompts --format md
```
Works with `--format jsonl` (default) or `md`, honors the same filters for bulk export, and combines with `--redact`.
#### Traces (HF Agent Trace Viewer)
`--format trace` emits Claude Code JSONL — the transcript shape the Hugging Face Hub auto-detects for its [Agent Trace Viewer](https://huggingface.co/docs/hub/agent-traces). Write it locally, or add `--upload` to push it to your own private `hermes-traces` dataset (reads `HF_TOKEN`):
```bash
# Trace of the most recent session, to stdout
hermes sessions export --format trace
# One session to a local trace file
hermes sessions export --format trace --session-id 20250305_091523_a1b2c3d4 trace.jsonl
# Upload straight to your private HF traces dataset
hermes sessions export --format trace --session-id 20250305_091523_a1b2c3d4 --upload
```
Trace exports are secret-redacted by default (they're meant to leave the machine); `--no-redact` opts out after manual review. `--upload` is private unless `--public`. Bulk trace export with filters writes one `.trace.jsonl` per session.
#### Markdown / QMD
Pass `--format md` or `--format qmd` when you want a readable, file-based archive before hiding or deleting old sessions. Markdown/QMD exports write one file per session into a directory (default: `~/.hermes/session-exports`).
```bash
# Export one session to Markdown
hermes sessions export --format md --session-id 20250305_091523_a1b2c3d4
# Export a compression lineage as one logical document
hermes sessions export --format md --session-id 20250305_091523_a1b2c3d4 --lineage logical
# Preview ended sessions older than 90 days without writing files
hermes sessions export --format md --older-than 90 --dry-run
# Export ended Telegram sessions older than 2 weeks to QMD files
hermes sessions export --format qmd --older-than 2w --source telegram
# Export long Claude sessions, secrets redacted
hermes sessions export --format md --model sonnet --min-messages 50 --redact
# Only after verification, export and delete one explicitly named session
hermes sessions export --format md --session-id 20250305_091523_a1b2c3d4 --delete-after-verified --yes
```
Markdown/QMD export writes one `.md` or `.qmd` file per exported session plus a `manifest.jsonl` with the file path, message count, lineage ids, and SHA-256. Bulk export requires at least one filter; a bare bulk export is refused. `--delete-after-verified` is intentionally limited to `--session-id` and requires `--yes`. Because deleting a parent session also removes its delegate/subagent sessions, this mode exports and verifies each delegate in a separate file before deleting anything. Markdown/QMD files hold the full history shown by the session, including turns archived by in-place compaction. Deletion compares that exact display transcript and the delegate set again inside the same database transaction that performs the delete; any intervening append, rewrite, rewind, compaction, or delegate change refuses deletion. The same display-history rule applies to `--format html`, `--only user-prompts` (with either Markdown or JSONL output), and `/save md|html`. Full-session JSON/JSONL exports and `/save json` remain live-only because importing archived turns would restore them as live model context. `--redact` scrubs secrets (API keys, tokens, credentials) from message content and tool output before writing — recommended for any export you plan to share.
### Delete a Session
```bash
# Delete a specific session (with confirmation)
hermes sessions delete 20250305_091523_a1b2c3d4
# Delete without confirmation
hermes sessions delete 20250305_091523_a1b2c3d4 --yes
```
### Rename a Session
```bash
# Set or change a session's title
hermes sessions rename 20250305_091523_a1b2c3d4 "debugging auth flow"
# Multi-word titles don't need quotes in the CLI
hermes sessions rename 20250305_091523_a1b2c3d4 debugging auth flow
```
If the title is already in use by another session, an error is shown.
### Pin a Session
Pinning sets a durable "keep" flag: pinned sessions are exempt from the
`sessions.auto_archive` stale sweep and always appear in listings. It is the
same flag the Desktop sidebar's Pinned section uses — pin from either surface
and both see it.
```bash
# Pin one or more sessions (unique ID prefixes work)
hermes sessions pin 20250305_091523_a1b2c3d4
hermes sessions pin 20250305 20250306
# Remove the pin
hermes sessions unpin 20250305_091523_a1b2c3d4
# List pinned sessions
hermes sessions pinned
# Machine-readable output, e.g. for a nightly backup of your pin set
hermes sessions pinned --json > pinned-sessions.json
```
### Prune Old Sessions
```bash
# Delete ended sessions inactive for 90 days (default)
hermes sessions prune
# Custom age threshold — bare numbers are days
hermes sessions prune --older-than 30
# Durations work too: 5h, 30m, 2d, 1w
hermes sessions prune --older-than 12h
# Delete only a specific time window (e.g. a batch of test sessions
# created in the last 5 hours)
hermes sessions prune --newer-than 5h
# Explicit window with absolute timestamps
hermes sessions prune --after "2026-07-05 09:00" --before "2026-07-05 14:30"
# Only prune sessions from a specific platform (all ages — any filter
# disables the implicit 90-day default)
hermes sessions prune --source telegram
hermes sessions prune --source cron --older-than 60 # add a time flag to narrow
# More filters — all AND together
hermes sessions prune --newer-than 5h --title "smoke test" # title substring
hermes sessions prune --older-than 30 --max-messages 3 # tiny sessions
hermes sessions prune --cwd ~/scratch --end-reason done # by cwd / end reason
hermes sessions prune --model gpt-5 --older-than 1w # by model (substring)
hermes sessions prune --provider openrouter --older-than 60 # by billing provider
hermes sessions prune --branch feature/old-experiment # by git branch
hermes sessions prune --user 12345678 --chat-type group # by messaging origin
hermes sessions prune --max-tokens 500 --older-than 7 # by token usage
hermes sessions prune --max-cost 0.01 --max-tool-calls 0 # cheap, tool-less runs
# Preview what would be deleted, without deleting anything
hermes sessions prune --newer-than 5h --dry-run
# Skip confirmation
hermes sessions prune --older-than 30 --yes
```
Time values (`--older-than`, `--newer-than`, `--before`, `--after`) accept a
duration (`5h`, `30m`, `2d`, `1w`), a bare number of days, or an ISO
timestamp (`2026-07-05`, `2026-07-05 14:30`). `--older-than`/`--before` set
the upper bound; `--newer-than`/`--after` set the lower bound. The
`--older-than`/`--newer-than` pair uses last activity — the freshest of live
activity, latest message, or session start — while `--before`/`--after`
explicitly use session start time. Combine either pair for a window.
Attribute filters: `--source` (platform, exact), `--title` / `--model` /
`--branch` (case-insensitive substring), `--provider` (billing provider,
exact), `--end-reason`, `--user`, `--chat-id`, `--chat-type` (exact),
`--cwd` (path prefix), plus numeric bounds `--min/--max-messages`,
`--min/--max-tokens` (input+output), `--min/--max-cost` (USD, actual falling
back to estimated), and `--min/--max-tool-calls`. Using any filter disables
the implicit 90-day default, so `hermes sessions prune --source cron` or
`--model gpt-4o` matches all ages — add a time flag to narrow it. Only a
completely bare `hermes sessions prune` keeps the 90-day cutoff. Every
non-`--yes` run shows the match count plus the oldest and newest matching
session before asking for confirmation.
Archived sessions are skipped by default; pass `--include-archived` to
delete them too.
:::info
Pruning only deletes **ended** sessions (sessions that have been explicitly ended or auto-reset). Active sessions are never pruned.
:::
### Bulk-Archive Sessions
If you want sessions out of your listings without deleting anything,
`hermes sessions archive` takes the same filters as `prune` but soft-hides
matching sessions instead (sets the same archived flag as archiving a single
session from the Desktop/Dashboard UI — messages and search stay intact):
```bash
# Archive everything from the last 5 hours (e.g. 75 CI smoke-test sessions)
hermes sessions archive --newer-than 5h
# Archive by title substring, preview first
hermes sessions archive --title "dry run" --dry-run
hermes sessions archive --title "dry run" --yes
```
At least one filter is required — a bare `hermes sessions archive` refuses to
archive your entire history. A compacted conversation is archived as a unit
through its live tip: an old compression segment never matches on its own age,
so a chat that is still active is never hidden because its history is long.
Archived sessions are hidden from
`hermes sessions list` and `/resume` but remain in the database and can be
unarchived from the Desktop/Dashboard session list.
### Session Statistics
```bash
hermes sessions stats
```
Output:
```
Total sessions: 142
Total messages: 3847
cli: 89 sessions
telegram: 38 sessions
discord: 15 sessions
Database size: 12.4 MB
```
For deeper analytics — token usage, cost estimates, tool breakdown, and activity patterns — use [`hermes insights`](../reference/cli-commands.md#hermes-insights).
### Repair Stranded Gateway Sessions
If a gateway conversation ever "jumps back in time" after a restart — resuming
a days-old topic as though recent messages never happened — the live
conversation may be stranded in a session row that lost its routing identity
(the damage class fixed in the v0.21 session-continuity work; current versions
prevent it by construction and self-heal at runtime).
`hermes sessions repair-routing` finds message-bearing session rows with no
routing identity and re-attaches each one to the conversation it continues —
but only when the evidence is unambiguous:
```bash
# Report only — shows each orphan, the proposed adoption, and the evidence
hermes sessions repair-routing
# Perform the adoptions (stop the gateway first — a running gateway holds
# the old routing in memory and would write it back over the repair)
hermes sessions repair-routing --apply
# Widen/narrow the contiguity window (default 900 seconds)
hermes sessions repair-routing --max-gap-seconds 300
```
Evidence rules:
- **lineage** — the orphan's `parent_session_id` points at a keyed row of the
same platform (a recorded fact; no time window applies)
- **contiguity** — exactly one keyed row of the same platform fell quiet
within the window of the orphan's start
Anything ambiguous (two candidate predecessors, two orphans claiming the same
predecessor) is reported with a reason and left untouched — a wrong adoption
would splice one conversation into another chat. The superseded row is retired
under `superseded_by_repair`, so restart recovery can never resurrect it.
Repair is deliberately **not automatic**: if the chat has since built up a
second history, choosing which thread it continues is your call. The stranded
conversation stays readable via `/resume` and session search either way —
routing is the only thing the repair changes. Back up first
(`cp ~/.hermes/state.db ~/.hermes/state.db.bak`).
### Repair State Crossed Between Profiles
Every profile owns one `state.db`, and every gateway session key names the
profile that owns the conversation (`agent:main:…` for the default profile,
`agent::…` for a named one). Older releases could leave the two
disagreeing — a named profile's rows written into the default store, a child
session inheriting from another profile's row, a routing row copied into the
wrong store, a Telegram topic or `/voice` setting saved without the bot's
profile. Current versions put new state in the right place; `hermes sessions
repair-profiles` settles what is already crossed.
```bash
# Report only — every store is scanned, nothing is written
hermes sessions repair-profiles
# Perform the repairs (stop the gateway first; a snapshot of every store is taken)
hermes sessions repair-profiles --apply
# Machine-readable report
hermes sessions repair-profiles --json
```
What it finds and does:
| Finding | Repair |
|---|---|
| `profile_name` disagrees with the row's own session key | relabel from the key |
| rows sitting in another profile's store | move (with all messages) to the owning profile's store |
| `parent_session_id` pointing at another profile's row | sever the link; the row's own identity is kept |
| routing rows outside the default store (under multiplexing) | move to the default store; an existing row there wins |
| routing rows / `sessions.json` entries for a profile that no longer exists | delete |
| Telegram topic bindings and voice-mode entries missing their bot's profile | relabel from the sessions that hold the chat |
Two cases are reported but never repaired without being told what they are:
rows keyed to a profile that does not exist (create the profile, or
`hermes profile migrate-identity `), and `agent:main:…` rows inside
a named profile's store. The latter are either the history of a gateway that
used to run standalone for that profile (`--legacy-main rekey` gives them the
profile's namespace) or default-profile chats that leaked in under a scoped
write (`--legacy-main move` sends them to the default store) — the rows
themselves cannot tell the two apart.
`--apply` refuses while a gateway owns any of the stores (it holds the routing
index in memory and would write it back), and is safe to re-run: a second run
finds nothing.
### Convert the Store Between WAL and DELETE Journal Mode
`database.journal_mode: delete` only applies to databases Hermes creates. An
existing `state.db` that is already in WAL mode is **never** live-downgraded at
open — other gateway, dashboard or cron processes may hold uncheckpointed WAL
commits, and a downgrade underneath them destroys those commits — so Hermes
keeps WAL and logs one `ERROR` per process telling you the configured `delete`
did not apply. The self-service conversion is:
```bash
# stop every process using the profile's store first (gateway, dashboard, CLIs, cron)
hermes sessions set-journal-mode delete # WAL -> rollback journal
hermes sessions set-journal-mode wal # back to WAL
hermes sessions set-journal-mode delete --db ~/.hermes/kanban.db # another Hermes store
```
The command refuses — naming each PID and command — while any process still
holds the file or its `-wal`/`-shm` sidecars, switches the mode without
waiting out openers (a holder that appears mid-way makes SQLite refuse instead
of racing it), and verifies the file header reports the new mode. It reminds
you to set `database.journal_mode` to the same value when the config disagrees,
because the next open re-applies the configured mode. The holder scan is local
(open-file tables on Linux/macOS, the Restart Manager on Windows), so it
cannot see a process in another container or VM sharing the volume. If the
scan itself fails the command refuses because it cannot prove the store is
quiet; `--force` waives only that case after you have stopped every Hermes
process yourself — a process the scan does find is always refused. Enabling
WAL is also refused when the store sits on a cross-VM filesystem (virtiofs/9p),
where WAL shared memory corrupts silently.
## Importing Sessions from Claude Code and Codex CLI
Started a conversation in another agent CLI? You can pull it into Hermes and
continue it here. Hermes reads Claude Code's session logs
(`~/.claude/projects/`, or `$CLAUDE_CONFIG_DIR/projects/` when Claude Code's
config dir is relocated) and Codex CLI's rollouts (`~/.codex/sessions/`, or
`$CODEX_HOME/sessions/`) — the foreign files are only read, never modified.
```bash
# Interactive picker across both tools, newest first
hermes sessions import
# Limit to one tool, or point at a specific file
hermes sessions import --from claude
hermes sessions import --from codex ~/.codex/sessions/2026/08/15/rollout-....jsonl
# Import-and-resume in one step
hermes --resume @claude
hermes --resume @codex
```
`hermes sessions import` creates a new Hermes session titled
`Imported from Claude Code: ` (or Codex CLI) and prints
the id plus a ready-to-paste `hermes --resume ` command.
`--resume @claude` / `--resume @codex` show the same picker and drop you
straight into the imported conversation.
**Hermes Desktop** has the same importer in the command palette (**Import
session**). It lists the logs on the machine the
connected backend runs on — not the computer running the app — shows a
read-only preview, and **Continue in Hermes** copies the conversation into the
selected profile. Browsing never writes to your session store, importing never
touches the source file, and importing the same log twice opens the existing
copy instead of making another.
What carries over: the ordered user/assistant conversation, with tool
activity condensed to short `[ran tool: …]` notes inside assistant turns.
System prompts, injected context, reasoning traces, and raw tool output are
left behind — the import is a clean transcript, not a byte-for-byte replay.
## Session Search Tool
The agent has a built-in `session_search` tool that performs full-text search across all past conversations using SQLite's FTS5 engine — and lets the agent scroll through any session it finds. It makes no LLM calls and returns views of actual messages from the DB rather than generating summaries.
### Four calling shapes
The tool infers what you want from which arguments you set. There's no `mode` parameter.
**1. Discovery — pass `query`:**
```python
session_search(query="auth refactor", limit=3)
```
Runs FTS5, dedupes hits by session lineage, and returns the top N sessions. Discovery uses adaptive detail by default: the highest-ranked result includes its full context window and bookends, while lower-ranked results stay compact. Pass `detail="full"` to fully hydrate every result.
Each result carries:
- `session_id`, `title`, `when`, `source`
- `snippet` — FTS5-highlighted match excerpt
- `detail` — `full` or `compact`
- `bookend_start` / `bookend_end` — first/last 3 user+assistant messages for full results; empty lists for compact results
- `messages` — ±5 messages around the FTS5 match for full results; only the flagged anchor message for compact results
- `match_message_id`, `messages_before`, `messages_after`
The top result reconstructs goal → match → resolution immediately. If another compact result looks more promising, use its session and message IDs with the scroll shape. Typical wall time is tens of milliseconds on a real session DB.
**2. Scroll — pass `session_id` + `around_message_id`:**
```python
session_search(session_id="20260510_174648_805cc2", around_message_id=590803, window=10)
```
Returns a window of ±`window` messages centered on the anchor. No FTS5, no bookends — just the slice. Use after a discovery call when you need more context than the ±5 default window.
- To scroll **forward**: pass `messages[-1].id` back as `around_message_id`
- To scroll **backward**: pass `messages[0].id` back as `around_message_id`
- The boundary message appears in both windows as an orientation marker
- When `messages_before` or `messages_after` is less than `window`, you're at the start or end of the session
Typical wall time: 1–2ms per scroll call.
**3. Read — pass `session_id` without an anchor:**
```python
session_search(session_id="20260510_174648_805cc2")
```
Returns the whole session, or a bounded head/tail view for large sessions. This shape is also used to resolve an `@session:/` link.
**4. Browse — no args:**
```python
session_search()
```
Returns recent sessions chronologically (titles, previews, timestamps). Useful when the user asks "what was I working on" without naming a topic.
### FTS5 query syntax
The keyword mode supports standard FTS5 query syntax:
- Simple keywords: `docker deployment` (FTS5 defaults to AND)
- Phrases: `"exact phrase"`
- Boolean: `docker OR kubernetes`, `python NOT java`
- Prefix: `deploy*`
### Optional parameters
- `sort` — `newest` or `oldest`, on top of FTS5 ranking. Omit for relevance-only ordering (the default; suitable for exploratory recall). Use `newest` for "where did we leave X" questions, `oldest` for "how did X start" questions.
- `detail` — `adaptive` (default) fully hydrates only the top discovery result; `full` hydrates every discovery result.
- `role_filter` — comma-separated roles to include. Discovery defaults to `user,assistant` (tool output is usually noise). Pass `user,assistant,tool` to include tool output (debugging tool behaviour) or `tool` to search tool output only.
### When It's Used
The agent is prompted to use session search automatically:
> *"When the user references something from a past conversation or you suspect relevant prior context exists, use session_search to recall it before asking them to repeat themselves."*
Typical triggers: "we did this before", "remember when", "last time", "as I mentioned", or any reference to a project/person/concept that isn't in the current window.
## Per-Platform Session Tracking
### Gateway Sessions
On messaging platforms, sessions are keyed by a deterministic session key built from the message source:
| Chat Type | Default Key Format | Behavior |
|-----------|--------------------|----------|
| Telegram DM | `agent:main:telegram:dm:` | One session per DM chat |
| Discord DM | `agent:main:discord:dm:` | One session per DM chat |
| WhatsApp DM | `agent:main:whatsapp:dm:` | One session per DM user (LID/phone aliases collapse to one identity when mapping exists) |
| Group chat | `agent:main::group::` | Per-user inside the group when the platform exposes a user ID |
| Group thread/topic | `agent:main::group::` | Shared session for all thread participants (default). Per-user with `thread_sessions_per_user: true`. |
| Channel | `agent:main::channel::` | Per-user inside the channel when the platform exposes a user ID |
When Hermes cannot get a participant identifier for a shared chat, it falls back to one shared session for that room.
### Shared vs Isolated Group Sessions
By default, Hermes uses `group_sessions_per_user: true` in `config.yaml`. That means:
- Alice and Bob can both talk to Hermes in the same Discord channel without sharing transcript history
- one user's long tool-heavy task does not pollute another user's context window
- a running turn is keyed to the sender that started it, but `/stop` still reaches it — see below
`/stop` means "stop what is running in this chat": it first tries the caller's own session key,
then any live turn in this chat — other participants' runs in the caller's own thread included —
authorization-gated, and never another room, workspace or profile. So an idle Alice's `/stop` can
end a turn Bob (or a bot) started in the room she is in. A `/stop` sent from *inside* a thread is
narrower: it reaches runs belonging to that thread and a room-wide run that carries no thread slot
(the rolling-DM shape), but never another thread of the same channel and never a peer's per-sender
top-level run.
If you want one shared "room brain" instead, set:
```yaml
group_sessions_per_user: false
```
That reverts groups/channels to a single shared session per room, which preserves shared conversational context but also shares token costs, interrupt state, and context growth.
### Session continuity
Gateway conversations do not reset after inactivity or at a daily boundary. Use `/new`
or `/reset` for an explicit new conversation; context compression remains automatic.
Legacy `session_reset` settings, reset-policy overrides and reset-timer environment
variables are ignored. Cached agents may be released to reclaim resources without
replacing the durable conversation. Restart-recovery freshness limits automatic
continuation, not the history loaded when you send a message.
### Session hygiene: why you should still run `/new`
Because gateway conversations never expire on their own, it is easy to run one
session for weeks. That works, but it quietly defeats the learning loop and
inflates costs:
- **Memory only pays off at boundaries.** `MEMORY.md` / `USER.md` are injected
at session start, and `session_search` exists to recall what fell out of
context. In a never-ending session everything is still *in* context, so the
agent has no reason to consult memory — the "self-learning" machinery barely
runs. Memory distillation (the save before reset) also only happens when a
session actually ends.
- **Cost grows with history.** Compression keeps a long session functional,
but every turn still carries a large (compacted) prefix. A fresh session
with distilled memory is almost always cheaper than a month-old thread.
Practical rule: end a session when you finish a task or topic. Run `/new`
(optionally named, e.g. `/new payments-refactor`) at natural stopping points —
daily or per-project both work. Before the reset, ask the agent to "remember
anything worth keeping" if the work surfaced durable preferences or
procedures; it saves memories and skills from the expiring session
automatically, but an explicit nudge helps. Restarting the machine or the
gateway is **not** a boundary — the same session resumes.
See [Memory](features/memory.md) for what gets carried across boundaries.
### Continuity After Crashes and Restarts
A gateway chat is designed to be **one continuous session** — compacted
repeatedly as it grows — until you explicitly run `/new` (or `/reset`). This
holds across gateway crashes, restarts, and updates:
- Session identity (routing key, chat, origin) is written **atomically** when
the session row is created, on every creation path (`/new`, first message,
`/branch` children). If that write ever fails, the very next turn's routing
refresh repairs the row automatically.
- After a restart, the gateway re-resolves each chat to the session with the
most recent **actual activity** — an older, stale row can never win over the
conversation you were actually having.
- Recovery **respects `/new` boundaries**: if the most recent event for a chat
is an intentional reset, recovery starts fresh rather than reaching behind
the reset to resurrect an older session. Elapsed time alone never prevents
recovery of a durable conversation.
## Storage Locations
| What | Path | Description |
|------|------|-------------|
| SQLite database | `~/.hermes/state.db` | All session metadata + messages with FTS5 |
| Gateway messages | `~/.hermes/state.db` | SQLite — canonical store for all session messages |
| Gateway routing index | `gateway_routing` table in `~/.hermes/state.db` | Maps session keys to active session IDs (origin metadata, expiry flags) |
| Legacy routing mirror | `~/.hermes/sessions/sessions.json` | Backward-compat mirror of the routing index, written when `gateway.write_sessions_json: true` (the default) |
The SQLite database uses WAL mode for concurrent readers and a single writer, which suits the gateway's multi-platform architecture well.
:::warning `sessions.json` is not the session list
The gateway routing index lives in the `gateway_routing` table inside
`state.db`; `~/.hermes/sessions/sessions.json` is a **legacy mirror** of it,
kept for backward compatibility (disable with
`gateway.write_sessions_json: false`). It maps messaging session keys
(`agent:main::...`) to active session IDs.
It only ever contains gateway/messaging entries, so if you run a messaging
platform you'll see only those (e.g. `agent:main:whatsapp:dm:...`).
This is **expected** and does **not** mean your CLI sessions are missing.
`hermes sessions list`, `/sessions`, and the dashboard all read `state.db`,
which holds **every** session (CLI, TUI, and gateway). The `/save` snapshots
under `~/.hermes/sessions/saved/*.json` are convenience exports, not the index.
If CLI sessions genuinely don't appear in `hermes sessions list`, the cause is
`state.db` not receiving them — run `hermes sessions repair` and watch for a
`⚠ Session store unavailable` warning at CLI startup, which means SQLite
persistence failed for that run.
:::
:::note Legacy JSONL transcripts
Sessions created before state.db became canonical may have leftover
`*.jsonl` files in `~/.hermes/sessions/`. They are no longer written or
read by Hermes. Safe to delete after verifying the corresponding session
exists in state.db.
:::
### Database Schema
Key tables in `state.db`:
- **sessions** — session metadata (id, source, user_id, model, title, timestamps, token counts). Titles have a unique index (NULL titles allowed, only non-NULL must be unique).
- **messages** — full message history (role, content, tool_calls, tool_name, token_count)
- **messages_fts** — FTS5 virtual table for full-text search across message content
## Session Expiry and Cleanup
### Automatic Cleanup
- Gateway conversations persist across inactivity; use `/new` or `/reset` for an explicit boundary
- Before reset, the agent saves memories and skills from the expiring session
- Auto-pruning (**on by default** since #54189): when `sessions.auto_prune` is `true`, ended sessions inactive for `sessions.retention_days` (default 90) are pruned at CLI/gateway/cron startup
- `sessions.retention_days` must be a whole number of days `>= 0`. A negative value (or a missing one) is rejected: startup maintenance logs a warning naming the allowed range and skips the sweep instead of treating the future cutoff as "everything" — `sessions.auto_prune: false` is the switch that disables pruning
- After a prune that actually removed rows, `state.db` is `VACUUM`ed to reclaim disk space only when **both** gates pass: at least `sessions.min_vacuum_interval_days` (default 30) have elapsed since the last successful `VACUUM`, **and** more than 25% of the file's pages are reclaimable (`PRAGMA freelist_count / page_count`). A dense database never pays for a full rewrite to reclaim a few MB (SQLite does not shrink the file on plain DELETE)
- Pruning runs at most once per `sessions.min_interval_hours` (default 24); the last-run timestamp is tracked inside `state.db` itself so it's shared across every Hermes process in the same `HERMES_HOME`
Without pruning, `state.db` grows without bound — multi-GB files within weeks were reported on gateway + cron installs. If you would rather keep every ended session forever (the pre-#54189 behavior), turn it off in `~/.hermes/config.yaml`:
```yaml
sessions:
auto_prune: false # default is true — set false to keep all history
retention_days: 90 # keep ended sessions active within this window
vacuum_after_prune: true # reclaim disk space after a pruning sweep
min_vacuum_interval_days: 30 # don't rewrite the DB more often than this
min_interval_hours: 24 # don't re-run the sweep more often than this
```
Existing installs that already set any of these keys explicitly keep their
values; only unset keys pick up the new defaults.
Only **ended** sessions are ever deleted. Active sessions are never auto-pruned,
regardless of age. Ended sessions are aged from their last activity — the
freshest of live activity, latest message, or session start — so a long-lived
conversation used recently is not deleted merely because it began before the
retention window.
**Stale open sessions from automation.** Some producers — cron jobs, kanban
workers, subagents, one-shot CLI runs — can die without ever marking their
session ended, and pruning only deletes *ended* rows. To keep those from
accumulating forever, each auto-prune pass also *closes* open sessions from
those state-owned sources (`cli`, `cron`, `kanban`, `acp`, `api_server`,
`subagent`, `tool`, plus the `recovered` placeholders that
`hermes sessions recover` synthesizes for orphaned messages) whose last
activity is older than `retention_days`
(`end_reason: startup_orphan_reap`). Closing is non-destructive — the
session stays resumable — and the row is aged from its close, so it is only
deleted by a *later* pass after a further full retention window. Messaging
platform sessions (Telegram, Discord, …), TUI/desktop sessions, pinned
sessions, and sessions with a live turn or compression in progress are
never closed by this sweep.
### Oversized-Transcript Guards
Two limits stop a runaway transcript from being loaded into memory all at once
(both default to `20000` active messages; `0` disables the guard):
```yaml
sessions:
max_resume_messages: 20000 # interactive resume (CLI / TUI / Desktop)
max_export_messages: 20000 # one-shot in-memory export of a single session
```
`max_resume_messages` bounds **what the resume actually loads**, not the whole
history of the conversation:
- A plain interactive resume (CLI `--resume`, the TUI) materializes the full
compression lineage — every compacted segment plus the live tip — so it is
bounded across the lineage.
- Desktop's cold resume pages the transcript over REST and only holds the live
tip segment in memory, so it is bounded by the tip alone. A long-lived chat
that has been compacted many times (dozens of segments, tens of thousands of
archived rows behind a small tip) is exactly what compression is meant to
produce and opens normally; its footer message count reflects the stored
lineage, not the live prompt.
When a resume is refused the client receives error code `4130` with the count
and the scope it was measured against (`across its lineage` or
`in its tip segment`). `hermes sessions export` still works for such sessions.
### Manual Cleanup
```bash
# Prune sessions older than 90 days
hermes sessions prune
# Delete a specific session
hermes sessions delete
# Export before pruning (backup)
hermes sessions export backup.jsonl
hermes sessions prune --older-than 30 --yes
```
:::tip
Auto-prune is **on by default**: ended sessions that have been inactive for `sessions.retention_days` (default 90) are removed at startup, and active sessions are never touched (see [Automatic Cleanup](#automatic-cleanup) above). Session history powers `session_search` recall across past conversations, so if you want to keep every ended session forever, set `sessions.auto_prune: false` in `config.yaml`, or raise `retention_days`. With auto-prune off, `hermes sessions prune` remains available for one-off cleanup (observed failure mode without any pruning: a 384 MB `state.db` with ~1000 sessions slowing down FTS5 inserts and `/resume` listing).
:::
---
# Profiles: Running Multiple Agents
# Profiles: Running Multiple Agents
Run multiple independent Hermes agents on the same machine — each with its own config, API keys, memory, sessions, skills, and gateway state.
## What are profiles?
A profile is a separate Hermes home directory. Each profile gets its own directory containing its own `config.yaml`, `.env`, `SOUL.md`, memories, sessions, skills, cron jobs, and state database. Hermes recognises a directory under `~/.hermes/profiles/` as a profile only when it carries one of those identity files (`config.yaml`, `.env`, `SOUL.md`, `profile.yaml`, `auth.json`, `state.db`); a bare directory left behind by logging or cron is ignored by `profile list`, gateways and `-p`. Profiles let you run separate agents for different purposes — a coding assistant, a personal bot, a research agent — without mixing up Hermes state.
:::caution Give every agent its own profile
Never point two agent processes at the same profile (the same Hermes home). Both write memory automatically, and each loads the other's writes into its system prompt at session start — so two writers on one home compound each other's state until it stops being anything you configured. Profiles exist exactly to prevent this; agents that need shared memory should use an [external memory provider](./features/memory-providers.md) instead.
:::
When you create a profile, it automatically becomes its own command. Create a profile called `coder` and you immediately have `coder chat`, `coder setup`, `coder gateway start`, etc.
### Profiles, agents, and bots
These terms describe different parts of Hermes:
- **Profile** is the persistent home for an assistant's configuration and data.
It keeps the same state across conversations and restarts.
- **Agent** is the running Hermes assistant that uses that configuration and
state. "Hermes Agent" also names the product.
- **Bot Mode bot** is a profile presented as a named entry in the desktop's
[Bot Mode](./bot-mode.md) roster, with an avatar and a persistent Bot Chat.
The same profile remains accessible from the CLI. Every Bot is a profile, but
not every profile is a Bot: a profile becomes a Bot when Bot Mode stores its
roster presentation (title, avatar, section, hidden state) in the profile's
metadata and pins its canonical Bot Chat. A profile you only ever drive from
the CLI, Docker, or a gateway and never add to the roster stays a plain profile.
- **Messaging bot** is an account on a platform such as Telegram, Discord, or
Slack, connected to Hermes through the gateway. Its
[bot token](#different-bot-tokens) identifies that platform account.
- **Subagent** is a child assistant spawned by
[`delegate_task`](./features/delegation.md) for a task, with a fresh
conversation. A separate conversation is different from a separate profile.
## Quick start
```bash
hermes profile create coder # creates profile + "coder" command alias
coder setup # configure API keys and model
coder chat # start chatting
```
That's it. `coder` is now its own Hermes profile with its own config, memory, and state.
## Creating a profile
:::tip
Quickest setup: run `hermes setup --portal` inside the new profile to wire up models + tools at once. See [Nous Portal](../integrations/nous-portal.md).
:::
### Blank profile
```bash
hermes profile create mybot
```
Creates a fresh profile with bundled skills seeded. Run `mybot setup` to configure API keys, model, and gateway tokens.
If you plan to use this profile as a kanban worker (or want the kanban orchestrator to route work to it), pass `--description ""` at create time so the orchestrator knows what it's good at:
```bash
hermes profile create researcher --description "Reads source code and external docs, writes findings."
```
You can also set or auto-generate the description later with `hermes profile describe` — see the [Kanban guide](./features/kanban#auto-vs-manual-orchestration) for the full routing model.
### Clone config only (`--clone`)
```bash
hermes profile create work --clone
```
Copies your current profile's `config.yaml`, `.env`, `SOUL.md`, skills, and the curated memory files `memories/MEMORY.md` and `memories/USER.md` into the new profile — memory is treated as part of the agent's identity, like `SOUL.md`. If `config.yaml` selects an external memory provider (`memory.provider`), that provider's own config travels too — its `/` directory or `.json` under the profile home, e.g. `hindsight/config.json` — so the clone's memory is available instead of silently off; a cloned `local_embedded` hindsight config still shares the source's embedded daemon and bank until you give the clone its own hindsight `profile`/`bank_id` ([#81815](https://github.com/NousResearch/hermes-agent/issues/81815)). Sessions, `state.db`, cron jobs and everything else start empty. For a blank memory as well, create the profile without `--clone` or delete the two files afterwards; the agent never falls back to another profile's memory when they are absent. Edit `~/.hermes/profiles/work/.env` for different API keys, or `~/.hermes/profiles/work/SOUL.md` for a different personality.
#### Keep a clone's imported agent setups synced (`--sync-imports`)
If the source profile has run [`hermes import-agent`](./import-from-other-agents.md), its `import-sync.json` records which external Claude Code / Codex trees it imported. `--clone` leaves that manifest behind, so the clone gets a one-off copy of those skills and memories and stops there. Add `--sync-imports` to carry the manifest over:
```bash
hermes profile create work --clone --sync-imports
hermes -p work import-agent --sync # pulls changes from the same ~/.claude / ~/.codex
```
This is explicit, opt-in and one-directional, and it links the clone to the **external agent trees only** — never to the source profile. Both profiles stay independent islands: editing the source's `config.yaml`, `SOUL.md` or skills afterwards never reaches the clone. `--clone-all` copies the manifest as part of the full copy.
### Clone everything (`--clone-all`)
```bash
hermes profile create backup --clone-all
```
Copies **everything** — config, API keys, personality, all memories, skills, plugins. A complete working snapshot. Per-profile history is excluded (session history, `state.db`, `backups/`, `state-snapshots/`, `checkpoints/`) — these belong to the source profile and can reach tens of GB. When cloning from the default profile, the local-model runtime trees (`models/`, `runtimes/`, `node/` — downloaded weights and managed binaries, re-fetched on demand) are skipped too, as `hermes backup` already does. **Cron jobs are not cloned** either: they are scheduled work bound to the source profile and its delivery channel, and a clone that inherited them would run every job twice (two gateways, same job ids). The new profile starts with an empty `cron/`. For a full backup including history and cron jobs, use `hermes profile export` or `hermes backup` instead.
:::note OAuth logins are shared, not copied
Anthropic (Claude Pro/Max), OpenAI Codex, and xAI OAuth logins use **single-use refresh tokens** — a copy of one is not a second credential, it is the same credential with two owners, and the first profile to refresh it revokes it for every other copy. `--clone-all` (and the dashboard's credential mirroring) therefore drops those OAuth rows from the clone. The new profile keeps reading the login from the root `~/.hermes/auth.json`, and a token refresh performed inside any profile is written back to root, so all profiles stay signed in. Static API keys are copied as usual. To give a profile its own separate OAuth login, run `hermes -p auth add ` inside it.
:::
### Clone from a specific profile
```bash
hermes profile create work --clone-from coder
```
`--clone-from ` selects the source profile directly and implies a config/skills/SOUL clone. Combine it with `--clone-all` when you want a full copy of that source profile:
```bash
hermes profile create work-backup --clone-from coder --clone-all
```
### Messaging channels are never cloned (`--clone-channels` to opt in)
Every clone — `--clone`, `--clone-from`, `--clone-all`, and the dashboard / Desktop / TUI
"clone from profile" option — copies the source **without its messaging channels**: bot tokens
and allowlists (`TELEGRAM_BOT_TOKEN`, `DISCORD_ALLOWED_USERS`, `WHATSAPP_ENABLED`,
`API_SERVER_KEY`, `WEBHOOK_SECRET`, …), the `platforms:` / `telegram:` / `discord:` sections of
`config.yaml`, `gateway.multiplex_profiles` / `profile_routes`, and (for `--clone-all`) the
pairing store, WhatsApp session and other per-bot state. Provider and tool API keys, the model
block, memory settings, skills and `SOUL.md` are copied as before. The command prints which
platforms were left behind.
The reason is that a bot can only belong to one profile: two standalone gateways holding the
same token fight over its long-poll, and a [multiplexed gateway](./multi-profile-gateways.md)
parks the duplicate adapter (and `hermes gateway migrate --multiplex` refuses with one
duplicate-credential blocker per platform). Configure the new profile's own bots with
`hermes -p setup` or the dashboard Messaging page.
```bash
hermes profile create twin --clone --clone-channels # keep the source's bots anyway
```
`--clone-channels` is refused when a running multiplexed gateway already serves the source
(the copy would be parked immediately) — from the CLI, the dashboard and the TUI alike — and
otherwise prints a warning naming the platforms now shared with the source. It is an error
without a clone flag. `hermes profile list` prints the same warning for any existing
profile whose bot credential is byte-identical to the default's, so older clones surface
before they bite.
**What counts as a channel setting** — the inventory is ownership-based and is judged in the
*source* profile's plugin scope (its private `plugins/` adapters included):
- every key an adapter declares (tokens, app/client ids, allowlists, allow-all switches, home
channels) and every key under its `_` prefix, including historical aliases
(`WECOM_*`, `SMS_*`/`TWILIO_*`, `QQ_*`, `HASS_*`, `EMAIL_*`);
- gateway-wide channel policy `GATEWAY_ALLOW_ALL_USERS` / `GATEWAY_ALLOWED_USERS` and the
relay enrollment identity `GATEWAY_RELAY_ID` / `GATEWAY_RELAY_SECRET` / `GATEWAY_RELAY_DELIVERY_KEY`;
- for `--clone-all`, per-bot state files **and directories** (`platforms/`, pairing ledgers,
`google_chat_user_tokens/`, `_*`).
Credentials that a messaging adapter shares with a non-channel capability — `HASS_TOKEN`/`HASS_URL`
(also the Home Assistant tool), `TWILIO_*` (also the telephony skill), `EMAIL_*` (also
mail-sending scripts) — are stripped **only when the source's gateway would run that adapter**
(the platform is enabled in its `config.yaml`, or its credential set is complete and not
explicitly disabled). A source with `platforms.homeassistant.enabled: false` uses `HASS_TOKEN`
as a tool key, so the clone keeps it. Allowlists and ports under those prefixes are always
channel-only and always stripped.
Clones are built in a hidden staging directory beside `profiles/` and published with one
rename after the strip, so a running multiplexer (which rescans `profiles/` on create) can
never start adapters on a half-copied tree. A symlinked source `.env`/`config.yaml` is
materialized as a private copy first — the clone never writes through to the source.
:::tip Honcho memory + profiles
When Honcho is enabled, clone operations automatically create a dedicated AI peer for the new profile while sharing the same user workspace. Each profile builds its own observations and identity. See [Honcho -- Multi-agent / Profiles](./features/memory-providers.md#honcho) for details.
:::
## Using profiles
### Command aliases
Every profile automatically gets a command alias at `~/.local/bin/`:
```bash
coder chat # chat with the coder agent
coder setup # configure coder's settings
coder gateway start # start coder's gateway
coder doctor # check coder's health
coder skills list # list coder's skills
coder config set model.default anthropic/claude-sonnet-4
```
The alias works with every hermes subcommand — it's just `hermes -p ` under the hood.
### The `-p` flag
You can also target a profile explicitly with any command:
```bash
hermes -p coder chat
hermes --profile=coder doctor
hermes chat -p coder -q "hello" # works in any position
```
### Sticky default (`hermes profile use`)
```bash
hermes profile use coder
hermes chat # now targets coder
hermes tools # configures coder's tools
hermes profile use default # switch back
```
Sets a default so plain `hermes` commands target that profile. Like `kubectl config use-context`.
### Knowing where you are
The CLI always shows which profile is active:
- **Prompt**: `coder ❯` instead of `❯`
- **Banner**: Shows `Profile: coder` on startup
- **`hermes profile`**: Shows current profile name, path, model, gateway status
## Profiles vs workspaces vs sandboxing
Profiles are often confused with workspaces or sandboxes, but they are different things:
- A **profile** gives Hermes its own state directory: `config.yaml`, `.env`, `SOUL.md`, sessions, memory, logs, cron jobs, and gateway state.
- A **workspace** or **working directory** is where terminal commands start. That is controlled separately by `terminal.cwd`.
- A **sandbox** is what limits filesystem access. Profiles do **not** sandbox the agent.
On the default `local` terminal backend, the agent still has the same filesystem access as your user account. A profile does not stop it from accessing folders outside the profile directory.
If you want a profile to start in a specific project folder, set an explicit absolute `terminal.cwd` in that profile's `config.yaml`:
```yaml
terminal:
backend: local
cwd: /absolute/path/to/project
```
Using `cwd: "."` on the local backend means "the directory Hermes was launched from", not "the profile directory".
Also note:
- `SOUL.md` can guide the model, but it does not enforce a workspace boundary.
- Changes to `SOUL.md` take effect cleanly on a new session. Existing sessions may still be using the old prompt state.
- Asking the model "what directory are you in?" is not a reliable isolation test. If you need a predictable starting directory for tools, set `terminal.cwd` explicitly.
## Running gateways
Each profile runs its own gateway as a separate process with its own bot token:
```bash
coder gateway start # starts coder's gateway
assistant gateway start # starts assistant's gateway (separate process)
```
### Different bot tokens
Each profile has its own `.env` file. Configure a different Telegram/Discord/Slack bot token in each:
```bash
# Edit coder's tokens
nano ~/.hermes/profiles/coder/.env
# Edit assistant's tokens
nano ~/.hermes/profiles/assistant/.env
```
### Safety: token locks
If two profiles accidentally use the same bot token, the second gateway will be blocked with a clear error naming the conflicting profile. Supported for Telegram, Discord, Slack, WhatsApp, and Signal.
### Persistent services
```bash
coder gateway install # creates hermes-gateway-coder systemd/launchd service
assistant gateway install # creates hermes-gateway-assistant service
```
Each profile gets its own service name. They run independently.
:::note Inside the official Docker image
Per-profile gateways are supervised by [s6-overlay](https://github.com/just-containers/s6-overlay) (PID 1 in the container), so `hermes profile create ` automatically registers an s6 service slot at `/run/service/gateway-/`. `hermes -p gateway start/stop/restart` dispatches to `s6-svc` instead of spawning a bare process — crashes are auto-restarted and `docker restart` preserves the previously-running set of gateways. See [Per-profile gateway supervision](./docker.md#per-profile-gateway-supervision) for details.
:::
## Configuring profiles
Each profile has its own:
- **`config.yaml`** — model, provider, toolsets, all settings
- **`.env`** — API keys, bot tokens
- **`SOUL.md`** — personality and instructions
```bash
coder config set model.default anthropic/claude-sonnet-4
echo "You are a focused coding assistant." > ~/.hermes/profiles/coder/SOUL.md
```
If you want this profile to work in a specific project by default, also set its own `terminal.cwd`:
```bash
coder config set terminal.cwd /absolute/path/to/project
```
### From the dashboard
The [web dashboard](features/web-dashboard.md#managing-multiple-profiles)
is a machine-level surface that can manage **any** profile's config, API
keys, skills, MCPs, and model via the profile switcher in its sidebar — no
per-profile dashboard needed. `coder dashboard` routes to the machine
dashboard with the `coder` profile preselected. The dashboard's Chat tab
also follows the switcher, spawning a conversation under the selected
profile's home.
Note: "Set as active" on the dashboard's Profiles page is the sticky
default for **future CLI/gateway runs** (same as `hermes profile use`) —
to edit a profile from the dashboard, use the switcher instead.
## Updating
`hermes update` pulls code once (shared) and syncs new bundled skills to **all** profiles automatically:
```bash
hermes update
# → Code updated (12 commits)
# → Skills synced: default (up to date), coder (+2 new), assistant (+2 new)
```
User-modified skills are never overwritten.
Dependency preparation reads every profile's `config.yaml` to compute the
plugin set the shared environment must carry. A profile whose `config.yaml`
does not parse (or whose `plugins` / `memory` sections have the wrong shape)
fails that step for the whole install — see
[Dependency preparation and preservation](./features/plugins.md#dependency-preparation-and-preservation).
## Managing profiles
```bash
hermes profile list # show all profiles with status
hermes profile show coder # detailed info for one profile
hermes profile rename coder dev-bot # rename (updates alias + service)
hermes profile migrate-identity coder dev-bot # retry a rename's identity migration
hermes profile purge-identity dev-bot # retry a delete's identity purge
hermes profile export coder # pack into coder.tar.gz (shareable; keys stripped)
hermes profile import coder.tar.gz # install an archive as a new profile
```
In chat, the same two live as `/export` and `/import` — and in the desktop app as **⌘K → Export/Import profile…**. See [Sharing a profile](#sharing-a-profile).
### Naming the default profile
The default profile's internal ID is always `default` — it can't be truly
renamed because `~/.hermes` is the installation root. Renaming it instead
sets a **display name**, which UI surfaces show in place of the bare ID:
```bash
hermes profile rename default Harumesu # Unicode fine: 小助手
```
The display name appears in `hermes profile list`/`show`, the `/profile`
chat command, the dashboard, and the desktop app (including the Bot Mode
roster). It is presentation-only: `-p default`, service names, cron jobs,
and every other reference keep using the canonical `default` ID. It is
stored as `display_name` in `~/.hermes/profile.yaml`; remove that line to
revert. Named profiles can carry a `display_name` too (it survives a real
rename), but `rename` for them still renames the profile itself.
## Deleting a profile
```bash
hermes profile delete coder
```
This stops the gateway, removes the systemd/launchd service, removes the command alias, and deletes all profile data. You'll be asked to type the profile name to confirm.
Use `--yes` to skip confirmation: `hermes profile delete coder --yes`
:::note
You cannot delete the default profile (`~/.hermes`). To remove everything, use `hermes uninstall`.
:::
## Tab completion
```bash
# Bash
eval "$(hermes completion bash)"
# Zsh
eval "$(hermes completion zsh)"
```
Add the line to your `~/.bashrc` or `~/.zshrc` for persistent completion. Completes profile names after `-p`, profile subcommands, and top-level commands.
## How it works
Profiles use the `HERMES_HOME` environment variable. When you run `coder chat`, the wrapper script sets `HERMES_HOME=~/.hermes/profiles/coder` before launching hermes. Since 119+ files in the codebase resolve paths via `get_hermes_home()`, Hermes state automatically scopes to the profile's directory — config, sessions, memory, skills, state database, gateway PID, logs, and cron jobs.
This is separate from terminal working directory. Tool execution starts from `terminal.cwd` (or the launch directory when `cwd: "."` on the local backend), not automatically from `HERMES_HOME`.
On host installs, tool subprocesses keep your real OS-user `HOME` by default so
existing CLI credentials under `~` keep working across profiles. Profile data is
isolated by `HERMES_HOME`, not by changing `HOME`. Container backends still use
`{HERMES_HOME}/home` for persistent tool state, and host users who need strict
per-profile tool config can opt in with `terminal.home_mode: profile`.
This means two things that are easy to mix up:
- `HERMES_HOME` is the profile boundary. It controls Hermes config, `.env`,
memory, sessions, skills, logs, cron jobs, gateway state, and other Hermes
data.
- `HOME` is the operating-system/user home that external CLIs expect. On host
installs, Hermes keeps it as the real user home by default so tools like
`git`, `ssh`, `gh`, `az`, `npm`, Claude Code, and Codex find the same
credentials they use in your normal shell.
The tradeoff is that host profiles share normal user-level CLI state by default.
If you need separate CLI identities per profile, set `terminal.home_mode:
profile` in that profile's `config.yaml`. In that mode Hermes launches tool
subprocesses with `HOME={HERMES_HOME}/home`; you then need to initialize or link
the profile-specific `~/.ssh`, `~/.gitconfig`, `~/.config/gh`, cloud CLI auth,
Claude/Codex auth, npm state, and similar files inside that profile home.
Hermes also exposes `HERMES_REAL_HOME` to subprocesses so scripts can still find
the actual account home when `home_mode: profile` is active.
The default profile is simply `~/.hermes` itself. No migration needed — existing installs work identically.
## Sharing a profile
A profile you built on one machine can go to another — your own workstation, a teammate's laptop, or the community. Two paths:
**Send a file.** `/export` packs the profile into one `.tar.gz` — skills, memory, persona, crons, plugins, settings, and (from the desktop) your theme and layout. API keys are stripped. The recipient runs `/import`.
Machine-specific PM state is not portable. Export, import, and distribution
install exclude `installs/`, `tools/`, and `cache/` at a profile's root.
Backup and restore apply the same rule to the default home and named profiles.
Older archives cannot replace the destination machine's PM selections or tools.
Files such as `plugins/example/facts.json` and `skills/example/tools/helper.py`
remain user data and are preserved. Install dependencies on the destination
through [PM](../reference/package-management.md), rather than copying environments.
```bash
# In chat, run /export, hand over the file, and they run /import on it
hermes profile export coder
hermes profile import ./coder.tar.gz --name coder
```
**Publish a distribution.** Package the profile as a **git repository** so recipients install it with one command and pull versioned updates later. Carries the SOUL, config, skills, cron jobs, and MCP connections; credentials, memories, and sessions stay per-machine.
```bash
# Install a whole agent from a git repo
hermes profile install github.com/you/research-bot --alias
# Update later when the author ships a new version (keeps your memories + .env)
hermes profile update research-bot
```
Use an export file for a one-time handoff or a move; use a distribution for an agent you'll keep shipping. See **[Profile Distributions: Share a Whole Agent](./profile-distributions.md)** for both — the comparison table, authoring, publishing, update semantics, and the security model.
---
# Git Worktrees
# Git Worktrees
Hermes Agent is often used on large, long‑lived repositories. When you want to:
- Run **multiple agents in parallel** on the same project, or
- Keep experimental refactors isolated from your main branch,
Git **worktrees** are the safest way to give each agent its own checkout without duplicating the entire repository.
This page shows how to combine worktrees with Hermes so each session has a clean, isolated working directory.
## Why Use Worktrees with Hermes?
Hermes treats the **current working directory** as the project root:
- CLI: the directory where you run `hermes` or `hermes chat`
- Messaging gateways: the directory set by `terminal.cwd` in `~/.hermes/config.yaml`
If you run multiple agents in the **same checkout**, their changes can interfere with each other:
- One agent may delete or rewrite files the other is using.
- It becomes harder to understand which changes belong to which experiment.
With worktrees, each agent gets:
- Its **own branch and working directory**
- Its **own Checkpoint Manager history** for `/rollback`
See also: [Checkpoints and /rollback](./checkpoints-and-rollback.md).
## Quick Start: Creating a Worktree
### From inside a session: `/worktree new`
The fastest path (inspired by Copilot CLI's `/worktree new`): from an
interactive CLI session, run
```
/worktree new my-experiment
```
Hermes creates `.worktrees/my-experiment/` inside the repo (branch
`hermes/my-experiment`, based on the freshly-fetched remote tip unless
`worktree_sync: false`), and retargets the session's terminal and file tools
into it — no restart needed. Omit the name to get a random `hermes-`
tree. `/worktree` alone shows the active tree; `/worktree list` lists all of
them. On exit the tree is kept only if it has unpushed commits, exactly like
`hermes -w`.
### Manually with git
From your main repository (containing `.git/`), create a new worktree for a feature branch:
```bash
# From the main repo root
cd /path/to/your/repo
# Create a new branch and worktree in ../repo-feature
git worktree add ../repo-feature feature/hermes-experiment
```
This creates:
- A new directory: `../repo-feature`
- A new branch: `feature/hermes-experiment` checked out in that directory
Now you can `cd` into the new worktree and run Hermes there:
```bash
cd ../repo-feature
# Start Hermes in the worktree
hermes
```
Hermes will:
- See `../repo-feature` as the project root.
- Use that directory for context files, code edits, and tools.
- Use a **separate checkpoint history** for `/rollback` scoped to this worktree.
## Running Multiple Agents in Parallel
You can create multiple worktrees, each with its own branch:
```bash
cd /path/to/your/repo
git worktree add ../repo-experiment-a feature/hermes-a
git worktree add ../repo-experiment-b feature/hermes-b
```
In separate terminals:
```bash
# Terminal 1
cd ../repo-experiment-a
hermes
# Terminal 2
cd ../repo-experiment-b
hermes
```
Each Hermes process:
- Works on its own branch (`feature/hermes-a` vs `feature/hermes-b`).
- Writes checkpoints under a different shadow repo hash (derived from the worktree path).
- Can use `/rollback` independently without affecting the other.
This is especially useful when:
- Running batch refactors.
- Trying different approaches to the same task.
- Pairing CLI + gateway sessions against the same upstream repo.
## Cleaning Up Worktrees Safely
When you are done with an experiment:
1. Decide whether to keep or discard the work.
2. If you want to keep it:
- Merge the branch into your main branch as usual.
3. Remove the worktree:
```bash
cd /path/to/your/repo
# Remove the worktree directory and its reference
git worktree remove ../repo-feature
```
Notes:
- `git worktree remove` will refuse to remove a worktree with uncommitted changes unless you force it.
- Removing a worktree does **not** automatically delete the branch; you can delete or keep the branch using normal `git branch` commands.
- Hermes checkpoint data under `~/.hermes/checkpoints/` is not automatically pruned when you remove a worktree, but it is usually very small.
## Best Practices
- **One worktree per Hermes experiment**
- Create a dedicated branch/worktree for each substantial change.
- This keeps diffs focused and PRs small and reviewable.
- **Name branches after the experiment**
- e.g. `feature/hermes-checkpoints-docs`, `feature/hermes-refactor-tests`.
- **Commit frequently**
- Use git commits for high‑level milestones.
- Use [checkpoints and /rollback](./checkpoints-and-rollback.md) as a safety net for tool‑driven edits in between.
- **Avoid running Hermes from the bare repo root when using worktrees**
- Prefer the worktree directories instead, so each agent has a clear scope.
## Using `hermes -w` (Automatic Worktree Mode)
Hermes has a built‑in `-w` flag that **automatically creates a disposable git worktree** with its own branch. You don't need to set up worktrees manually — just `cd` into your repo and run:
```bash
cd /path/to/your/repo
hermes -w
```
Hermes will:
- Create a temporary worktree under `.worktrees/` inside your repo.
- Check out an isolated branch (e.g. `hermes/hermes-`).
- Run the full CLI session inside that worktree.
This is the easiest way to get worktree isolation. You can also combine it with a single query:
```bash
hermes -w -z "Fix issue #123"
```
For parallel agents, open multiple terminals and run `hermes -w` in each — every invocation gets its own worktree and branch automatically.
## Putting It All Together
- Use **git worktrees** to give each Hermes session its own clean checkout.
- Use **branches** to capture the high‑level history of your experiments.
- Use **checkpoints + `/rollback`** to recover from mistakes inside each worktree.
This combination gives you:
- Strong guarantees that different agents and experiments do not step on each other.
- Fast iteration cycles with easy recovery from bad edits.
- Clean, reviewable pull requests.
## Developing the UI surfaces across worktrees
The TypeScript surfaces (`ui-tui/`, `apps/desktop/`) each need a `node_modules`, which a fresh `npm ci` per worktree duplicates across every branch. If you hack on the TUI or desktop app from multiple worktrees, see [TUI & Desktop from Worktrees](../developer-guide/worktree-ui-dev.md) for the `htui` / `hgui` helpers that share one install by symlink.
---
# Hermes Docker Setup
# Hermes Docker Setup
There are two distinct ways Docker intersects with Hermes Agent:
1. **Running Hermes IN Docker** — the agent itself runs inside a container (this page's primary focus)
2. **Docker as a terminal backend** — the agent runs on your host but executes every command inside a single, persistent Docker sandbox container that survives across tool calls, `/new`, and subagents for the life of the Hermes process (see [Configuration → Docker Backend](./configuration.md#docker-backend))
This page covers option 1. The container stores all user data (config, API keys, sessions, skills, memories) in a single directory mounted from the host at `/opt/data`. The image itself is stateless and can be upgraded by pulling a new version without losing any configuration.
## Image channels and runtime ownership
| Image tag | Meaning |
|---|---|
| `latest` / `stable` | The image accepted by the stable release gate. |
| `main` | The development image published from main-branch builds. |
| `X.Y.Z` | A versioned stable image. Use a digest for an exact deployment pin. |
The workflow builds and tests amd64 and arm64 images. Stable publication uses
the tested image archives rather than rebuilding them. Only the full release
promotion moves `stable` and `latest`; a main push does not advance those tags.
The image's Python environment follows `pyproject.toml` and `uv.lock` (currently
Python 3.14). Its curated extras are not the native desktop bundle's
`--all-extras` set. It does not include an Electron desktop app.
## Quick start
If this is your first time running Hermes Agent, create a data directory on the host and start the container interactively to run the setup wizard:
:::caution Avoid browser-based VPS consoles for the install commands
Some VPS providers (Hetzner Cloud, and several others) offer a browser-based
console for managing hosts. These consoles transmit special characters
incorrectly — `:` may arrive as `;`, `@` may be mis-rendered, and non-English
keyboard layouts fare worse — which silently corrupts `docker run` arguments
like `-v ~/.hermes:/opt/data`, `-e KEY=value`, and pasted API keys / tokens.
**Connect over SSH instead** (`ssh root@`) for copy-paste-safe command
entry. If you must use the browser console, type the commands manually
instead of pasting, and double-check every `:`, `@`, `=`, and `/` in the
result before hitting Enter.
:::
```sh
mkdir -p ~/.hermes
docker run -it --rm \
-v ~/.hermes:/opt/data \
nousresearch/hermes-agent setup
```
This drops you into the setup wizard, which will prompt you for your API keys and write them to `~/.hermes/.env`. You only need to do this once. It is highly recommended to set up a chat system for the gateway to work with at this point.
:::tip
Inside the container, run `hermes setup --portal` once — the refresh token persists in the mounted `~/.hermes` volume. See [Nous Portal](../integrations/nous-portal.md).
:::
## Running in gateway mode
Once configured, run the container in the background as a persistent gateway (Telegram, Discord, Slack, WhatsApp, etc.):
```sh
docker run -d \
--name hermes \
--restart unless-stopped \
-v ~/.hermes:/opt/data \
-p 8642:8642 \
nousresearch/hermes-agent gateway run
```
Port 8642 exposes the gateway's [OpenAI-compatible API server](./features/api-server.md) and health endpoint. It's optional if you only use chat platforms (Telegram, Discord, etc.), but required if you want the dashboard or external tools to reach the gateway.
:::tip Gateway runs supervised
Inside the official Docker image, `gateway run` is **automatically supervised by s6-overlay**: if the gateway process crashes it's restarted within a couple of seconds without losing the container, and the dashboard (when `HERMES_DASHBOARD=1` is set) is supervised alongside it. The `gateway run` CMD process itself is a `sleep infinity` heartbeat that keeps the container alive while s6 manages the actual gateway process — so `docker stop` still shuts everything down cleanly, but `docker logs` shows the supervised gateway's output.
You'll see a one-line breadcrumb in `docker logs` confirming the upgrade. To opt out — and get the historical "gateway is the container's main process, container exit = gateway exit" semantics — pass `--no-supervise` or set `HERMES_GATEWAY_NO_SUPERVISE=1`. The opt-out is useful for CI smoke tests that want the container to exit with the gateway's status code; for production deployments the supervised default is strictly better.
This behavior applies to the s6-based image only. Earlier (tini-based) images still run `gateway run` as the foreground main process.
:::
:::note Where gateway logs go
See the [Where the logs go](#where-the-logs-go) section below for the full routing map (per-profile gateways, dashboard, boot reconciler, container-wide `docker logs`).
:::
:::note Tool-loop hard stops for unattended gateways
Unattended gateway and cron sessions enable tool-loop hard stops by default through `non_interactive_hard_stop_enabled`. Interactive CLI, TUI, Desktop, and ACP sessions remain warning-only. To opt an unattended deployment out in the profile's `config.yaml`:
```yaml
tool_loop_guardrails:
non_interactive_hard_stop_enabled: false
```
:::
Note: the API server is gated on `API_SERVER_ENABLED=true`. To expose it beyond `127.0.0.1` inside the container, also set `API_SERVER_HOST=0.0.0.0` and an `API_SERVER_KEY` (minimum 8 characters — generate one with `openssl rand -hex 32`). Example:
```sh
docker run -d \
--name hermes \
--restart unless-stopped \
-v ~/.hermes:/opt/data \
-p 8642:8642 \
-e API_SERVER_ENABLED=true \
-e API_SERVER_HOST=0.0.0.0 \
-e API_SERVER_KEY="$(openssl rand -hex 32)" \
-e API_SERVER_CORS_ORIGINS='*' \
nousresearch/hermes-agent gateway run
```
Opening any port on an internet facing machine is a security risk. You should not do it unless you understand the risks.
## Running the dashboard
The built-in web dashboard runs as a supervised s6-rc service alongside the gateway in the same container. Set `HERMES_DASHBOARD=1` to bring it up:
```sh
docker run -d \
--name hermes \
--restart unless-stopped \
-v ~/.hermes:/opt/data \
-p 8642:8642 \
-p 9119:9119 \
-e HERMES_DASHBOARD=1 \
nousresearch/hermes-agent gateway run
```
The dashboard is supervised by s6 — if it crashes, `s6-supervise` restarts it automatically after a short backoff. Dashboard stdout/stderr is forwarded to `docker logs ` (no prefix; the gateway's own output now lives in a per-profile s6-log file — see [Where the logs go](#where-the-logs-go) below — so the two streams don't clash).
| Environment variable | Description | Default |
|---------------------|-------------|---------|
| `HERMES_DASHBOARD` | Set to `1` (or `true` / `yes`) to enable the supervised dashboard service | *(unset — service is registered but stays down)* |
| `HERMES_DASHBOARD_HOST` | Bind address for the dashboard HTTP server | `0.0.0.0` |
| `HERMES_DASHBOARD_PORT` | Port for the dashboard HTTP server | `9119` |
| `HERMES_DASHBOARD_INSECURE` | **Deprecated / no-op.** Formerly bypassed the auth gate; as of the June 2026 hardening it no longer disables authentication. A non-loopback bind always requires an auth provider | *(ignored — configure a provider instead)* |
The dashboard inside the container defaults to binding `0.0.0.0` — without it, the published `-p 9119:9119` port would not be reachable from the host. To restrict the bind to container loopback (for sidecar / reverse-proxy setups), set `HERMES_DASHBOARD_HOST=127.0.0.1`.
The dashboard's auth gate engages automatically when both of the following are true:
1. The bind host is non-loopback (e.g. the default `0.0.0.0` inside the container), **and**
2. A `DashboardAuthProvider` plugin is registered.
There are three bundled ways to satisfy the second condition:
- **Username/password** — the simplest for a self-hosted / on-prem / homelab container on a trusted network or behind a VPN: set `HERMES_DASHBOARD_BASIC_AUTH_USERNAME` + `HERMES_DASHBOARD_BASIC_AUTH_PASSWORD` (and `HERMES_DASHBOARD_BASIC_AUTH_SECRET` for restart-stable sessions). Not suitable for direct public-internet exposure.
- **OAuth (Nous Portal)** — for hosted/public deploys: the `dashboard_auth/nous` provider activates whenever `HERMES_DASHBOARD_OAUTH_CLIENT_ID` is set.
- **Self-hosted OIDC** — to authenticate against your own identity provider via standard OpenID Connect: the `dashboard_auth/self_hosted` provider activates when `HERMES_DASHBOARD_OIDC_ISSUER` + `HERMES_DASHBOARD_OIDC_CLIENT_ID` are set.
Whichever you choose, the gate redirects callers to a login page before they can reach any protected route. See [Web Dashboard → Authentication](features/web-dashboard.md#authentication-gated-mode) for all three providers.
When a reverse proxy such as Traefik or nginx runs in another container, its
bridge-network address is not trusted by default. Set the dashboard's public
URL and trust only that proxy's exact IP, or a bounded CIDR for a dedicated
proxy network, in the mounted `config.yaml`:
```yaml
dashboard:
public_url: "https://dashboard.example.com"
trusted_proxies:
- "172.20.0.5"
# Or, if the proxy address is dynamic on a dedicated network:
# - "172.20.0.0/24"
```
This allows the proxy's `X-Forwarded-Proto: https` to control secure OAuth
cookies while leaving forwarding headers from other peers untrusted. Do not
use `*`, `0.0.0.0/0`, or `::/0`; Hermes rejects those unbounded entries.
If no provider is registered and the bind is non-loopback, the dashboard **fails closed at startup** with a specific error pointing at the missing env var. There is no longer an escape hatch that serves the dashboard unauthenticated on a public bind: `HERMES_DASHBOARD_INSECURE=1` is now a deprecated no-op (it logs a warning and is ignored). Configure a provider, or bind `HERMES_DASHBOARD_HOST=127.0.0.1` and reach the dashboard over an SSH tunnel / Tailscale instead.
:::warning Why `--insecure` was removed
An unauthenticated public dashboard was the entry point for the June 2026 MCP-config persistence campaign: internet scanners reached exposed dashboards (and OpenAI API servers) and drove the agent into planting an SSH-key backdoor. The auth gate is now mandatory on every non-loopback bind. For a trusted-LAN / homelab box, the bundled username/password provider (`HERMES_DASHBOARD_BASIC_AUTH_USERNAME` + `_PASSWORD`) is the zero-infra way to satisfy it.
:::
Running the dashboard as a separate container **is** supported when that container shares the host PID and network namespace (e.g. `network_mode: host`, as the repo's own `docker-compose.yml` does — see its `dashboard` service). Its gateway-liveness detection requires a shared PID namespace with the gateway process, so the limitation only applies to dashboards run in isolated bridge-network containers without a shared PID namespace.
## Running interactively (CLI chat)
To open an interactive chat session against a running data directory:
```sh
docker run -it --rm \
-v ~/.hermes:/opt/data \
nousresearch/hermes-agent
```
Or if you have already opened a terminal in your running container (via Docker Desktop for instance), just run:
```sh
/opt/hermes/.venv/bin/hermes
```
## Persistent volumes
The `/opt/data` volume is the single source of truth for all Hermes state. It maps to your host's `~/.hermes/` directory and contains:
| Path | Contents |
|------|----------|
| `.env` | API keys and secrets |
| `config.yaml` | All Hermes configuration |
| `SOUL.md` | Agent personality/identity |
| `sessions/` | Conversation history |
| `memories/` | Persistent memory store |
| `skills/` | Installed skills |
| `home/` | Per-profile HOME for Hermes tool subprocesses (`git`, `ssh`, `gh`, `npm`, and skill CLIs) |
| `cron/` | Scheduled job definitions |
| `hooks/` | Event hooks |
| `logs/` | Runtime logs |
| `skins/` | Custom CLI skins |
### Filesystem requirements for `state.db` in containers
Hermes keeps sessions in a SQLite database (`/opt/data/state.db`) that is opened in WAL journal mode by default. WAL relies on shared memory (`state.db-shm`) being coherent between every process that has the file open. Bind mounts that cross a VM boundary do not provide that: **virtiofs** (Docker Desktop and Podman on macOS, OrbStack) and **9p / drvfs** (Docker Desktop on Windows) both let concurrent writers silently corrupt a WAL database while the main file still passes `PRAGMA integrity_check`.
What Hermes does about it (since v2026.9.14):
- A **fresh** database whose directory is on a virtiofs/9p mount is created in rollback (`DELETE`) journal mode and a one-time warning is logged. Nothing to do.
- An **existing** WAL database on such a mount is never live-downgraded — other Hermes processes may hold it open, and a live switch destroys their uncheckpointed commits. Instead, every process logs a one-time error at startup and `hermes doctor` flags the database. Fix it one of two ways:
1. Stop every Hermes process that uses the database, then run a one-time offline conversion with the Python that ships in the image (it has no `sqlite3` shell): `docker exec hermes python3 -c "import sqlite3; print(sqlite3.connect('/opt/data/state.db').execute('PRAGMA journal_mode=DELETE').fetchone()[0])"`. Set `database.journal_mode: delete` in `config.yaml` so a later open does not switch it back to WAL.
2. Move the data directory onto a native volume — a named Docker volume (`-v hermes-data:/opt/data`) lives on the VM's own ext4 filesystem and supports WAL normally.
Detection reads `/proc/self/mountinfo` inside the container, so it works regardless of the host operating system. It does not classify NFS, SMB, or generic FUSE mounts; on those, set `database.journal_mode: delete` explicitly. Hermes does not offer SQLite's `locking_mode=EXCLUSIVE` as an alternative because the gateway, cron, and worker processes open the database concurrently.
### Immutable install tree
In hosted and published Docker images, `/opt/hermes` is the installed application tree. It is root-owned and read-only to the runtime `hermes` user, so agent turns, gateway sessions, dashboard actions, and normal `docker exec hermes hermes ...` commands cannot edit the core source, bundled `.venv`, `node_modules`, or TUI bundle in place.
All mutable Hermes state belongs under `/opt/data`: config, `.env`, profiles, skills, memories, sessions, logs, dashboard uploads, plugins, and other user-managed files. The image also disables runtime `.pyc` writes and Hermes lazy dependency installs into `/opt/hermes`; optional platform dependencies needed by the published image should be baked into the image or installed through a new image build.
On hosted/published images, agent self-improvement is scoped to skills, memory, plugins, and config under `/opt/data`. The installed core source under `/opt/hermes` is immutable; core changes are made via PRs to the repo and shipped by updating the image, not by live-editing the running install.
If an operator needs to repair or inspect files outside `/opt/data`, use a root shell intentionally. The `hermes` shim normally drops `docker exec hermes hermes ...` back to the runtime user; set `HERMES_DOCKER_EXEC_AS_ROOT=1` for a one-off root invocation when you explicitly need root semantics.
Skill CLIs that store credentials under `~` must be initialized against the subprocess HOME, not just the data-volume root. For example, the [xurl skill](./skills/bundled/social-media/social-media-xurl.md) stores OAuth state in `~/.xurl`; in the official Docker layout, Hermes tool calls read that as `/opt/data/home/.xurl`, so run manual xurl auth with `HOME=/opt/data/home` and verify with `HOME=/opt/data/home xurl auth status`.
:::warning
Never run two Hermes **gateway** containers against the same data directory simultaneously — session files and memory stores are not designed for concurrent write access.
:::
## Multi-profile support
Hermes supports [multiple profiles](../reference/profile-commands.md) — separate `~/.hermes/` subdirectories that let you run independent agents (different SOUL, skills, memory, sessions, credentials) from a single installation. **Inside the official Docker image, the s6 supervision tree treats each profile as a first-class supervised service**, so the recommended deployment is **one container hosting all profiles**.
Each profile created with `hermes profile create ` gets:
- A dedicated s6 service slot at `/run/service/gateway-/`, registered dynamically by the runtime — no container rebuild required.
- Auto-restart on crash, backoff-managed by `s6-supervise`.
- Per-profile rotated logs at `${HERMES_HOME}/logs/gateways//current` (10 archives × 1 MB each).
- State persistence across container restarts: the boot-time reconciler reads `gateway_state.json` from each profile directory and brings the slot back up only for profiles whose last recorded state was `running`. Only a gateway you explicitly stopped (`hermes gateway stop`) stays down across a restart — a container restart, image upgrade, or unexpected exit leaves the recorded state as `running`, so the gateway auto-starts on the next boot.
A profile created from the **host** against a bind-mounted `~/.hermes` gets its directory but no slot (the host process cannot reach the container's `/run/service`). Inside the container, `hermes -p gateway start` registers the missing slot on demand and starts it — no `docker restart` needed. Only `start` does this, and only for a real profile directory (one carrying `SOUL.md`); `stop`/`restart` on an unregistered profile and a mistyped `-p` name still fail with `✗ no such gateway`.
The lifecycle commands you'd run on the host work the same way from inside the container:
```sh
# Create a profile — registers the gateway- s6 slot.
docker exec hermes hermes profile create coder
# Start / stop / restart — dispatches s6-svc; the gateway lifecycle survives docker restart.
docker exec hermes hermes -p coder gateway start
docker exec hermes hermes -p coder gateway stop
docker exec hermes hermes -p coder gateway restart
# Status — reports `Manager: s6 (container supervisor)` inside the container.
docker exec hermes hermes -p coder gateway status
# Remove a profile — tears down the s6 slot too.
docker exec hermes hermes profile delete coder
```
Under the hood, `hermes gateway start/stop/restart` inside the container is intercepted and routed to `s6-svc` against the right service directory; you don't need to learn the s6 commands directly. For raw supervisor state, use `/command/s6-svstat /run/service/gateway-` (note `/command/` is on PATH only for processes spawned by the supervision tree — when calling from `docker exec`, pass the absolute path).
### Reaching more than one profile from outside the container
Two different surfaces reach a profile's gateway from outside, and they behave differently — don't conflate them:
**Hermes Desktop (and the web dashboard).** The Desktop app's **Remote Gateway** connection talks to a `hermes dashboard` backend (default **port 9119**, enabled by `HERMES_DASHBOARD=1`) — *not* the OpenAI API server. One dashboard backend serves **every** co-located profile: the app's profile switcher sends the target profile with each request and the backend opens that profile's `HERMES_HOME` on disk. So you do **not** need a second port — or a second connection — per profile for Desktop; one `:9119` connection covers them all through the switcher.
**OpenAI-compatible API clients (Open WebUI, LobeChat, `/v1/...`).** These talk to each profile's **API server**, which binds **port 8642 for every profile** (resolved from `API_SERVER_PORT` / `platforms.api_server.extra.port` — there is no auto-allocation and no `config.yaml`/`gateway.port` key). If you want a client to reach a *specific* second profile, give that profile a distinct `API_SERVER_PORT` in **its own** `.env`, otherwise its gateway tries to bind 8642 too and conflicts with the default profile:
```sh
# Create the profile (registers its gateway- s6 slot)
docker exec hermes hermes profile create work
# Point its API server at a free port (write to the profile's own .env)
cat >> /opt/data/profiles/work/.env <<'EOF'
API_SERVER_ENABLED=true
API_SERVER_PORT=8643
EOF
docker exec hermes hermes -p work gateway restart
```
Keep `API_SERVER_PORT` in each profile's **own** `.env`, never in the container-wide `environment:` block — a global value would force every profile onto the same port and they would collide. With bridge networking, publish the extra port in `docker-compose.yml` (`- "8643:8643"`); with `network_mode: host` it is already reachable on the host. The default profile's 8642 connection is untouched.
### Why one container with many profiles, not many containers
Before the s6 migration, "one container per profile" was the recommended pattern because there was no in-container supervisor to manage multiple gateways. With s6 as PID 1, that's no longer necessary, and the single-container layout is simpler in almost every dimension:
| | One container, many profiles | One container per profile |
|---|---|---|
| Disk overhead | One image, one bundled venv, one Playwright cache | N images / N caches |
| Memory overhead | Shared Python interpreter cache, shared node_modules | Duplicated per container |
| Profile creation | `docker exec ... hermes profile create ` (seconds) | New `docker run` invocation + port allocation + bind-mount config |
| Per-profile crash recovery | `s6-supervise` auto-restart | Docker's `--restart unless-stopped` (slower, kills sibling work) |
| Logs | Per-profile rotated file via `s6-log`, plus container-boot audit log | `docker logs ` per container — no built-in rotation |
| Backup | One `~/.hermes` directory | N directories to coordinate |
The default profile (`default`) is always registered on first boot, so a fresh container ships with one supervised gateway out of the box. Additional profiles are pure runtime adds.
### When you DO want a separate container
Profile-in-container is the default. Run a separate container per profile only when you have a specific reason:
- **Resource isolation per workload** — e.g. a runaway browser-tool session in profile A shouldn't be able to OOM profile B. Containers give you `--memory` / `--cpus` per profile.
- **Independent image pinning** — different upstream image tags per workload.
- **Network segmentation** — distinct Docker networks per profile (e.g. one customer-facing, one internal).
- **Compliance / blast radius** — distinct credentials never share an OS-level process tree.
In those cases, declare one service per profile with distinct `container_name`, `volumes`, and `ports`:
```yaml
services:
hermes-work:
image: nousresearch/hermes-agent:latest
container_name: hermes-work
restart: unless-stopped
command: gateway run
ports:
- "8642:8642"
volumes:
- ~/.hermes-work:/opt/data
hermes-personal:
image: nousresearch/hermes-agent:latest
container_name: hermes-personal
restart: unless-stopped
command: gateway run
ports:
- "8643:8642"
volumes:
- ~/.hermes-personal:/opt/data
```
The warning from [Persistent volumes](#persistent-volumes) still applies: never point two containers at the same `~/.hermes` directory simultaneously. The s6 supervisor inside each container manages its own profile set; cross-container sharing of a data volume corrupts session files and memory stores.
## Where the logs go
The s6 container has four distinct log surfaces, and "why isn't my gateway showing anything in `docker logs`" is a common surprise. Cheatsheet:
| Source | Where it lands | How to read it |
|---|---|---|
| **Per-profile gateway** (`hermes gateway run` and per-profile gateways under s6) | Tee'd to two places: `docker logs ` (real time, no extra prefix) **and** `${HERMES_HOME}/logs/gateways//current` (rotated, ISO-8601 timestamped, 10 archives × 1 MB each) | `docker logs -f hermes` or `tail -F ~/.hermes/logs/gateways/default/current` on the host |
| **Dashboard** (when `HERMES_DASHBOARD=1`) | `docker logs ` (no prefix) | `docker logs -f hermes` — interleaved with gateway lines |
| **Boot reconciler** (records which profile gateways were restored on each container start) | `${HERMES_HOME}/logs/container-boot.log` (append-only audit log) | `tail -F ~/.hermes/logs/container-boot.log` |
| **Generic Hermes logs** (`agent.log`, `errors.log`) | `${HERMES_HOME}/logs/` (profile-aware) | `docker exec hermes hermes logs --follow [--level WARNING] [--session ]` |
Two practical consequences worth knowing:
- The file copy at `logs/gateways//current` is what survives container restarts. `docker logs` only retains output from the current container's lifetime (and is wiped on `docker rm`); the rotated files persist on the bind-mounted volume.
- The boot reconciler's audit line shape is ` profile= prior_state= action=`, so a quick `grep profile=coder ~/.hermes/logs/container-boot.log` reveals when a given profile was last restored and whether s6 auto-started it.
## Environment variable forwarding
API keys are read from `/opt/data/.env` inside the container. You can also pass environment variables directly:
```sh
docker run -it --rm \
-v ~/.hermes:/opt/data \
-e ANTHROPIC_API_KEY="sk-ant-..." \
-e OPENAI_API_KEY="sk-..." \
nousresearch/hermes-agent
```
Direct `-e` flags override values from `.env`. This is useful for CI/CD or secrets-manager integrations where you don't want keys on disk.
:::note Looking for Docker as the **terminal backend**?
This page covers running Hermes itself inside Docker. If you want Hermes to execute the agent's `terminal` / `execute_code` calls inside a Docker sandbox container (one long-lived container shared across Hermes processes — see issue #20561), that's a separate config block — `terminal.backend: docker` plus `terminal.docker_image`, `terminal.docker_volumes`, `terminal.docker_forward_env`, `terminal.docker_env`, `terminal.docker_run_as_host_user`, `terminal.docker_extra_args`, `terminal.docker_persist_across_processes`, and `terminal.docker_orphan_reaper`. See [Configuration → Docker Backend](configuration.md#docker-backend) for the full set including container-lifecycle rules.
:::
## Docker Compose example
For persistent deployment with both the gateway and dashboard, a `docker-compose.yaml` is convenient:
```yaml
services:
hermes:
image: nousresearch/hermes-agent:latest
container_name: hermes
restart: unless-stopped
command: gateway run
ports:
- "8642:8642" # gateway API
- "9119:9119" # dashboard (only reached when HERMES_DASHBOARD=1)
volumes:
- ~/.hermes:/opt/data
environment:
- HERMES_DASHBOARD=1
# Uncomment to forward specific env vars instead of using .env file:
# - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
# - OPENAI_API_KEY=${OPENAI_API_KEY}
# - TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
deploy:
resources:
limits:
memory: 4G
cpus: "2.0"
```
Start with `docker compose up -d` and view logs with `docker compose logs -f`. The supervised gateway's stdout is also tee'd to `${HERMES_HOME}/logs/gateways//current` on the volume — see [Where the logs go](#where-the-logs-go) for the full routing map.
## Optional: Linux desktop audio bridge
Voice mode in Docker needs two separate things to work: Hermes must be allowed to probe audio devices inside the container, and the container must be able to reach your host audio server. The setup below covers the host audio plumbing for Linux desktops that expose a PulseAudio-compatible socket, including many PipeWire setups.
:::caution
This is a Linux desktop workaround, not a general Docker Desktop feature. It is useful when you already have host audio working and want CLI voice mode inside the Hermes container. If Hermes still reports `Running inside Docker container -- no audio devices`, use a build that includes Docker audio probing support for `PULSE_SERVER` / `PIPEWIRE_REMOTE`.
:::
First, create an ALSA config next to your Compose file:
```conf title="asound.conf"
pcm.!default {
type pulse
hint {
show on
description "Default ALSA Output (PulseAudio)"
}
}
pcm.pulse {
type pulse
}
ctl.!default {
type pulse
}
```
Then build a small derived image with the ALSA PulseAudio plugin installed:
```dockerfile title="Dockerfile.audio"
FROM nousresearch/hermes-agent:latest
USER root
RUN apt-get update \
&& apt-get install -y --no-install-recommends libasound2-plugins \
&& rm -rf /var/lib/apt/lists/*
```
Use that image in Compose and pass through the host user's PulseAudio socket and cookie:
```yaml
services:
hermes:
build:
context: .
dockerfile: Dockerfile.audio
image: hermes-agent-audio
container_name: hermes
restart: unless-stopped
command: gateway run
volumes:
- ~/.hermes:/opt/data
- /run/user/${HERMES_UID}/pulse:/run/user/${HERMES_UID}/pulse
# no-tmp: ok — path inside the container
- ~/.config/pulse/cookie:/tmp/pulse-cookie:ro
- ./asound.conf:/etc/asound.conf:ro
environment:
- HERMES_UID=${HERMES_UID}
- HERMES_GID=${HERMES_GID}
- XDG_RUNTIME_DIR=/run/user/${HERMES_UID}
- PULSE_SERVER=unix:/run/user/${HERMES_UID}/pulse/native
# no-tmp: ok — path inside the container
- PULSE_COOKIE=/tmp/pulse-cookie
```
Start it with your host UID/GID so the container process can access the per-user audio socket:
```sh
export HERMES_UID="$(id -u)"
export HERMES_GID="$(id -g)"
docker compose up -d --build
```
To verify what PortAudio sees inside the container:
```sh
docker exec hermes /opt/hermes/.venv/bin/python -c "import sounddevice as sd; print(sd.query_devices())"
```
## Resource limits
The Hermes container needs moderate resources. Recommended minimums:
| Resource | Minimum | Recommended |
|----------|---------|-------------|
| Memory | 1 GB | 2–4 GB |
| CPU | 1 core | 2 cores |
| Disk (data volume) | 500 MB | 2+ GB (grows with sessions/skills) |
Browser automation (Playwright/Chromium) is the most memory-hungry feature. If you don't need browser tools, 1 GB is sufficient. With browser tools active, allocate at least 2 GB.
Set limits in Docker:
```sh
docker run -d \
--name hermes \
--restart unless-stopped \
--memory=4g --cpus=2 \
-v ~/.hermes:/opt/data \
nousresearch/hermes-agent gateway run
```
## What the Dockerfile does
The image uses Debian 13.4 and includes:
- A Python 3.14 environment synchronized from the committed `uv.lock`, followed
by a no-dependency editable install of Hermes.
- The curated extras `all`, `messaging`, `otlp`, `anthropic`, `bedrock`,
`azure-identity`, and `matrix`. This is not `--all-extras`.
- Node.js 26 and npm from the digest-pinned Node source image.
- PM-pinned uv, full Chromium, FFmpeg, and ripgrep in `/opt/hermes/tools`.
- System Git, OpenSSH, Docker CLI, and Chromium shared libraries.
- Prebuilt TUI/dashboard assets and baked Photon sidecar dependencies.
- s6-overlay for supervision and zombie-process cleanup.
Chromium is staged through PM, not `npx playwright install`. The build records
its resolved executable in `/etc/hermes/agent-browser-executable-path`.
`PLAYWRIGHT_BROWSERS_PATH` names `/opt/hermes/tools`, outside the data mount.
Every image, including the unsuffixed (non-`-desktop`) tags, carries the full
Chromium build rather than Playwright's lighter headless shell: one pinned,
checksummed browser serves both headless browsing and headed Bot Screen
sessions. The cost is image size — the full build is larger than the headless
shell earlier images shipped.
Opt-in backend SDKs (Edge TTS, Firecrawl, Exa, platform adapters, plugin
dependencies) install on first use into PM dependency generations under
`/opt/data/installs`, so they survive container recreation and image updates.
The image's own `/opt/hermes/.venv` is never modified. On each boot the
container re-resolves the recorded selection against the new image's lock
before services start; if that fails (for example offline), it boots the
image's own environment and keeps the recorded extras for the next boot or
install. Set `security.allow_lazy_installs: false` to refuse on-demand
installs. The old `lazy-packages` overlay is not used.
Image provenance lives at `/etc/hermes/image-provenance.json`, outside both the
source and data mounts. The build stamp lives at `/opt/hermes/install-stamp.json`.
A local build without a supplied stamp reports an unknown revision rather than
inventing a commit. `hermes update` refuses image-owned code changes; replace
the image to update the application.
The container's `ENTRYPOINT` is a small dispatcher (`docker/entrypoint-dispatch.sh`). When the container owns PID 1 (normal Docker / Podman), it exec's s6-overlay's `/init` and you get the full supervision tree described below. When a platform wraps the image entrypoint under its own PID-1 init (Fly.io Machines, `docker run --init`, some Nomad/Kubernetes setups), `/init` would abort with `s6-overlay-suexec: fatal: can only run as pid 1` — so the dispatcher instead runs the stage2 bootstrap directly and exec's the main wrapper without s6. On that fallback path the requested command still runs, but supervised services (dashboard, per-profile gateways) are unavailable.
On the PID-1 path, `/init`:
1. Runs `/etc/cont-init.d/01-hermes-setup` (= `docker/stage2-hook.sh`) as root: optional UID/GID remap, fixes volume ownership, seeds `.env` / `config.yaml` / `SOUL.md` on first boot, runs non-interactive config-schema migrations unless `HERMES_SKIP_CONFIG_MIGRATION=1`, syncs bundled skills.
2. Runs `/etc/cont-init.d/02-reconcile-profiles` (= `hermes_cli.container_boot`): walks `$HERMES_HOME/profiles//`, recreates the per-profile gateway s6 service slot under `/run/service/gateway-/`, and auto-starts only those whose last recorded state was `running` (see [Per-profile gateway supervision](#per-profile-gateway-supervision)).
3. Starts the static `main-hermes` and `dashboard` s6-rc services.
4. Exec's the container's CMD as the main program (`/opt/hermes/docker/main-wrapper.sh`), which routes the arguments the user passed to `docker run`:
- no args → `hermes` (the default)
- first arg is an executable on PATH (e.g. `sleep`, `bash`) → exec it directly
- anything else → `hermes ` (subcommand passthrough)
The container exits when this main program exits, with its exit code.
:::warning Breaking change vs. pre-s6 images
The container ENTRYPOINT is now the `entrypoint-dispatch.sh` dispatcher (which delegates to s6-overlay's `/init` under PID 1), not `/usr/bin/tini`. All five documented `docker run` invocation patterns (no args, `chat -q "…"`, `sleep infinity`, `bash`, `--tui`) behave identically to the tini-based image. If you have a downstream wrapper that depended on tini-specific signal behavior or hard-coded `/usr/bin/tini --` invocation, pin to the previous image tag.
:::
:::warning Privilege model
Do not override the image entrypoint unless you keep `/init` (or, equivalently, the legacy `docker/entrypoint.sh` shim that forwards to the stage2 hook) in the command chain. s6-overlay's `/init` runs as root so it can chown the volume on first boot, then drops to the `hermes` user via `s6-setuidgid` for every supervised service AND for the main program. Starting `hermes gateway run` as root inside the official image is refused by default because it can leave root-owned files in `/opt/data` and break later dashboard or gateway starts. Set `HERMES_ALLOW_ROOT_GATEWAY=1` only when you intentionally accept that risk.
:::
:::warning Overriding `entrypoint:` also removes the zombie reaper
`/init` is what reaps orphaned grandchildren (headless browsers, MCP servers, `git`/`npm` helpers spawned by tools). A Compose service that overrides `entrypoint:` to call `hermes` directly — for example to run the dashboard as a non-root user — makes the hermes process itself PID 1, and nothing above it ever calls `wait()`: every orphan stays a `` entry forever (one deployment reached 284 zombies in under three hours). Hermes prints `[hermes] WARNING: this process is PID 1 with no init above it` at startup in that configuration.
If you must override the entrypoint, add Docker's init as PID 1 so orphans are reaped:
```yaml
services:
hermes-dashboard:
image: nousresearch/hermes-agent:latest
init: true # docker-init becomes PID 1 and reaps orphans
entrypoint: ["/opt/hermes/.venv/bin/hermes"]
command: ["dashboard", "--host", "0.0.0.0", "--port", "9119", "--no-open", "--skip-build"]
```
(`docker run --init …` is the equivalent flag.) This fixes the zombie accumulation only — with `/init` out of the chain, the s6 supervision tree is still gone: the dashboard, `hermes gateway run` and per-profile gateways are unsupervised, exactly as the dispatcher's own non-PID-1 warning says. Keep the default `ENTRYPOINT` whenever you can.
:::
### `docker exec` automatically drops to the `hermes` user
`docker exec hermes ` defaults to running as root inside the container, but the image ships a thin shim at `/opt/hermes/bin/hermes` (earliest on PATH) that detects root callers and transparently re-execs through `s6-setuidgid hermes`. So `docker exec hermes hermes login`, `docker exec hermes hermes profile create …`, `docker exec hermes hermes setup`, etc. all write files owned by UID 10000 — i.e. readable by the supervised gateway — with no extra `--user` flag needed. Non-root callers (the supervised processes themselves, `docker exec --user hermes`, kanban subagents inside the container) hit a short-circuit that exec's the venv binary directly, so there's no overhead on the hot paths.
If you specifically need a `docker exec` that retains root semantics (diagnostic sessions, inspecting root-only state, files outside `/opt/data` that root happens to own), opt out per invocation:
```sh
docker exec -e HERMES_DOCKER_EXEC_AS_ROOT=1 hermes
```
The shim accepts `1` / `true` / `yes` (case-insensitive). Anything else — including typos like `=0` — falls through to the drop, so silent opt-outs aren't possible. If `s6-setuidgid` isn't available (custom builds that stripped s6-overlay), the shim refuses to run as root and exits 126 instead, surfacing the broken privilege model loudly rather than regressing to the historical footgun where `docker exec hermes login` would write `auth.json` as `root:root` and break the supervised gateway's auth on every chat platform message.
### Per-profile gateway supervision
Each profile created with `hermes profile create ` automatically gets an s6-supervised gateway service registered at `/run/service/gateway-/`, with state-persistent auto-restart across container restarts. See [Multi-profile support](#multi-profile-support) above for the user-facing workflow and the lifecycle commands.
**Supervision benefits over the pre-s6 image:**
- Gateway crashes are auto-restarted by `s6-supervise` after a ~1s backoff.
- Dashboard, when enabled with `HERMES_DASHBOARD=1`, is supervised on the same supervision tree and gets the same auto-restart treatment.
- `docker restart`, image upgrades (`docker compose up -d --force-recreate`), and unexpected exits preserve running gateways: the cont-init reconciler reads `$HERMES_HOME/profiles//gateway_state.json` and brings the slot back up if the last recorded state was `running`. Only an explicit `hermes gateway stop` records `stopped` and keeps the gateway down across the restart; the container/s6 SIGTERM sent on a restart or upgrade is treated as "still running" and auto-starts.
- Per-profile gateway logs persist under `$HERMES_HOME/logs/gateways//current` (rotated by `s6-log`), and the reconciler's actions are appended to `$HERMES_HOME/logs/container-boot.log` per boot. See [Where the logs go](#where-the-logs-go) for the full routing map.
`hermes status` inside the container reports `Manager: s6 (container supervisor)`. Use `/command/s6-svstat /run/service/gateway-` for the raw supervisor view (note `/command/` is on PATH for supervision-tree processes only; pass the absolute path when calling from `docker exec`).
## Upgrading
Pull the latest image and recreate the container. Your data directory is
preserved, and the container runs non-interactive config-schema migrations
against the mounted `$HERMES_HOME/config.yaml` before starting the gateway.
When a migration is needed, Hermes writes timestamped backups next to
`config.yaml` and `.env` first.
```sh
docker pull nousresearch/hermes-agent:latest
docker rm -f hermes
docker run -d \
--name hermes \
--restart unless-stopped \
-v ~/.hermes:/opt/data \
nousresearch/hermes-agent gateway run
```
Or with Docker Compose:
```sh
docker compose pull
docker compose up -d
```
Set `HERMES_SKIP_CONFIG_MIGRATION=1` only if you need to inspect or migrate the
persisted config manually before letting the new image rewrite it.
## Skills and credential files
When using Docker as the execution environment (not the methods above, but when the agent runs commands inside a Docker sandbox — see [Configuration → Docker Backend](./configuration.md#docker-backend)), Hermes reuses a single long-lived container for all tool calls and automatically bind-mounts the skills directory (`~/.hermes/skills/`) and any credential files declared by skills into that container as read-only volumes. Skill scripts, templates, and references are available inside the sandbox without manual configuration, and because the container persists for the life of the Hermes process, any dependencies you install or files you write stay around for the next tool call.
The same syncing happens for SSH and Modal backends — skills and credential files are uploaded via rsync or the Modal mount API before each command.
## Installing more tools in the container
The official image ships with a curated set of utilities (see [What the Dockerfile does](#what-the-dockerfile-does)), but not every tool an agent might want is preinstalled. There are five recommended approaches, in increasing order of effort and durability.
### npm or Python tools — use `npx` or `uvx`
For any tool published to npm or PyPI, instruct Hermes to run it via `npx` (npm) or `uvx` (Python) and to remember that command in its persistent memory. If the tool needs a config file or credentials, instruct it to drop those under `/opt/data` (e.g. `/opt/data//config.yaml`).
Dependencies are fetched on demand and cached for the life of the container. Configuration written under `/opt/data` survives container restarts because it lives on the bind-mounted host directory. The package cache itself is rebuilt after a `docker rm`, but `npx` and `uvx` re-fetch transparently the next time the tool runs.
### Other tools (apt packages, binaries) — install and remember
The runtime user cannot install system APT packages. For occasional tools, an
operator can deliberately use a root shell; those changes last only until the
container is replaced. A container restart retains its writable layer, while
recreation does not. Memory of an install command is not automatic provisioning.
Use a derived image for repeatable system dependencies.
This is a good fit for tools that are quick to install and used occasionally. For tools used constantly, prefer the next approach.
### Durable installs — build a derived image
When a tool must be available immediately on every container start with no re-install delay, build a new image that inherits from `nousresearch/hermes-agent` and installs the tool in a layer:
```dockerfile
FROM nousresearch/hermes-agent:latest
USER root
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
&& rm -rf /var/lib/apt/lists/*
# Keep the root entrypoint; s6 drops privileges for the runtime.
```
Build it and use it in place of the official image:
```sh
docker build -t my-hermes:latest .
docker run -d \
--name hermes \
--restart unless-stopped \
-v ~/.hermes:/opt/data \
-p 8642:8642 \
my-hermes:latest gateway run
```
The entrypoint script and `/opt/data` semantics are inherited unchanged, so the rest of this page still applies. Remember to rebuild the image when pulling a newer upstream `nousresearch/hermes-agent`.
### Complex tools or multi-service stacks — run a sidecar container
For tools that bring their own service (a database, a web server, a queue, a headless browser farm) or that are too heavy to live inside the Hermes container, run them as a separate container on a shared Docker network. Hermes reaches the sidecar by container name, the same way it reaches a local inference server (see [Connecting to local inference servers](#connecting-to-local-inference-servers-vllm-ollama-etc)).
```yaml
services:
hermes:
image: nousresearch/hermes-agent:latest
container_name: hermes
restart: unless-stopped
command: gateway run
ports:
- "8642:8642"
volumes:
- ~/.hermes:/opt/data
networks:
- hermes-net
my-tool:
image: example/my-tool:latest
container_name: my-tool
restart: unless-stopped
networks:
- hermes-net
networks:
hermes-net:
driver: bridge
```
From inside the Hermes container, the sidecar is reachable at `http://my-tool:` (or whatever protocol it serves). This pattern keeps each service's lifecycle, resource limits, and upgrade cadence independent, and avoids bloating the Hermes image with dependencies that are only needed by one tool.
### Broadly useful tools — open an issue or pull request
If a tool is likely to be useful to most Hermes Agent users, consider contributing it upstream rather than carrying it in a private derived image. Open an issue or pull request on the [hermes-agent repository](https://github.com/NousResearch/hermes-agent) describing the tool and its use case. Tools that get bundled into the official image benefit every user and avoid the maintenance overhead of a downstream fork.
## Connecting to local inference servers (vLLM, Ollama, etc.)
When running Hermes in Docker and your inference server (vLLM, Ollama, text-generation-inference, etc.) is also running on the host or in another container, networking requires extra attention.
### Docker Compose (recommended)
Put both services on the same Docker network. This is the most reliable approach:
```yaml
services:
vllm:
image: vllm/vllm-openai:latest
container_name: vllm
command: >
--model Qwen/Qwen2.5-7B-Instruct
--served-model-name my-model
--host 0.0.0.0
--port 8000
ports:
- "8000:8000"
networks:
- hermes-net
deploy:
resources:
reservations:
devices:
- capabilities: [gpu]
hermes:
image: nousresearch/hermes-agent:latest
container_name: hermes
restart: unless-stopped
command: gateway run
ports:
- "8642:8642"
volumes:
- ~/.hermes:/opt/data
networks:
- hermes-net
networks:
hermes-net:
driver: bridge
```
Then in your `~/.hermes/config.yaml`, use the **container name** as the hostname:
```yaml
model:
provider: custom
model: my-model
base_url: http://vllm:8000/v1
api_key: "none"
```
:::tip Key points
- Use the **container name** (`vllm`) as the hostname — not `localhost` or `127.0.0.1`, which refer to the Hermes container itself.
- The `model` value must match the `--served-model-name` you passed to vLLM.
- Set `api_key` to any non-empty string (vLLM requires the header but doesn't validate it by default).
- Do **not** include a trailing slash in `base_url`.
:::
### Standalone Docker run (no Compose)
If your inference server runs directly on the host (not in Docker), use `host.docker.internal` on macOS/Windows, or `--network host` on Linux:
**macOS / Windows:**
```sh
docker run -d \
--name hermes \
-v ~/.hermes:/opt/data \
-p 8642:8642 \
nousresearch/hermes-agent gateway run
```
```yaml
# config.yaml
model:
provider: custom
model: my-model
base_url: http://host.docker.internal:8000/v1
api_key: "none"
```
**Linux (host networking):**
```sh
docker run -d \
--name hermes \
--network host \
-v ~/.hermes:/opt/data \
nousresearch/hermes-agent gateway run
```
```yaml
# config.yaml
model:
provider: custom
model: my-model
base_url: http://127.0.0.1:8000/v1
api_key: "none"
```
:::warning With `--network host`, the `-p` flag is ignored — all container ports are directly exposed on the host.
:::
### Verifying connectivity
From inside the Hermes container, confirm the inference server is reachable:
```sh
docker exec hermes curl -s http://vllm:8000/v1/models
```
You should see a JSON response listing your served model. If this fails, check:
1. Both containers are on the same Docker network (`docker network inspect hermes-net`)
2. The inference server is listening on `0.0.0.0`, not `127.0.0.1`
3. The port number matches
### Ollama
Ollama works the same way. If Ollama runs on the host, use `host.docker.internal:11434` (macOS/Windows) or `127.0.0.1:11434` (Linux with `--network host`). If Ollama runs in its own container on the same Docker network:
```yaml
model:
provider: custom
model: llama3
base_url: http://ollama:11434/v1
api_key: "none"
```
## Troubleshooting
### Container exits immediately
Check logs: `docker logs hermes`. Common causes:
- Missing or invalid `.env` file — run interactively first to complete setup
- Port conflicts if running with exposed ports
### "Permission denied" errors
The container's stage2 hook drops privileges to the non-root `hermes` user (UID 10000) via `s6-setuidgid` inside each supervised service. If your host `~/.hermes/` is owned by a different UID, set `HERMES_UID`/`HERMES_GID` — or their `PUID`/`PGID` aliases, for parity with LinuxServer.io and NAS images — to match your host user, or ensure the data directory is writable:
Do not make the whole data tree world-readable. It contains credentials.
Match the container UID/GID to the bind mount's owner instead.
On a NAS (UGOS, Synology, unRAID) the data directory is typically a **bind mount** owned by a host UID the container cannot `chown`. Set `PUID`/`PGID` (or `HERMES_UID`/`HERMES_GID`) to that host user so the runtime runs as the owner of the mount rather than UID 10000:
```sh
docker run -d \
--name hermes \
-e PUID=1000 -e PGID=10 \
-v /volume1/docker/hermes:/opt/data \
nousresearch/hermes-agent gateway run
```
`docker exec hermes ` automatically drops to UID 10000 too — see [`docker exec` automatically drops to the `hermes` user](#docker-exec-automatically-drops-to-the-hermes-user) for details and the per-invocation opt-out.
### Shared data directory keeps resetting to `0700`
Outside a container Hermes locks `HERMES_HOME` (and its `cron/`, `sessions/`, `logs/`, `memories/` subdirectories) to owner-only `0700` on every start. Inside a container it leaves directory modes alone, so a bind mount shared with a sibling container running as a different UID (a web UI, a permissions fixer) keeps whatever mode and ACLs you set on the host. To force a specific directory mode anyway, set `HERMES_HOME_MODE` (octal, e.g. `HERMES_HOME_MODE=0755`); it is applied in containers too.
### "Permission denied" on every `docker exec` (install dir locked to 0700)
Images built before late August 2026 had a bug where writing a credential file directly under `/opt/hermes` restricted that directory to `0700`, locking the `hermes` user (UID 10000) out of the install tree. Every new `docker exec` then fails with `Permission denied`.
Pulling a newer image and recreating the container fixes it permanently (the install dir ships as `0755` and current releases no longer restrict it). If you need to recover a running container in place without recreating it:
```sh
docker exec -u root hermes chmod 0755 /opt/hermes
```
### Zombie (``) processes piling up under PID 1
`ps -eo stat,ppid,comm | awk '$1 ~ /^Z/'` inside the container lists dead children that were never reaped. This happens when hermes itself is PID 1 — almost always because a Compose service overrides `entrypoint:` and so skips `docker/entrypoint-dispatch.sh` → `/init`. Hermes also warns about it at startup (`this process is PID 1 with no init above it`). Restore the default entrypoint, or add `init: true` (Compose) / `docker run --init` so `docker-init` reaps orphans; see [What the Dockerfile does](#what-the-dockerfile-does). Recreating the container clears the existing zombies.
### Browser tools not working
Playwright needs shared memory. Add `--shm-size=1g` to your Docker run command:
```sh
docker run -d \
--name hermes \
--shm-size=1g \
-v ~/.hermes:/opt/data \
nousresearch/hermes-agent gateway run
```
### Gateway not reconnecting after network issues
The `--restart unless-stopped` flag handles most transient failures. If the gateway is stuck, restart the container:
```sh
docker restart hermes
```
### Checking container health
```sh
docker logs --tail 50 hermes # Recent logs
docker run -it --rm nousresearch/hermes-agent:latest --version # Verify version
docker stats hermes # Resource usage
```
---
# Security
# Security
Hermes Agent is designed with a defense-in-depth security model. This page covers every security boundary — from command approval to container isolation to user authorization on messaging platforms.
## Overview
The security model has eight layers:
1. **User authorization** — who can talk to the agent (allowlists, DM pairing)
2. **Dangerous command approval** — human-in-the-loop for destructive operations
3. **File write safety** — denylist and optional write sandbox for `write_file`/`patch`
4. **Container isolation** — Docker/Singularity/Modal sandboxing with hardened settings
5. **MCP credential filtering** — environment variable isolation for MCP subprocesses
6. **Context file scanning** — prompt injection detection in project files
7. **Cross-session isolation** — sessions cannot access each other's data or state; cron job storage paths are hardened against path traversal attacks
8. **Input sanitization** — working directory parameters in terminal tool backends are validated against an allowlist to prevent shell injection
## Dangerous Command Approval
Before executing any command, Hermes checks it against a curated list of dangerous patterns. If a match is found, the user must explicitly approve it.
### Approval Modes
The approval system supports three modes, configured via `approvals.mode` in `~/.hermes/config.yaml`:
```yaml
approvals:
mode: smart # smart | manual | off
timeout: 300 # seconds to wait for user response (default: 300)
cron_mode: deny # deny | approve — what cron jobs do when they hit a dangerous command
single_query_mode: deny # deny | approve — what single-query (-q) sessions do on a dangerous command
unattended_mode: deny # deny | approve — what webhook/API sessions do on a dangerous command
mcp_reload_confirm: true # /reload-mcp asks before invalidating the MCP tool cache
destructive_slash_confirm: true # /clear, /new, /reset, /undo prompt before discarding state
```
The full set of keys:
| Key | Default | What it controls |
|---|---|---|
| `mode` | `smart` | Approval policy for dangerous shell commands — see the table below. |
| `timeout` | `300` | Seconds Hermes waits for an approval reply before timing out. |
| `cron_mode` | `deny` | How [cron jobs](./features/cron.md) behave headlessly when they trigger a dangerous-command prompt. `deny` blocks the command (the agent must find another path); `approve` auto-approves everything in cron context. |
| `single_query_mode` | `deny` | How one-shot [`hermes chat -q`](./cli.md) sessions behave when they trigger a dangerous-command prompt. A `-q` session runs a single turn and exits with no user waiting to answer prompts; `deny` blocks the command (the agent must find another path), `approve` auto-approves everything in single-query context. Mirrors `cron_mode`. |
| `unattended_mode` | `deny` | How sessions on unattended programmatic platforms (webhook, msgraph_webhook, api_server) behave when they trigger a dangerous-command prompt. These surfaces have no human who can answer `/approve`, so instead of blocking for the full approval timeout, `deny` blocks the command instantly (the agent must find another path) and `approve` auto-approves everything in unattended context. Mirrors `cron_mode`. |
| `mcp_reload_confirm` | `true` | When true, `/reload-mcp` asks before rebuilding the MCP tool set. Rebuilding invalidates the provider prompt cache (tool schemas live in the system prompt), so the next message re-sends full input tokens. Users who click **Always Approve** flip this key to `false`. |
| `destructive_slash_confirm` | `true` | When true, destructive session slash commands (`/clear`, `/new`, `/reset`, `/undo`) prompt before discarding conversation state. Three-option dialog (Approve Once / Always Approve / Cancel) routed through native yes/no buttons on Telegram, Discord, and Slack; text fallback elsewhere. Users who click **Always Approve** flip this key to `false`. The TUI also honors this setting for its `/clear`, `/new`, and `/reset` modal; `HERMES_TUI_NO_CONFIRM=1` force-skips that modal regardless of the configured value. |
| Mode | Behavior |
|------|----------|
| **smart** (default) | Use an auxiliary LLM to assess risk. Low-risk commands (e.g., `python -c "print('hello')"`) are auto-approved for that command only. Genuinely dangerous commands are auto-denied. Uncertain cases escalate to a manual prompt. |
| **manual** | Always prompt the user for approval on dangerous commands. |
| **off** | Disable all approval checks — equivalent to running with `--yolo`. All commands execute without prompts. |
:::warning
Setting `approvals.mode: off` disables all safety prompts. Use only in trusted environments (CI/CD, containers, etc.).
:::
### YOLO Mode
YOLO mode bypasses **all** dangerous command approval prompts for the current session. It can be activated three ways:
1. **CLI flag**: Start a session with `hermes --yolo` or `hermes chat --yolo`
2. **Slash command**: Type `/yolo` during a session to toggle it on/off
3. **Environment variable**: Set `HERMES_YOLO_MODE=1`
The `/yolo` command is a **toggle** — each use flips the mode on or off:
```
> /yolo
⚡ YOLO mode ON — all commands auto-approved. Use with caution.
> /yolo
⚠ YOLO mode OFF — dangerous commands will require approval.
```
YOLO mode is available in both CLI and gateway sessions. Internally, it sets the `HERMES_YOLO_MODE` environment variable which is checked before every command execution.
When YOLO is active, Hermes shows two persistent visual reminders so it's hard to forget that approval prompts are bypassed:
- A red banner line at session start when YOLO is already active: `⚠ YOLO mode — all approval prompts bypassed`. Hidden when YOLO is off so the default banner stays uncluttered.
- A `⚠ YOLO` fragment in the status bar across all width tiers, updated live as you toggle YOLO on or off (rich-text renderer and plain-text fallback).
:::danger
YOLO mode disables **all** dangerous command safety checks for the session — **except** the hardline blocklist (see below). Use only when you fully trust the commands being generated (e.g., well-tested automation scripts in disposable environments).
:::
For destructive session slash commands (`/clear`, `/new` / `/reset`, `/undo`, `/quit --delete` — `/exit --delete` is an alias), the CLI also prompts for confirmation before running them. See [Slash Commands — Confirmation prompts for destructive commands](../reference/slash-commands.md#confirmation-prompts-for-destructive-commands).
### Supervised-gateway lifecycle restriction
The terminal tool has a separate, non-overridable guard against stopping or
restarting the gateway from inside its own supervised process. A self-restart can
terminate the tool before it finishes and cause a supervisor/auto-resume loop.
User approval, YOLO mode, and `force=True` do not bypass this guard.
The guard also refuses process killers aimed at the interpreter image the gateway
runs as — `taskkill /F /IM python.exe`, `taskkill /FI "IMAGENAME eq python.exe"`,
`Stop-Process -Name python`, `pkill -9 python3`, `killall python`, `pkill -f python`,
and name-derived kills such as `pgrep python | xargs kill` — because a supervised
gateway is literally a `python` process and such a command takes it (and the agent's
own turn) down. Kills scoped to a process the agent owns pass: the `proc_*` id of a
background job (`process(action="kill", …)`) or an explicit PID
(`taskkill /F /PID `, `kill `). Other image names (`taskkill /F /IM notepad.exe`)
are unaffected. The guard is active under every generated launcher — systemd unit,
launchd plist, s6 run script and the Windows Scheduled Task — via the
`HERMES_SUPERVISED_CHILD` marker they export.
On macOS, executed `launchctl submit` and `launchctl bootstrap` commands are
restricted **regardless of the job label**. This is a conservative registration
restriction intended to catch indirect restart helpers with neutral labels, not
an inspection of the target plist. It also rejects independent scheduled jobs
with `RunAtLoad=false` and no `KeepAlive` key; rejection does **not** establish that
the job uses KeepAlive or controls Hermes.
For authorized LaunchAgent maintenance, use a separate shell outside the running
gateway. Some independent `load`/`unload` commands currently pass the label-based
checks, but that is not a target-verified exemption or a supported way to evade a
`bootstrap` rejection. Read-only `launchctl print` is not a lifecycle operation.
After external maintenance, distinguish the on-disk plist from the loaded job:
validate the plist and read back the loaded schedule before reporting activation.
A tool rejection means the command did not execute through that tool call. An
assistant declining to issue a call is a separate model decision; changing models
does not change the terminal guard's policy.
### Hardline Blocklist (Always-On Floor)
Some commands are so catastrophic — irreversible filesystem wipes, fork bombs, direct block-device writes — that Hermes refuses to run them **regardless** of:
- `--yolo` / `/yolo` toggled on
- `approvals.mode: off`
- Cron jobs running in headless `approve` mode
- User explicitly clicking "allow always"
The blocklist is the floor below `--yolo`. It trips **before** the approval layer even sees the command, and there's no override flag. Patterns currently covered (not exhaustive; kept in sync with `tools/approval.py::UNRECOVERABLE_BLOCKLIST`):
| Pattern | Why it's hardline |
|---|---|
| `rm -rf /` and obvious variants | Wipes the filesystem root |
| `rm -rf --no-preserve-root /` | The explicit "yes I mean root" variant |
| `:(){ :\|:& };:` (bash fork bomb) | Pegs the host until reboot |
| `mkfs.*` on a mounted root device | Formats the live system |
| `dd if=/dev/zero of=/dev/sd*` | Zeroes a physical disk |
| Piping untrusted URLs to `sh` at the rootfs top level | Remote-code-execution attack vector too broad to approve |
If you hit the blocklist, the tool call returns an explanatory error to the agent and nothing runs. If a legitimate workflow needs one of these commands (you're the operator of a wipe-and-reinstall pipeline, for example), run it outside the agent.
The floor also fails closed on a command whose shell quoting cannot be parsed (`grep 'unterminated`): the error says `malformed executable payload`. Quoting is judged on the command exactly as written, so shell-valid escapes inside a quoted pattern (`grep -o "[^\"]*" file`) are not malformed, and an escaped quote before a separator (`echo "a\"b"; reboot`) does not hide the command that follows it.
### User-Defined Deny Rules (`approvals.deny`)
The hardline blocklist is fixed and code-shipped. `approvals.deny` is its user-editable counterpart: a list of glob patterns that block matching terminal commands unconditionally — **before** `--yolo`, `/yolo`, and `approvals.mode: off` are consulted. Use it to run yolo-with-exceptions: "let the agent do everything, except these specific things, ever."
```yaml
approvals:
deny:
- "git push --force*"
- "*curl*|*sh*"
- "dd if=* of=/dev/*"
```
Details:
- Patterns are [fnmatch](https://docs.python.org/3/library/fnmatch.html) globs (`*`, `?`, `[...]`) matched **case-insensitively** against the whole command text and individual executable-command candidates. `git push --force*` matches `git push --force origin main` but not `git push origin main`.
- Matching runs over the same normalized/deobfuscated command variants the dangerous-pattern detector uses, so simple quoting tricks (`git pu""sh --force`) don't slip past a rule.
- Executable candidates retain the literal path and also match its basename: `sudo *` covers `/usr/bin/sudo -n id` and `./sudo -n id`. A path-specific rule such as `/usr/bin/sudo *` does **not** become a rule for every binary named `sudo`.
- Quote-aware parsing exposes commands after assignments, leading redirections, `;`, `&&`, `||`, pipelines, groups, command substitutions, and ordinary `if`/`then`/`else`/`do` transitions. Supported launchers include `sudo`, `env`, `command`, `exec`, `nohup`, `setsid`, `time`, `nice`, `timeout`, `stdbuf`, `ionice`, `chrt`, `taskset`, and `chroot`. Known option operands are skipped; `command -v`/`-V` lookups are not executions. Shell `-c` payloads are inspected recursively. Literal executable-and-argument strings in `env -S` / `--split-string` use GNU quoting and escapes (including `\_` word boundaries and `\c` termination), with the remaining command arguments appended; shell punctuation inside those arguments stays data unless an actual shell `-c` consumes it. `env -a` / `--argv0` values are arguments, not executable names. Shell and GNU split-string comments do not introduce executable candidates.
- In the additional executable candidates, whitespace **between** words is collapsed, but quoted argument content and argument paths are retained. An exact rule such as `git status` therefore also matches `env git\tstatus; echo done` (where `\t` represents a tab). Quoted mentions such as `echo 'sudo -n id'` are not promoted to commands. Existing whole-input globs such as `*sudo*` still intentionally match mentions anywhere.
- **YAML quoting:** always quote patterns. A bare leading `*` is a YAML alias and fails to parse; `{`, `!`, and `: ` have their own YAML meanings. Single quotes are safest for shell-ish content.
- User-defined deny rules apply to all terminal backends, including isolated containers, before any backend-specific approval shortcut.
- A denied command returns a BLOCKED error to the agent telling it not to retry or rephrase. Nothing runs.
Like the rest of the approval config, changes take effect immediately (the config cache is mtime-keyed) — no session restart needed.
:::note Threat model
Deny rules are a shell-command policy, not a complete shell interpreter or an OS capability sandbox. Normalization does not resolve arbitrary variables (including GNU `env -S` `${NAME}` expansion), aliases, functions, renamed binaries, scripts, interpreter programs, or every shell/launcher grammar (for example, case-pattern syntax, clustered launcher options, or options embedded inside an `env -S` string). Do not use a basename deny rule as a guarantee that a capability cannot be reached by other means. For containment, use OS permissions and an isolated backend with appropriately restricted mounts, credentials, and network access. This matching behavior does not change the configured approval mode or the empty-deny-list default.
:::
### Approval Timeout
When a dangerous command prompt appears, the user has a configurable amount of time to respond. If no response is given within the timeout, the command is **denied** by default (fail-closed).
An expired prompt cannot be reopened: the pending entry is discarded and the agent is told not to retry on its own within that turn. To run the operation after all, send a new message asking for it (for example "go ahead and run that now") — the agent issues a fresh tool call, which raises a fresh approval card, and a "once" approval applies only to that call. A timeout is not counted as a denial, so asking again is never penalized.
Configure the timeout in `~/.hermes/config.yaml`:
```yaml
approvals:
timeout: 300 # seconds (default: 300)
```
### What Triggers Approval
The following patterns trigger approval prompts (defined in `tools/approval.py`):
| Pattern | Description |
|---------|-------------|
| `rm -r` / `rm --recursive` | Recursive delete |
| `rm ... /` | Delete in root path |
| `chmod 777/666` / `o+w` / `a+w` | World/other-writable permissions |
| `chmod --recursive` with unsafe perms | Recursive world/other-writable (long flag) |
| `chown -R root` / `chown --recursive root` | Recursive chown to root |
| `mkfs` | Format filesystem |
| `dd if=` | Disk copy |
| `> /dev/sd` | Write to block device |
| `DROP TABLE/DATABASE` | SQL DROP |
| `DELETE FROM` (without WHERE) | SQL DELETE without WHERE |
| `TRUNCATE TABLE` | SQL TRUNCATE |
| `> /etc/` | Overwrite system config |
| `systemctl stop/restart/disable/mask` | Stop/restart/disable system services |
| `kill -9 -1` | Kill all processes |
| `pkill -9` | Force kill processes |
| Fork bomb patterns | Fork bombs |
| `bash -c` / `sh -c` / `zsh -c` / `ksh -c` | Shell command execution via `-c` flag (including combined flags like `-lc`) |
| `python -e` / `perl -e` / `ruby -e` / `node -c` | Script execution via `-e`/`-c` flag |
| `curl ... \| sh` / `wget ... \| sh` | Pipe remote content to shell |
| `bash <(curl ...)` / `sh <(wget ...)` | Execute remote script via process substitution |
| `tee` to `/etc/`, `~/.ssh/`, `~/.hermes/.env` | Overwrite sensitive file via tee |
| `>` / `>>` to `/etc/`, `~/.ssh/`, `~/.hermes/.env` | Overwrite sensitive file via redirection |
| `xargs rm` | xargs with rm |
| `find -exec rm` / `find -delete` | Find with destructive actions |
| `cp`/`mv`/`install` to `/etc/` | Copy/move file into system config |
| `sed -i` / `sed --in-place` on `/etc/` | In-place edit of system config |
| `pkill`/`killall` hermes/gateway | Self-termination prevention |
| `gateway run` with `&`/`disown`/`nohup`/`setsid` | Prevents starting gateway outside service manager |
| `docker stop/kill/restart`, `docker compose down/stop/kill/restart` | Container lifecycle (also catches global flags and `docker-compose`) |
| `docker -H`/`--host`/`--context`, `DOCKER_HOST=`/`DOCKER_CONTEXT=` | Docker daemon redirect — the command targets a different (often remote) daemon |
| `docker context use` | Switches the default daemon for all future docker commands |
| `podman --remote`/`-r`/`--url`/`--connection`/`--identity`, `CONTAINER_HOST=` | Podman remote daemon redirect |
:::info
**Container bypass**: When running in `docker`, `singularity`, `modal`, `daytona`, or `vercel_sandbox` backends, dangerous command checks are **skipped** because the container itself is the security boundary. Destructive commands inside a container can't harm the host.
:::
### Approval Flow (CLI)
In the interactive CLI, dangerous commands show an inline approval prompt:
```
⚠️ DANGEROUS COMMAND: recursive delete
rm -rf ~/old-project
[o]nce | [s]ession | [a]lways | [d]eny
Choice [o/s/a/D]:
```
The four options:
- **once** — allow this single execution
- **session** — allow this pattern for the rest of the session
- **always** — add to permanent allowlist (saved to `config.yaml`)
- **deny** (default) — block the command
### Approval Flow (Gateway/Messaging)
On messaging platforms, the agent sends the dangerous command details to the chat and waits for the user to reply:
- Reply **yes**, **y**, **approve**, **ok**, or **go** to approve
- Reply **no**, **n**, **deny**, or **cancel** to deny
The `HERMES_EXEC_ASK=1` environment variable is automatically set when running the gateway.
### Permanent Allowlist
Commands approved with "always" are saved to `~/.hermes/config.yaml`:
```yaml
# Permanently allowed dangerous command patterns
command_allowlist:
- rm
- systemctl
```
These patterns are loaded at startup and silently approved in all future sessions.
Entries can be exact command text, a shell-style glob (`podman *`), or a
dangerous-pattern rule key such as `script execution via heredoc` (the key shown
in the approval prompt). Rule keys are honored on every surface, including
unattended ones: a cron job, `hermes chat -q` run or webhook session under
`cron_mode`/`single_query_mode`/`unattended_mode: deny` still runs a command whose
detected rule key is in `command_allowlist`, while Tirith content-security
findings on the same command continue to block it.
The setting must be a list of strings. Legacy installs that stored a list as a
quoted YAML/JSON string recover that list at load time and log a warning to
re-save it with `hermes config edit`. Other malformed values are ignored with
a warning; they never become per-character approvals. Loading does not rewrite
your configuration file.
:::tip
Use `hermes config edit` to review or remove patterns from your permanent allowlist.
:::
:::caution
The list is read when Hermes starts. A pattern you remove while a session is
already running stays approved in that session until it next writes the file
(the next time you answer `always` to a prompt) or you restart Hermes. If you
removed it for safety reasons, restart.
:::
### Mining Approval History (`hermes approvals suggest`)
Instead of answering the same prompt session after session, you can mine your
past approval decisions into allowlist proposals:
```bash
hermes approvals suggest # dry run — prints a numbered proposal
hermes approvals suggest --apply 1,3 # merge picks into command_allowlist
hermes approvals suggest --json # machine-readable output
```
The command scans the session database (`~/.hermes/state.db`) for
dangerous-classified commands that actually executed — i.e. commands you
approved — aggregates them into patterns (`git push *`, or the dangerous-class
key for compound commands), and ranks them by approval frequency:
```
Proposed command_allowlist additions (from approval history, last 90 days):
1. git push * — approved 14x
2. docker restart/stop/kill (container lifecycle) — approved 9x (class key)
```
Safety rules:
- **Nothing is ever applied automatically** — the default run is read-only;
only an explicit `--apply N[,M...]` writes to `config.yaml`.
- **Destructive classes are never proposed**, no matter how often they were
approved: recursive deletes, `sudo`, disk/device writes, credential and
system-config edits, pipe-to-shell, SQL DROP/TRUNCATE, process kills, and
every hardline class are excluded outright. `rm -rf build/` approved 100
times still never yields an `rm` entry.
- Proposals already covered by your existing `command_allowlist` are skipped.
- **Credentials inside mined commands are masked** (`ghp_…`, `bot:`
URLs, `KEY=value` assignments, bearer tokens) in both the printed `e.g.`
examples and the `--json` payload, using the same redactor as terminal
output. Masked text is never used as an allowlist pattern: a command whose
glob would embed a credential (`TOKEN=… git …`) is proposed under its
dangerous-class key instead. The session database itself still holds the
command as it was executed.
Useful flags: `--days N` (history window, default 90), `--min-count N`
(minimum approvals to qualify, default 2), `--limit N`, and `--db PATH`.
## File Write Safety {#file-write-safety}
Before `write_file` or `patch` touches disk, Hermes checks the target path against a denylist and an optional sandbox. Blocked writes return an error to the agent immediately — **there is no approval prompt** and no way to override from the chat UI. The model may still claim the edit succeeded; when `display.file_mutation_verifier` is on (default), trust the [file-mutation verifier footer](./configuration.md#file-mutation-verifier) over the assistant's closing summary.
### Protected paths (always blocked)
These categories are always denied, even when `HERMES_WRITE_SAFE_ROOT` is unset:
| Category | Examples |
|----------|----------|
| OS credential stores | `~/.ssh/` (keys, `authorized_keys`), `~/.aws/`, `~/.kube/`, `/etc/sudoers`, `~/.netrc` |
| Hermes secret stores | `.env`, `.anthropic_oauth.json`, `auth/google_oauth.json`, Bitwarden cache (`cache/bws_cache.json`, `cache/bws_cache.enc.json`), `vault/`, `browser-profile/`, `mcp-tokens/`, `pairing/` under HERMES_HOME (active profile and global root). Control files (`auth.json`, `config.yaml`, `webhook_subscriptions.json`) are read-denied but stay writable. |
| Windows NT/device-namespace paths | `\??\...`, `\\.\...`, `\\?\UNC\...`, `\\?\GLOBALROOT...` — rejected for both reads and writes on every platform. On Windows, merely *resolving* such a path (e.g. `\??\UNC\host\share`) triggers outbound SMB authentication and can leak the user's NTLM hash; the prefixes also bypass normal path normalization. Ordinary extended-length local paths (`\\?\C:\...`) and plain UNC shares (`\\server\share`) are unaffected. |
Project-local `.env`, `.env.local`, `.env.production` and `.envrc` files are **read-denied** anywhere on disk (the file tools refuse to read them) but remain writable: the agent can create or edit them for you, it just cannot read the values back.
Sensitive paths inside the safe root are still blocked — pointing `HERMES_WRITE_SAFE_ROOT` at `$HOME` does not allow writing `~/.ssh/id_rsa`.
The `~` in the OS-credential rows means *every* home a write can land in, not just the process `HOME`: the OS user's real home, the profile home (`{HERMES_HOME}/home` under `TERMINAL_HOME_MODE=profile`, containers and spawned workers, where the process `HOME` is pinned), and named accounts (`~root/.ssh/authorized_keys`). An absolute path to the real home's `~/.aws/credentials` is denied even when the agent process runs with `HOME` pointed elsewhere.
Safe-root violations return `Write denied: '…' is outside HERMES_WRITE_SAFE_ROOT (…)`. Credential-path blocks use `Write denied: '…' is a protected system/credential file.`
**Exception — `~/.ssh/config` is approval-gated, not hard-blocked.** The SSH
*client config* holds no private-key material and editing it (host aliases,
`ProxyJump`, VS Code Remote-SSH targets) is a routine task, so `write_file` /
`patch` route it through the same approve-once/session/always prompt the
terminal tool already uses for `~/.ssh` writes — instead of the flat refusal
that used to apply. It can still carry `ProxyCommand` / `Match exec` directives
that run commands, so the write is never silent. Non-interactive callers (ACP
file bridge, background jobs with no human channel) fail closed. Private keys,
`authorized_keys`, and everything else under `~/.ssh/` remain hard-blocked.
### HERMES_WRITE_SAFE_ROOT (optional sandbox)
When set, `write_file` and `patch` may only target paths inside the listed directory prefix(es). Anything outside is **hard-blocked** — not routed through dangerous-command approval.
- Set automatically in the [official Docker image](https://github.com/NousResearch/hermes-agent) (`HERMES_WRITE_SAFE_ROOT=/opt/data`)
- Supports multiple roots separated by `:` on Unix or `;` on Windows
- **Do not add to `~/.hermes/.env` casually.** If you set it to a project directory, the agent cannot write to `~/.hermes/cron/jobs.json`, profile skills, or other Hermes state outside that prefix
To allow both a workspace and Hermes home:
```bash
export HERMES_WRITE_SAFE_ROOT=/path/to/project:/home/you/.hermes
```
Unset the variable to restore unrestricted writes (subject to the protected-path denylist). Full reference: [HERMES_WRITE_SAFE_ROOT](../reference/environment-variables.md#hermes_write_safe_root).
### Cron and other Hermes state
Do not ask the agent to `patch` `~/.hermes/cron/jobs.json` directly. Use the `cronjob_manage` tool, [`hermes cron`](./features/cron.md), or `/cron` — they update the job store through the supported API. The same applies to other Hermes control files when write safety blocks direct edits.
:::note Defense-in-depth, not a hard boundary
Write guards apply to `write_file` and `patch` only, with one exception: the Windows NT/device-namespace row is also enforced on reads — `read_file`, `search_files`, `@file:`/`@folder:` context references and the ACP file bridge all refuse those paths on the raw string, before anything resolves them. The `terminal` tool runs as the same OS user and can still `cat` or overwrite denied paths via shell commands. The denylist reduces accidental damage and gives models a clear stop signal; it does not sandbox a hostile or compromised agent.
:::
## User Authorization (Gateway)
When running the messaging gateway, Hermes controls who can interact with the bot through a layered authorization system.
### Authorization Check Order
The `_is_user_authorized()` method checks in this order:
1. **Per-platform allow-all flag** (e.g., `DISCORD_ALLOW_ALL_USERS=true`)
2. **DM pairing approved list** (users approved via pairing codes)
3. **Platform-specific allowlists** (e.g., `TELEGRAM_ALLOWED_USERS=12345,67890`)
4. **Global allowlist** (`GATEWAY_ALLOWED_USERS=12345,67890`)
5. **Global allow-all** (`GATEWAY_ALLOW_ALL_USERS=true`)
6. **Default: deny**
### Platform Allowlists
Set allowed user IDs as comma-separated values in `~/.hermes/.env`:
```bash
# Platform-specific allowlists
TELEGRAM_ALLOWED_USERS=123456789,987654321
DISCORD_ALLOWED_USERS=111222333444555666
WHATSAPP_ALLOWED_USERS=15551234567
SLACK_ALLOWED_USERS=U01ABC123
# Cross-platform allowlist (checked for all platforms)
GATEWAY_ALLOWED_USERS=123456789
# Per-platform allow-all (use with caution)
DISCORD_ALLOW_ALL_USERS=true
# Global allow-all (use with extreme caution)
GATEWAY_ALLOW_ALL_USERS=true
```
The global allow-all can also live in `config.yaml` as `gateway.allow_all_users: true` (or top-level `allow_all_users: true`); a true value is bridged to `GATEWAY_ALLOW_ALL_USERS` at gateway startup (re-derived on every config load and restart, so flipping it back to `false` closes the gate), an explicit env var wins, and the gateway logs a warning naming `config.yaml` as the grant source. In a multi-profile gateway a secondary profile sets `GATEWAY_ALLOW_ALL_USERS` in its own `.env` (its `config.yaml` is never bridged into the process environment).
:::warning
If **no allowlists are configured** and `GATEWAY_ALLOW_ALL_USERS` is not set, **all users are denied**. The gateway logs a warning at startup:
```
No user allowlists configured. All unauthorized users will be denied.
Set GATEWAY_ALLOW_ALL_USERS=true in ~/.hermes/.env to allow open access,
or configure platform allowlists (e.g., TELEGRAM_ALLOWED_USERS=your_id).
```
:::
### DM Pairing System
For more flexible authorization, Hermes includes a code-based pairing system. Instead of requiring user IDs upfront, unknown users receive a one-time pairing code that the bot owner approves via the CLI.
**How it works:**
1. An unknown user sends a DM to the bot
2. The bot replies with an 8-character pairing code
3. The bot owner runs `hermes pairing approve ` on the CLI
4. The user is permanently approved for that platform
Control how unauthorized direct messages are handled in `~/.hermes/config.yaml`:
```yaml
unauthorized_dm_behavior: pair
whatsapp:
unauthorized_dm_behavior: ignore
```
- `pair` is the default for chat-style DM platforms. Unauthorized DMs get a pairing code reply.
- `ignore` silently drops unauthorized DMs.
- `decline` sends one short, polite decline ("I can only chat with my owner") instead of a pairing code, then ignores further messages from that sender for 24 hours. Customize the text with `unauthorized_dm_decline_message`.
- Email defaults to `ignore` unless `platforms.email.unauthorized_dm_behavior: pair` is set, because inboxes can contain unrelated unread mail.
- Platform sections override the global default, so you can keep pairing on Telegram while keeping WhatsApp silent.
**Security features** (based on OWASP + NIST SP 800-63-4 guidance):
| Feature | Details |
|---------|---------|
| Code format | 8-char from 32-char unambiguous alphabet (no 0/O/1/I) |
| Randomness | Cryptographic (`secrets.choice()`) |
| Code TTL | 1 hour expiry |
| Rate limiting | 1 request per user per 10 minutes |
| Pending limit | Max 3 pending codes per platform |
| Lockout | 5 failed approval attempts → 1-hour lockout |
| File security | `chmod 0600` on all pairing data files |
| Logging | Codes are never logged to stdout |
**Pairing CLI commands:**
```bash
# List pending and approved users
hermes pairing list
# Approve a pairing code
hermes pairing approve telegram ABC12DEF
# Revoke a user's access
hermes pairing revoke telegram 123456789
# Clear all pending codes
hermes pairing clear-pending
```
:::tip Docker users: run pairing commands as the `hermes` user
The official Docker image runs the gateway as the unprivileged `hermes` user
(uid 10000) via `gosu`, but `docker exec` defaults to root. Approval files
created by root are written with mode `0600 root:root` and the gateway
cannot read them — the approval is silently ignored ([#10270][i10270]).
Always pass `-u hermes`:
```bash
docker exec -u hermes hermes-agent hermes pairing approve telegram ABC12DEF
```
If you already ran the command as root and the user is still unauthorized,
restart the container — the entrypoint will fix ownership on the next start.
[i10270]: https://github.com/NousResearch/hermes-agent/issues/10270
:::
**Storage:** Pairing data is stored in `~/.hermes/pairing/` with per-platform JSON files:
- `{platform}-pending.json` — pending pairing requests
- `{platform}-approved.json` — approved users
- `_rate_limits.json` — rate limit and lockout tracking
## Container Isolation
When using the `docker` terminal backend, Hermes applies strict security hardening to every container.
### Docker Security Flags
Every container runs with these flags (defined in `tools/environments/docker.py`):
```python
_BASE_SECURITY_ARGS = [
"--cap-drop", "ALL", # Drop ALL Linux capabilities
"--cap-add", "DAC_OVERRIDE", # Root can write to bind-mounted dirs
"--cap-add", "CHOWN", # Package managers need file ownership
"--cap-add", "FOWNER", # Package managers need file ownership
"--security-opt", "no-new-privileges", # Block privilege escalation
"--pids-limit", "256", # Limit process count
# no-tmp: ok — configures the sandbox's own tmpfs
"--tmpfs", "/tmp:rw,nosuid,size=512m", # Size-limited /tmp
"--tmpfs", "/var/tmp:rw,noexec,nosuid,size=256m", # No-exec /var/tmp
]
```
`SETUID`/`SETGID` are **not** in the base list — they're added conditionally when the container starts as root and an init/entrypoint must drop privileges (the s6 privilege-drop path). They're skipped when the container already runs as a non-root `--user`. The `/run` tmpfs is also split out from the base list and mounted per-image (hardened `noexec` by default, `exec` only for s6-overlay images that exec from `/run`).
### Resource Limits
Container resources are configurable in `~/.hermes/config.yaml`:
```yaml
terminal:
backend: docker
docker_image: "nikolaik/python-nodejs:python3.11-nodejs20"
docker_forward_env: [] # Explicit allowlist only; empty keeps secrets out of the container
container_cpu: 1 # CPU cores
container_memory: 5120 # MB (default 5GB)
container_disk: 51200 # MB (default 50GB, requires overlay2 on XFS)
container_persistent: true # Persist filesystem across sessions
```
### Filesystem Persistence
- **Persistent mode** (`container_persistent: true`): Bind-mounts `/workspace` and `/root` from `~/.hermes/sandboxes/docker//`
- **Ephemeral mode** (`container_persistent: false`): Uses tmpfs for workspace — everything is lost on cleanup
:::tip
For production gateway deployments, use `docker`, `modal`, `daytona`, or `vercel_sandbox` backend to isolate agent commands from your host system. This eliminates the need for dangerous command approval entirely.
:::
:::warning
If you add names to `terminal.docker_forward_env`, those variables are intentionally injected into the container for terminal commands. This is useful for task-specific credentials like `GITHUB_TOKEN`, but it also means code running in the container can read and exfiltrate them.
:::
## Terminal Backend Security Comparison
| Backend | Isolation | Dangerous Cmd Check | Best For |
|---------|-----------|-------------------|----------|
| **local** | None — runs on host | ✅ Yes | Development, trusted users |
| **ssh** | Remote machine | ✅ Yes | Running on a separate server |
| **docker** | Container | ❌ Skipped (container is boundary) | Production gateway |
| **singularity** | Container | ❌ Skipped | HPC environments |
| **modal** | Cloud sandbox | ❌ Skipped | Scalable cloud isolation |
| **daytona** | Cloud sandbox | ❌ Skipped | Persistent cloud workspaces |
| **vercel_sandbox** | Cloud microVM | ❌ Skipped | Cloud execution with snapshot persistence |
## Environment Variable Passthrough {#environment-variable-passthrough}
Both `execute_code` and `terminal` strip sensitive environment variables from child processes to prevent credential exfiltration by LLM-generated code. However, skills that declare `required_environment_variables` legitimately need access to those vars.
First-party platform credentials — the `BUZZ_*` variables used by the Buzz messaging platform — are passed through to `terminal` children (foreground and background/PTY spawns) **only when the session is actually operating as a Buzz agent**: the process is a Buzz-ACP managed agent (`BUZZ_MANAGED_AGENT` set by the Buzz Desktop harness) or the live gateway session's platform is `buzz`. This lets a Buzz platform agent invoke its platform-mandated CLI (e.g. `buzz`) from the terminal tool, while Telegram/CLI/cron sessions on the same host keep the variables stripped. Because `_sanitize_subprocess_env` also feeds search workers (e.g. the ddgs web-search subprocess), the computer-use driver binary, and user-script runners (bang `!` commands, quick commands, cron scripts, webhook-filter scripts), those children receive the variables too when spawned from a Buzz session. The carve-out is **terminal-only**: it does not apply to `execute_code`, browser/TUI-host spawns (`hermes_subprocess_env`), Docker/Modal children, or `env_passthrough` registration, which remain sealed.
### How It Works
Two mechanisms allow specific variables through the sandbox filters:
**1. Skill-scoped passthrough (automatic)**
When a skill is loaded (via `skill_view` or the `/skill` command) and declares `required_environment_variables`, any of those vars that are actually set in the environment are automatically registered as passthrough. Missing vars (still in setup-needed state) are **not** registered.
```yaml
# In a skill's SKILL.md frontmatter
required_environment_variables:
- name: TENOR_API_KEY
prompt: Tenor API key
help: Get a key from https://developers.google.com/tenor
```
After loading this skill, `TENOR_API_KEY` passes through to `execute_code`, `terminal` (local), **and remote backends (Docker, Modal)** — no manual configuration needed.
:::info Docker & Modal
Prior to v0.5.1, Docker's `forward_env` was a separate system from the skill passthrough. They are now merged — skill-declared env vars are automatically forwarded into Docker containers and Modal sandboxes without needing to add them to `docker_forward_env` manually.
:::
**2. Config-based passthrough (manual)**
For env vars not declared by any skill, add them to `terminal.env_passthrough` in `config.yaml`:
```yaml
terminal:
env_passthrough:
- MY_CUSTOM_KEY
- ANOTHER_TOKEN
```
Both lists apply to `terminal`, `execute_code` and `no_agent` cron scripts alike. A declared variable is forwarded with the value of the profile the child runs for: when one process serves several profiles (multi-profile gateway, Desktop/dashboard backend) each profile's declared value comes from its own `.env` / secret sources, never from the process environment the launch profile populated, and the launch profile's `.env` credentials are dropped from a served profile's children.
### Credential File Passthrough (OAuth tokens, etc.) {#credential-file-passthrough}
Some skills need **files** (not just env vars) in the sandbox — for example, Google Workspace stores OAuth tokens as `google_token.json` under the active profile's `HERMES_HOME`. Skills declare these in frontmatter:
```yaml
required_credential_files:
- path: google_token.json
description: Google OAuth2 token (created by setup script)
- path: google_client_secret.json
description: Google OAuth2 client credentials
```
When loaded, Hermes checks if these files exist in the active profile's `HERMES_HOME` and registers them for mounting:
- **Docker**: Read-only bind mounts (`-v host:container:ro`)
- **Modal**: Mounted at sandbox creation + synced before each command (handles mid-session OAuth setup)
- **Local**: No action needed (files already accessible)
You can also list credential files manually in `config.yaml`:
```yaml
terminal:
credential_files:
- google_token.json
- my_custom_oauth_token.json
```
Paths are relative to `~/.hermes/`. Files are mounted to `/root/.hermes/` inside the container. This list is read by `tools/credential_files.py` (`terminal.credential_files`) — it lives under the `terminal:` block but is loaded by the credential-files module, not the core terminal backend, so it isn't part of the bundled `DEFAULT_CONFIG` snapshot.
### Borrowed CLI logins (Codex CLI, Claude Code) {#borrowed-cli-logins}
When Hermes has no usable login of its own for `openai-codex` or `anthropic`, it can borrow the Codex CLI's `~/.codex/auth.json` and Claude Code's `~/.claude/.credentials.json` (or Keychain entry) and refresh them on your behalf. Both use single-use, rotating refresh tokens: once two programs hold one token family, whichever refreshes first invalidates the other's copy, which shows up as "I logged in once in the terminal and Hermes keeps failing" (or the reverse). If you run those CLIs alongside Hermes, give Hermes its own login and turn adoption off:
```yaml
auth:
adopt_external_logins: false # default: true
```
With the switch off Hermes never reads or refreshes those files: the `claude_code` credential-pool row disappears, `hermes auth list` prints one line saying so, and the log carries one INFO line per process. Only automatic adoption is affected — `hermes auth add openai-codex` still asks before importing an existing Codex CLI login. Automatic recovery also only repairs the credential Hermes already holds: a Codex CLI/Desktop login into a different ChatGPT workspace is refused with a warning (re-authenticate with `hermes auth add openai-codex`), and a login you complete while recovery is running is never overwritten. Add your own logins with `hermes auth add anthropic` / `hermes auth add openai-codex`.
### What Each Sandbox Filters
| Sandbox | Default Filter | Passthrough Override |
|---------|---------------|---------------------|
| **execute_code** | Blocks vars containing `KEY`, `TOKEN`, `SECRET`, `PASSWORD`, `CREDENTIAL`, `PASSWD`, `AUTH` in name; only allows safe-prefix vars through | ✅ Passthrough vars bypass both checks |
| **terminal** (local) | Blocks explicit Hermes infrastructure vars (provider keys, gateway tokens, tool API keys) | ✅ Passthrough vars bypass the blocklist |
| **terminal** (Docker) | No host env vars by default | ✅ Passthrough vars + `docker_forward_env` forwarded via `-e` |
| **terminal** (SSH) | No host env vars by default | ✅ Passthrough vars forwarded via `SendEnv`; the remote `sshd_config` needs a matching `AcceptEnv` (see [SSH backend](configuration.md#ssh-backend)) |
| **terminal** (Modal) | No host env/files by default | ✅ Credential files mounted; env passthrough via sync |
| **MCP** | Blocks everything except safe system vars + explicitly configured `env` | ❌ Not affected by passthrough (use MCP `env` config instead) |
### Security Considerations
- The passthrough only affects vars you or your skills explicitly declare — the default security posture is unchanged for arbitrary LLM-generated code
- Credential files are mounted **read-only** into Docker containers
- Skills Guard scans skill content for suspicious env access patterns before installation
- Missing/unset vars are never registered (you can't leak what doesn't exist)
- Hermes infrastructure secrets (provider API keys, gateway tokens) should never be added to `env_passthrough` — they have dedicated mechanisms
## MCP Credential Handling
MCP (Model Context Protocol) server subprocesses receive a **filtered environment** to prevent accidental credential leakage.
### Safe Environment Variables
Only these variables are passed through from the host to MCP stdio subprocesses:
```
PATH, HOME, USER, LANG, LC_ALL, TERM, SHELL, TMPDIR
```
Plus any `XDG_*` variables. All other environment variables (API keys, tokens, secrets) are **stripped**.
Variables explicitly defined in the MCP server's `env` config are passed through:
```yaml
mcp_servers:
github:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-github"]
env:
GITHUB_PERSONAL_ACCESS_TOKEN: "ghp_..." # Only this is passed
```
### Credential Redaction
Error messages from MCP tools are sanitized before being returned to the LLM. The following patterns are replaced with `[REDACTED]`:
- GitHub PATs (`ghp_...`)
- OpenAI-style keys (`sk-...`)
- Bearer tokens
- `token=`, `key=`, `API_KEY=`, `password=`, `secret=` parameters
### Website Access Policy
You can restrict which websites the agent can access through its web and browser tools. This is useful for preventing the agent from accessing internal services, admin panels, or other sensitive URLs.
```yaml
# In ~/.hermes/config.yaml
security:
website_blocklist:
enabled: true
domains:
- "*.internal.company.com"
- "admin.example.com"
shared_files:
- "/etc/hermes/blocked-sites.txt"
```
When a blocked URL is requested, the tool returns an error explaining the domain is blocked by policy. The blocklist is enforced across `web_search`, `web_extract`, `browser_navigate`, and all URL-capable tools.
See [Website Blocklist](./configuration.md#website-blocklist) in the configuration guide for full details.
### SSRF Protection
All URL-capable tools (web search, web extract, vision, browser) validate URLs before fetching them to prevent Server-Side Request Forgery (SSRF) attacks. Blocked addresses include:
- **Private networks** (RFC 1918): `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`
- **Loopback**: `127.0.0.0/8`, `::1`
- **Link-local**: `169.254.0.0/16` (includes cloud metadata at `169.254.169.254`)
- **CGNAT / shared address space** (RFC 6598): `100.64.0.0/10` (Tailscale, WireGuard VPNs)
- **Cloud metadata hostnames**: `metadata.google.internal`, `metadata.goog`
- **Reserved, multicast, and unspecified addresses**
SSRF protection is always active for internet-facing use and DNS failures are treated as blocked (fail-closed). Redirect chains are re-validated at each hop to prevent redirect-based bypasses.
The same guard covers fetches whose URL comes from a remote party rather than from you: image/video URLs returned by a generation provider, reference-image URLs a model supplies for edits, pet spritesheets and the petdex manifest, and skills.sh sitemap entries. A provider or index that points one of those at a private or metadata address is refused before any connection opens; the operator's own provider `base_url` is not affected — a download fetched directly from your configured `base_url` (the OpenRouter video content endpoint) skips only the private-address class check on that first hop, while the cloud-metadata floor still applies and any redirect it issues is re-validated in full — and an image-generation provider hosted on your LAN needs `security.allow_private_urls: true` (below) for the *result* URLs it returns to be cached locally.
#### Intentionally allowing private URLs
Some setups legitimately need private/internal URL access — home networks that resolve `home.arpa` to RFC 1918 space, LAN-only Ollama/llama.cpp endpoints, internal wikis, cloud metadata debugging, and the like. For those cases there's a global opt-out:
```yaml
security:
allow_private_urls: true # default: false
```
When on, web tools, the browser, vision URL fetches, and gateway media downloads no longer reject RFC 1918 / loopback / link-local / CGNAT / cloud-metadata destinations. **This is a deliberate trust boundary** — only enable it on machines where the agent running arbitrary prompt-injected URLs against the local network is an acceptable risk. Public-facing gateways should leave it off.
The host-substring guard (which blocks lookalike Unicode domain tricks even when the underlying IP is public) stays on regardless of this setting.
#### Local proxy fake-ip ranges
A TUN proxy in fake-ip mode (Mihomo/Clash `fake-ip`, Surge enhanced mode) answers DNS with an
address from its own block — `198.18.0.0/15` (RFC 2544 benchmarking) by default — for every name
outside its filter. Those answers are the proxy's sentinel, not an internal host, so the
private-IP guard otherwise rejects every outbound fetch on such a host: `web_extract`, platform
attachment downloads and the browser relay all fail with *URL targets a private or internal
network address* while the request never reaches the network. Declare the block to let the
sentinel through:
```yaml
security:
fake_ip_ranges:
- 198.18.0.0/15
```
Empty by default, and narrower than `allow_private_urls`: only the declared blocks get the
exemption, they should be ranges the local proxy owns (the dial still goes to the proxy, which
resolves the real target itself), and loopback, RFC 1918, link-local, CGNAT and cloud-metadata
destinations stay blocked — an entry that overlaps one of those classes (including `0.0.0.0/0`
or `::/0`) is ignored with a warning rather than widening the guard. On a host with a cloud
browser provider, the declared sentinel also stops counting as private for
`browser.auto_local_for_private_urls`, so those pages keep going to the cloud browser.
### Tirith Pre-Exec Security Scanning
Hermes integrates [tirith](https://github.com/sheeki03/tirith) for content-level command scanning before execution. Tirith detects threats that pattern matching alone misses:
- Homograph URL spoofing (internationalized domain attacks)
- Pipe-to-interpreter patterns (`curl | bash`, `wget | sh`)
- Terminal injection attacks
Tirith requests a pinned [PM package](../reference/package-management.md#optional-security-tools)
when enabled and absent. PM checks artifact hashes from `pm/lock.json` and
calls the cosign checker when available. An explicit provenance rejection aborts
installation. Startup requests installation in the background, subject to the
lazy-install policy. PM owns durable installation state and recovery, not a
separate `.tirith-install-failed` marker.
An explicit `security.tirith_path` remains authoritative, even if the executable
is missing. With the default name, lookup uses `PATH` before the PM selection.
External binaries remain outside PM's hash and provenance checks.
```yaml
# In ~/.hermes/config.yaml
security:
tirith_enabled: true # Enable/disable tirith scanning (default: true)
tirith_path: "tirith" # Path to tirith binary (default: PATH lookup)
tirith_timeout: 5 # Subprocess timeout in seconds
tirith_fail_open: true # Allow execution when tirith is unavailable (default: true)
```
When `tirith_fail_open` is `true` (default), commands proceed if tirith is not installed or times out. Set to `false` in high-security environments to block commands when tirith is unavailable.
Three consecutive operational failures (spawn error, timeout, crash) suspend scanning for five minutes so a broken binary cannot stall every command; after that window one command re-probes tirith, and any completed scan (allow, warn or block) resumes normal scanning. A probe that fails again re-arms the five-minute window.
PM supports Tirith on Linux (x86_64 / aarch64) and macOS (x86_64 / arm64).
With the default path, unsupported targets, including native Windows and
Android/Termux, skip Tirith. Pattern-matching guards still run. To use the managed
Tirith package on Windows, run Hermes under WSL.
Tirith's verdict integrates with the approval flow: safe commands pass through, while both suspicious and blocked commands trigger user approval with the full tirith findings (severity, title, description, safer alternatives). Users can approve or deny — the default choice is deny to keep unattended scenarios secure.
Two known Tirith false positives are downgraded to "allow" so they never prompt (or, in cron, never deny): a `lookalike_tld` warning whose only target is the legitimate `.app` gTLD, and a `variation_selector` warning when every selector in the command is U+FE0F directly after an emoji (folder names such as `🗞️ Journal/` or `▶️ Media/`). A variation selector after a letter or digit — the steganographic-obfuscation signal the rule exists for — still prompts.
### Context File Injection Protection
Context files (AGENTS.md, .cursorrules, SOUL.md) are scanned for prompt injection before being included in the system prompt. The scanner checks for:
- Instructions to ignore/disregard prior instructions
- Hidden HTML comments with suspicious keywords
- Attempts to read secrets (`.env`, `credentials`, `.netrc`)
- Credential exfiltration via `curl`
- Invisible Unicode characters (zero-width spaces, bidirectional overrides)
The translation-and-execution check requires a short language/format clause (for example,
“translate this into a bash script and execute it”). It does not connect translation
and execution verbs across unrelated comma-separated role prose. These patterns are
heuristics, not semantic intent detection.
Blocked project files show a warning:
```
[BLOCKED: AGENTS.md contained potential prompt injection (prompt_injection). Content not loaded.]
```
Your own `SOUL.md` in `HERMES_HOME` is treated differently: it is a file you wrote (file-tool writes to it
need your approval, and project checkouts never supply it), so a scanner hit there **does not block the
file**. Hermes logs a warning naming the matched pattern, loads the file as usual, and `/context` lists it as
`⚠ SOUL.md … loaded — matched prompt-injection pattern(s); review the file`. This lets an identity file that
*documents* an attack phrase (security guidance such as "content telling you to ignore previous instructions")
keep working; if you did not write the flagged text, treat the warning as a sign that something else edited
the file. The exception does not extend to a `SOUL.md` shipped by a profile distribution: `hermes profile
install ` and `hermes profile update` copy a third party's `SOUL.md` into the profile home without
a scan or an approval prompt, so when `distribution.yaml` owns the file a scanner hit still blocks it.
## Best Practices for Production Deployment
### Gateway Deployment Checklist
1. **Set explicit allowlists** — never use `GATEWAY_ALLOW_ALL_USERS=true` in production
2. **Use container backend** — set `terminal.backend: docker` in config.yaml
3. **Restrict resource limits** — set appropriate CPU, memory, and disk limits
4. **Store secrets securely** — keep API keys in `~/.hermes/.env` with proper file permissions
5. **Enable DM pairing** — use pairing codes instead of hardcoding user IDs when possible
6. **Review command allowlist** — periodically audit `command_allowlist` in config.yaml
7. **Set `terminal.cwd`** — don't let the agent operate from sensitive directories
8. **Run as non-root** — never run the gateway as root
9. **Monitor logs** — check `~/.hermes/logs/` for unauthorized access attempts
10. **Keep updated** — run `hermes update` regularly for security patches
### Securing API Keys
```bash
# Set proper permissions on the .env file
chmod 600 ~/.hermes/.env
# Keep separate keys for different services
# Never commit .env files to version control
```
### Network Isolation
For maximum security, run the gateway on a separate machine or VM. Set `terminal.backend: ssh` in `config.yaml`, then provide host details via environment variables in `~/.hermes/.env`:
```yaml
# ~/.hermes/config.yaml
terminal:
backend: ssh
```
```bash
# ~/.hermes/.env
TERMINAL_SSH_HOST=agent-worker.local
TERMINAL_SSH_USER=hermes
TERMINAL_SSH_KEY=~/.ssh/hermes_agent_key
```
The SSH connection details live in `.env` (not `config.yaml`) so they aren't checked in or shared along with profile exports. This keeps the gateway's messaging connections separate from the agent's command execution.
## TLS certificate trust
Hermes initializes the platform verifier through `truststore`. Windows uses
its certificate store, macOS uses its system trust services, and Linux uses
the OpenSSL system trust paths. If initialization fails, Hermes logs the
failure and falls back to OpenSSL defaults.
For a corporate TLS proxy, install its root through your organization's
operating-system trust procedure. Hermes' provider resolver no longer selects
trust through `HERMES_CA_BUNDLE` or the old CA-environment-variable ladder.
Sandboxed subprocesses can have their own separate CA configuration.
The former startup certificate guard is gone with it: Hermes no longer
validates `HERMES_CA_BUNDLE` / `SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE` /
`CURL_CA_BUNDLE` at launch, so there is no `SSLConfigurationError` and the
`HERMES_SKIP_SSL_GUARD` escape hatch has no effect. `HERMES_CA_BUNDLE` is
still honoured by the Nous Portal login flow only (`hermes login`, or its
`--ca-bundle` flag); the standard `SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE` / `CURL_CA_BUNDLE` variables
are still read by the plain `requests`/`urllib` calls some tools make (and by
`pip`, `uv`, `curl`, Node), so a stale path in one of them now fails at the
call that uses it rather than at startup. Fix or unset the variable there.
A custom provider can declare `ssl_ca_cert` for its endpoint. That bundle
replaces platform trust for chat, model metadata, and model catalog probes.
A missing file produces a warning and falls back to platform trust.
`ssl_verify: false` disables certificate verification and is unsafe for
untrusted networks. Do not use it as a permanent fix for a missing corporate root.
Provider HTTP clients keep proxy configuration separate from certificate
selection. A stale ambient CA-file path cannot prevent those clients from
starting. PM index credentials are sent only to their exact HTTPS origin;
redirects to another origin do not receive them.
## Trusted-by-placement extension points {#trusted-by-placement}
Most third-party code Hermes can run is gated by an explicit allow-list: general plugins need `plugins.enabled`, shell hooks need a first-use approval (or `hooks_auto_accept`), MCP servers are listed in config. One surface is deliberately different:
| Extension point | Loaded from | Loaded when | Opt-in |
|-----------------|-------------|-------------|--------|
| [Gateway event hooks](./features/hooks.md#gateway-event-hooks) | `/hooks//` (`HOOK.yaml` + `handler.py`) | Gateway startup (`HookRegistry.discover_and_load()`), per served profile | **Placing the directory.** No `plugins.enabled` entry, no prompt; `HERMES_SAFE_MODE` does not skip it. |
The gateway imports every valid hook directory in-process, with the gateway's own privileges. This is the documented contract (since `3988c3c245f`), not an oversight: the profile home is operator-owned configuration, and anyone who can write into it can already run code as you through `config.yaml` shell hooks or by editing `plugins.enabled`, so a separate consent gate for `hooks/` would add friction without moving the trust boundary. Treat the contents of `~/.hermes/hooks/` like the contents of `config.yaml` — review a `handler.py` before you place it, and include `ls ~/.hermes/hooks/` whenever you audit the rest of the profile home (the directory is not on the [protected-paths denylist](#file-write-safety), so it is ordinary writable state). Full details: [gateway hook trust model](./features/hooks.md#gateway-hook-trust).
## Supply-chain advisory checking
Hermes ships with a built-in advisory scanner that flags Python packages in the active venv that match a curated catalog of known-compromised versions (supply-chain worms like the May 2026 `mistralai 2.4.6` poisoning). Implementation lives in `hermes_cli/security_advisories.py`.
How it runs:
- **CLI startup banner.** A one-line warning is printed if any advisory matches, with a pointer to `hermes doctor` for the full remediation.
- **`hermes doctor`.** Surfaces every active advisory with version specifics and 2-4 step remediation instructions.
- **Gateway startup.** Logged to `gateway.log`; the first interactive message gets a short operator banner.
Each advisory carries a stable id. Once you have read and acted on it you can dismiss it for good:
```bash
hermes doctor --ack
```
The ack is persisted to `config.security.acked_advisories` and survives restart. Old advisories are intentionally **not** removed from the catalog — leaving them in place keeps fresh installs warned about historically poisoned versions that might still be cached in a private mirror.
The check itself is stdlib-only and runs from one `importlib.metadata.version()` lookup per advisory, so it's safe to run on every startup.
### Lazy install of optional dependencies
PM manages optional Python features as extras from `pyproject.toml`.
Source installers select the `all` extra. Native bundles include all extras
supported by their target. These are different feature sets.
When a backend requests an unavailable extra, `pm.ensure_import("extra-name")`
uses the same dependency transaction as plugin admission:
1. PM checks platform support and `security.allow_lazy_installs`.
2. PM prepares a complete environment with the existing extras and enabled plugin requirements.
3. Without plugin members, it uses the committed lock unchanged. With members, it resolves from the previous selection before a frozen workspace sync.
4. It validates the candidate before publishing its selection. A failed candidate leaves the previous environment selected.
5. If the current process uses the previous environment, PM reports that Hermes must restart. It does not replace imported libraries in place.
Shipped source, locks, and signed payloads remain unchanged. Additional tools
and Python environments use writable storage outside the base artifact.
Plugin dependencies share the complete environment; they are not isolated
Python sandboxes. Compatible transitive dependencies can change, but declared
constraints and exact pins remain binding.
| Control | Behavior |
|---|---|
| Declared extras | The helper accepts project extra names, not arbitrary pip commands. The removed `LAZY_DEPS` feature-name registry is not used. |
| Verified tools | Managed tool archives have versions and SHA-256 hashes in `pm/lock.json`. |
| Atomic selection | Preparation and validation precede publication of the new runtime selection. |
| Failure reporting | Failures raise `pm.InstallError`. PM sync receipts include failed steps and policy refusals. |
| No automatic plugin removal | A failed dependency union does not silently disable or delete installed plugins. |
To disable on-demand installations, run:
```bash
hermes config set security.allow_lazy_installs false
```
Already installed dependencies remain usable. Explicit PM install commands
are separate from on-demand installation. A bundle's frozen feature list,
when present with lazy installs disabled, restricts requested Python extra
names. This setting is not a blanket ban on explicit plugin admission or
manual package-manager commands. The official Docker image also disables
on-demand installs through its internal environment policy.
For missing dependencies, use `hermes tools` and `hermes doctor` to identify
the requirement. Do not run pip against a signed payload or the system Python.
See [Package management](../reference/package-management.md) for installation
ownership, diagnostics, and command boundaries.
---
# Checkpoints and /rollback
# Checkpoints and `/rollback`
Hermes Agent can automatically snapshot your project before **destructive operations** and restore it with a single command. Checkpoints are **opt-in** as of v2 — most users never use `/rollback`, and the shadow-store storage is non-trivial over time, so the default is off.
Enable checkpoints per-session with `--checkpoints`:
```bash
hermes chat --checkpoints
```
Or enable globally in `~/.hermes/config.yaml`:
```yaml
checkpoints:
enabled: true
```
This safety net is powered by an internal **Checkpoint Manager** that keeps a single shared shadow git repository under `~/.hermes/checkpoints/store/` — your real project `.git` is never touched. Every project the agent works in shares the same store, so git's content-addressable object DB deduplicates across projects and across turns.
## What Triggers a Checkpoint
Checkpoints are taken automatically before:
- **File tools** — `write_file` and `patch`
- **Destructive terminal commands** — `rm`, `rmdir`, `cp`, `install`, `mv`, `sed -i`, `truncate`, `dd`, `shred`, output redirects (`>`), and `git reset`/`clean`/`checkout`
The agent creates **at most one checkpoint per directory per turn**, so long-running sessions don't spam snapshots.
## Quick Reference
In-session slash commands:
| Command | Description |
|---------|-------------|
| `/rollback` | List all checkpoints with change stats |
| `/rollback ` | Restore to checkpoint N, keeping your hand-edits (also undoes last chat turn) |
| `/rollback --all` | Full restore — overwrites your hand-edits too |
| `/rollback diff ` | Preview diff between checkpoint N and current state |
| `/rollback ` | Restore a single file from checkpoint N |
CLI for inspecting and managing the store outside a session:
| Command | Description |
|---------|-------------|
| `hermes checkpoints` | Show total size, project count, per-project breakdown |
| `hermes checkpoints status` | Same as bare `checkpoints` |
| `hermes checkpoints list` | Alias for `status` |
| `hermes checkpoints prune` | Force a sweep: delete orphans/stale, GC, enforce size cap |
| `hermes checkpoints clear` | Nuke the entire checkpoint base (asks first) |
| `hermes checkpoints clear-legacy` | Delete only the `legacy-*` archives from v1 migration |
## How Checkpoints Work
At a high level:
- Hermes detects when tools are about to **modify files** in your working tree.
- Once per conversation turn (per directory), it:
- Resolves a reasonable project root for the file.
- Initialises or reuses the **single shared shadow store** at `~/.hermes/checkpoints/store/`.
- Stages into a per-project index, builds a tree, and commits to a per-project ref (`refs/hermes/`).
- These per-project refs form a checkpoint history that you can inspect and restore via `/rollback`.
```mermaid
flowchart LR
user["User command\n(hermes, gateway)"]
agent["AIAgent\n(run_agent.py)"]
tools["File & terminal tools"]
cpMgr["CheckpointManager"]
store["Shared shadow store\n~/.hermes/checkpoints/store/"]
user --> agent
agent -->|"tool call"| tools
tools -->|"before mutate\nensure_checkpoint()"| cpMgr
cpMgr -->|"git add/commit-tree/update-ref"| store
cpMgr -->|"OK / skipped"| tools
tools -->|"apply changes"| agent
```
## Configuration
Configure in `~/.hermes/config.yaml`:
```yaml
checkpoints:
enabled: false # master switch (default: false — opt-in)
max_snapshots: 20 # max checkpoints per project (enforced via ref rewrite + gc)
max_total_size_mb: 500 # hard cap on total store size; oldest commits dropped
max_file_size_mb: 10 # skip any single file larger than this
# Auto-maintenance (on by default): sweep ~/.hermes/checkpoints/ in the
# background — the CLI on a helper thread right after launch, the gateway
# on its housekeeping tick — and delete project entries whose last_touch is
# older than retention_days. Runs at most once per min_interval_hours,
# tracked via a .last_prune marker. It never blocks the prompt or gateway
# startup: the `git gc` that reclaims space can take tens of seconds on a
# large store. This sweep never deletes "orphan" entries (working directory
# not found) — a missing workdir is ambiguous (deleted project vs. an
# unmounted external volume / network share / VPN not yet up), so orphan
# cleanup is only ever done via the explicit `hermes checkpoints prune`
# command below, with a confirmation prompt.
auto_prune: true
retention_days: 7
min_interval_hours: 24
```
To disable everything:
```yaml
checkpoints:
enabled: false
auto_prune: false
```
When `enabled: false`, the Checkpoint Manager is a no-op and never attempts git operations. When `auto_prune: false`, the store grows until you run `hermes checkpoints prune` manually.
## Listing Checkpoints
From a CLI session:
```
/rollback
```
Hermes responds with a formatted list showing change statistics:
```text
📸 Checkpoints for /path/to/project:
1. 4270a8c 2026-03-16 04:36 before patch (1 file, +1/-0)
2. eaf4c1f 2026-03-16 04:35 before write_file
3. b3f9d2e 2026-03-16 04:34 before terminal: sed -i s/old/new/ config.py (1 file, +1/-1)
/rollback restore to checkpoint N (keeps your hand-edits)
/rollback --all full restore, overwriting your hand-edits too
/rollback diff preview changes since checkpoint N
/rollback restore a single file from checkpoint N
```
## Inspecting the Store from the Shell
```bash
hermes checkpoints
```
Sample output:
```text
Checkpoint base: /home/you/.hermes/checkpoints
Total size: 142.3 MB
store/ 138.1 MB
legacy-* 4.2 MB
Projects: 12
WORKDIR COMMITS LAST TOUCH STATE
/home/you/code/hermes-agent 20 2h ago live
/home/you/code/experiments/rl-runner 8 1d ago live
/home/you/code/old-prototype 3 9d ago orphan
...
Legacy archives (1):
legacy-20260506-050616 4.2 MB
Clear with: hermes checkpoints clear-legacy
```
Force a full sweep (ignores the 24h idempotency marker):
```bash
hermes checkpoints prune --retention-days 3 --max-size-mb 200
```
## Previewing Changes with `/rollback diff`
Before committing to a restore, preview what has changed since a checkpoint:
```
/rollback diff 1
```
This shows a git diff stat summary followed by the actual diff.
## Restoring with `/rollback`
```
/rollback 1
```
Behind the scenes, Hermes:
1. Verifies the target commit exists in the shadow store.
2. Takes a **pre-rollback snapshot** of the current state so you can "undo the undo" later.
3. Restores tracked files in your working directory — **preserving your hand-edits** (see below).
4. **Undoes the last conversation turn** so the agent's context matches the restored filesystem state.
### User hand-edits are preserved by default
`/rollback ` restores only the files Hermes itself changed. Every successful
`write_file` / `patch` records the file's content hash in an **agent-write
ledger**; at restore time, any file whose current contents no longer match what
Hermes last wrote (you edited it afterwards, or Hermes never touched it) is
**skipped** instead of overwritten, and listed in the output:
```
✅ Restored to checkpoint a1b2c3d4: before write_file
↷ Kept your hand-edits: src/config.py, notes.md
Use /rollback --all to restore those too.
```
To force the classic full restore that reverts everything — including your own
edits — add `--all`:
```
/rollback 1 --all
```
If the ledger is empty (a store created before this feature, or Hermes hasn't
written any files in the project yet), `/rollback` falls back to the full
restore automatically.
## Single-File Restore
Restore just one file from a checkpoint without affecting the rest of the directory:
```
/rollback 1 src/broken_file.py
```
## Safety and Performance Guards
### Container Backends
With a container terminal backend (`docker`, `singularity`, `modal`, `daytona`, `vercel_sandbox`, or a container plugin), file paths belong to the sandbox rather than the host. Hermes therefore does not take checkpoints or record the agent-write ledger for those paths, and `/rollback` explains the limitation: it still lists existing host checkpoints but refuses diff and restore, on the CLI and in messaging-gateway chats alike; `/diff session` answers with the same reason. The TUI and Desktop behave the same: `/rollback list` still works while `/rollback diff` and `/rollback ` are refused with that reason. Local and SSH backends are unaffected. To point `terminal.cwd` at the container-side view of a mounted directory see [`terminal.docker_mount_cwd_to_workspace`](./configuration.md).
- **Git availability** — if `git` is not found on `PATH`, checkpoints are transparently disabled.
- **Directory scope** — Hermes skips overly broad directories (root `/`, home `$HOME`).
- **Repository size** — directories with more than 50,000 files are skipped.
- **Per-file size cap** — files larger than `max_file_size_mb` (default 10 MB) are excluded from the snapshot. Prevents accidentally swallowing datasets, model weights, or generated media.
- **Total store size cap** — when the store exceeds `max_total_size_mb` (default 500 MB), the oldest commit per project is dropped round-robin. Each drop is reclaimed before deciding whether another is necessary. Every project keeps at least one snapshot. A failed Git operation stops pruning and is reported; maintenance never discards more history to compensate for failed reclamation.
- **Real pruning, off the hot path** — `max_snapshots` and the size cap are enforced by rewriting the per-project ref at checkpoint time (cheap); the store is then marked `.gc-pending` and the periodic prune runs `git gc --prune=now` once, so loose objects don't accumulate and a tool call never waits on a full repack.
- **Concurrent operations** — snapshots, restores, diffs and maintenance use one process-shared store lock. An operation reports a busy store rather than running GC over another process's unpublished objects. A restore applies its selected tree before pruning the safety snapshot's history.
- **No-change snapshots** — if there are no changes since the last snapshot, the checkpoint is skipped.
- **Non-fatal errors** — snapshot failures do not block your tools. Pruning failures are logged as warnings; explicit maintenance reports an error count.
## Where Checkpoints Live
```text
~/.hermes/checkpoints/
├── store/ # single shared bare git repo
│ ├── HEAD, objects/ # git internals (shared across projects)
│ ├── refs/hermes/ # per-project branch tip
│ ├── indexes/ # per-project git index
│ ├── projects/.json # workdir + created_at + last_touch
│ ├── .gc-pending # refs rewritten since the last gc; cleared by the next prune
│ └── info/exclude
├── .last_prune # auto-prune idempotency marker
└── legacy-/ # archived pre-v2 per-project shadow repos
```
Each `` is derived from the absolute path of the working directory. You normally never need to touch these manually — use `hermes checkpoints status` / `prune` / `clear` instead.
The sibling `.checkpoints.lock` coordinates processes and survives a store clear. Do not remove it while Hermes is running.
### Migration from v1
Before the v2 rewrite, each working directory got its own complete shadow git repo directly under `~/.hermes/checkpoints//`. That layout couldn't dedup objects across projects and had a documented no-op pruner — the store would grow without bound.
On first v2 run, any pre-v2 shadow repos are moved into `~/.hermes/checkpoints/legacy-/` so the new single-store layout starts clean. Old `/rollback` history is still reachable by manually inspecting the legacy archive with `git`; once you're confident you don't need it, run:
```bash
hermes checkpoints clear-legacy
```
to reclaim the space. Legacy archives are also swept by `auto_prune` after `retention_days`.
## Best Practices
- **Enable checkpoints only when you need them** — `hermes chat --checkpoints` or per-profile `enabled: true`.
- **Use `/rollback diff` before restoring** — preview what will change to pick the right checkpoint.
- **Use `/rollback` instead of `git reset`** when you want to undo agent-driven changes only.
- **Check `hermes checkpoints status` occasionally** if you use checkpoints regularly — shows which projects are active and what the store costs you.
- **Combine with Git worktrees** for maximum safety — keep each Hermes session in its own worktree/branch, with checkpoints as an extra layer.
For running multiple agents in parallel on the same repo, see the guide on [Git worktrees](./git-worktrees.md).
---
# Features Overview
# Features Overview
Hermes Agent includes a rich set of capabilities that extend far beyond basic chat. From persistent memory and file-aware context to browser automation and voice conversations, these features work together to make Hermes a powerful autonomous assistant.
:::tip Don't know where to start?
`hermes setup --portal` covers a model provider plus all four Tool Gateway tools (web search, image generation, TTS, browser) in one command. See [Nous Portal](../../integrations/nous-portal.md).
:::
## Core
- **[Tools & Toolsets](tools.md)** — Tools are functions that extend the agent's capabilities. They're organized into logical toolsets that can be enabled or disabled per platform, covering web search, terminal execution, file editing, memory, delegation, and more.
- **[Skills System](skills.md)** — On-demand knowledge documents the agent can load when needed. Skills follow a progressive disclosure pattern to minimize token usage and are compatible with the [agentskills.io](https://agentskills.io/specification) open standard.
- **[Persistent Memory](memory.md)** — Bounded, curated memory that persists across sessions. Hermes remembers your preferences, projects, environment, and things it has learned via `MEMORY.md` and `USER.md`.
- **[Context Files](context-files.md)** — Hermes automatically discovers and loads project context files (`.hermes.md`, `AGENTS.md`, `CLAUDE.md`, `SOUL.md`, `.cursorrules`) that shape how it behaves in your project.
- **[Context References](context-references.md)** — Type `@` followed by a reference to inject files, folders, git diffs, and URLs directly into your messages. Hermes expands the reference inline and appends the content automatically.
- **[Checkpoints](../checkpoints-and-rollback.md)** — Hermes automatically snapshots your working directory before making file changes, giving you a safety net to roll back with `/rollback` if something goes wrong.
## Automation
- **[Scheduled Tasks (Cron)](cron.md)** — Schedule tasks to run automatically with natural language or cron expressions. Jobs can attach skills, deliver results to any platform, and support pause/resume/edit operations.
- **[Subagent Delegation](delegation.md)** — The `delegate_task` tool spawns child agent instances with isolated context, restricted toolsets, and their own terminal sessions. Run 3 concurrent subagents by default (configurable) for parallel workstreams.
- **[Code Execution](code-execution.md)** — The `execute_code` tool lets the agent write Python scripts that call Hermes tools programmatically, collapsing multi-step workflows into a single LLM turn via sandboxed RPC execution.
- **[Event Hooks](hooks.md)** — Run custom code at key lifecycle points. Gateway hooks handle logging, alerts, and webhooks; plugin hooks handle tool interception, metrics, and guardrails.
- **[Batch Processing](batch-processing.md)** — Run the Hermes agent across hundreds or thousands of prompts in parallel, generating structured ShareGPT-format trajectory data for training data generation or evaluation.
## Media & Web
- **[Voice Mode](voice-mode.md)** — Full voice interaction across CLI and messaging platforms. Talk to the agent using your microphone, hear spoken replies, and have live voice conversations in Discord voice channels.
- **[Wake Word](wake-word.md)** — Hands-free "Hey Hermes" trigger for the CLI, TUI, and desktop app. An on-device hotword listener starts a voice session when you speak the wake phrase.
- **[Browser Automation](browser.md)** — Full browser automation with multiple backends: Browserbase cloud, Browser Use cloud, local Chrome/Brave/Chromium/Edge via CDP, or local Chromium. Navigate websites, fill forms, and extract information.
- **[Vision & Image Paste](vision.md)** — Multimodal vision support. Paste images from your clipboard into the CLI and ask the agent to analyze, describe, or work with them using any vision-capable model.
- **[Image Generation](image-generation.md)** — Generate images from text prompts using FAL.ai. Eleven models supported (FLUX 2 Klein/Pro, GPT-Image 1.5/2, Nano Banana Pro, Ideogram V3, Recraft V4 Pro, Qwen, Z-Image Turbo, Krea V2 Medium/Large); pick one via `hermes tools`.
- **[Voice & TTS](tts.md)** — Text-to-speech output and voice message transcription across all messaging platforms, with ten native provider options: Edge TTS (free), ElevenLabs, OpenAI TTS, MiniMax, Mistral Voxtral, Google Gemini, xAI, NeuTTS, KittenTTS, and Piper — plus custom command providers for any local TTS CLI.
## Integrations
- **[MCP Integration](mcp.md)** — Connect to any MCP server via stdio or HTTP transport. Access external tools from GitHub, databases, file systems, and internal APIs without writing native Hermes tools. Includes per-server tool filtering and sampling support.
- **[Provider Routing](provider-routing.md)** — Fine-grained control over which AI providers handle your requests. Optimize for cost, speed, or quality with sorting, whitelists, blacklists, and priority ordering.
- **[Fallback Providers](fallback-providers.md)** — Automatic failover to backup LLM providers when your primary model encounters errors, including independent fallback for auxiliary tasks like vision and compression.
- **[Credential Pools](credential-pools.md)** — Distribute API calls across multiple keys for the same provider. Automatic rotation on rate limits or failures.
- **[Prompt caching](../configuration#prompt-caching)** — Built-in cross-session 1-hour prefix cache for Claude on native Anthropic, OpenRouter, and Nous Portal. Always-on; no configuration required.
- **[Memory Providers](memory-providers.md)** — Plug in external memory backends (Honcho, OpenViking, Mem0, Holographic, RetainDB, ByteRover, Supermemory, plus Hindsight from the plugin catalog) for cross-session user modeling and personalization beyond the built-in memory system.
- **[API Server](api-server.md)** — Expose Hermes as an OpenAI-compatible HTTP endpoint. Connect any frontend that speaks the OpenAI format — Open WebUI, LobeChat, LibreChat, and more.
- **[IDE Integration (ACP)](acp.md)** — Use Hermes inside ACP-compatible editors such as VS Code, Zed, and JetBrains. Chat, tool activity, file diffs, and terminal commands render inside your editor.
- **[Batch Processing](batch-processing.md)** — Run the agent over many prompts or tasks in parallel from the CLI, with structured outputs and trajectory capture suitable for evals or downstream training pipelines.
## Customization
- **[Personality & SOUL.md](personality.md)** — Fully customizable agent personality. `SOUL.md` is the primary identity file — the first thing in the system prompt — and you can swap in built-in or custom `/personality` presets per session.
- **[Skins & Themes](skins.md)** — Customize the CLI's visual presentation: banner colors, spinner faces and verbs, response-box labels, branding text, and the tool activity prefix.
- **[Plugins](plugins.md)** — Add custom tools, hooks, and integrations without modifying core code. Three plugin types: general plugins (tools/hooks), memory providers (cross-session knowledge), and context engines (alternative context management). Managed via the unified `hermes plugins` interactive UI.
---
# Tools & Toolsets
# Tools & Toolsets
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.
Tools are functions that extend the agent's capabilities. They're organized into logical **toolsets** that can be enabled or disabled per platform.
## Available Tools
Hermes ships with a broad built-in tool registry covering web search, browser automation, terminal execution, file editing, memory, delegation, scheduled tasks, Home Assistant, and more.
:::note
**Honcho cross-session memory** is available as a memory provider plugin (`plugins/memory/honcho/`), not as a built-in toolset. See [Plugins](./plugins.md) for installation.
:::
High-level categories:
| Category | Examples | Description |
|----------|----------|-------------|
| **Web** | `web_search`, `web_extract` | Search the web and extract page content. |
| **X Search** | `x_search` | Search X (Twitter) posts and threads via xAI's built-in `x_search` Responses tool — gated on xAI credentials (SuperGrok OAuth or `XAI_API_KEY`); off by default, opt in via `hermes tools` → 🐦 X (Twitter) Search. |
| **Terminal & Files** | `terminal`, `process`, `read_file`, `patch` | Execute commands and manipulate files. |
| **Browser** | `browser_navigate`, `browser_snapshot`, `browser_vision` | Interactive browser automation with text and vision support. |
| **Media** | `vision_analyze`, `image_generate`, `text_to_speech` | Multimodal analysis and generation. |
| **Agent orchestration** | `todo`, `clarify`, `execute_code`, `delegate_task` | Planning, clarification, code execution, and subagent delegation. |
| **Memory & recall** | `memory`, `session_search` | Persistent memory and session search. |
| **Automation** | `cronjob` | Scheduled tasks with create/list/update/pause/resume/run/remove actions. Outbound delivery is handled by cron's own delivery, the `hermes send` CLI, and the gateway notifier — not by an agent-callable tool. |
| **Integrations** | `ha_*`, MCP server tools | Home Assistant, MCP, and other integrations. |
For the authoritative code-derived registry, see [Built-in Tools Reference](../../reference/tools-reference.md) and [Toolsets Reference](../../reference/toolsets-reference.md).
:::tip Nous Tool Gateway
Paid [Nous Portal](https://portal.nousresearch.com) subscribers can use web search, image generation, TTS, and browser automation through the **[Tool Gateway](tool-gateway.md)** — no separate API keys needed. Run `hermes model` to enable it, or configure individual tools with `hermes tools`.
:::
## Using Toolsets
```bash
# Use specific toolsets
hermes chat --toolsets "web,terminal"
# See all available tools
hermes tools
# Configure tools per platform (interactive)
hermes tools
```
Common toolsets include `web`, `search`, `terminal`, `file`, `browser`, `vision`, `image_gen`, `skills`, `tts`, `todo`, `memory`, `session_search`, `cronjob`, `code_execution`, `delegation`, `clarify`, `homeassistant`, `messaging`, `spotify`, `discord`, `discord_admin`, `debugging`, and `safe`.
See [Toolsets Reference](../../reference/toolsets-reference.md) for the full set, including platform presets such as `hermes-cli`, `hermes-telegram`, and dynamic MCP toolsets like `mcp-`.
## Tool result annotations
A few tool behaviors are worth knowing when you read agent transcripts:
- **Signal deaths are explained.** When a terminal command is killed by a signal, the result carries a human-readable note instead of a bare numeric code — e.g. exit `-9`/`137` becomes "terminated by signal 9: SIGKILL — often the kernel OOM killer on memory exhaustion, or an explicit kill -9", and segfaults, aborts, SIGTERM, broken pipes, and CPU/file-size limits are labeled the same way. Negative codes (subprocess semantics) are stated definitively; the shell's `128+signum` convention is hedged with "usually" since an application can legitimately exit with those codes.
- **UTF-16 text files are transcoded, not refused.** `read_file` detects UTF-16 (BOM or byte-pattern heuristic, either endianness — common for Windows Notepad files and PowerShell `>` redirects) and transcodes it to UTF-8 for display instead of flagging the file as binary. The result includes a hint disclosing the conversion; edits via `patch`/`write_file` re-encode as UTF-8. Files over 10 MB and genuinely binary files still get the binary-file refusal.
## Terminal Backends
The terminal tool can execute commands in different environments:
| Backend | Description | Use Case |
|---------|-------------|----------|
| `local` | Run on your machine (default) | Development, trusted tasks |
| `docker` | Isolated containers | Security, reproducibility |
| `ssh` | Remote server | Sandboxing, keep agent away from its own code |
| `singularity` | HPC containers | Cluster computing, rootless |
| `modal` | Cloud execution | Serverless, scale |
| `daytona` | Cloud sandbox workspace | Persistent remote dev environments |
| `vercel_sandbox` | Vercel Sandbox cloud microVM | Cloud execution with snapshot-backed filesystem persistence |
### Configuration
```yaml
# In ~/.hermes/config.yaml
terminal:
backend: local # or: docker, ssh, singularity, modal, daytona, vercel_sandbox
cwd: "." # Working directory
timeout: 180 # Command timeout in seconds
```
### Shell startup files and non-interactive commands
Agent terminal calls run your shell **non-interactively** — there is no TTY and no human at the prompt. Heavy or interactive shell initialisation that you never notice in a normal terminal can break or badly slow every command the agent runs:
- **Slow init (`nvm`, version managers, network-touching prompts):** the classic `nvm.sh` sourcing adds noticeable latency to *every* shell start, and the agent starts many shells. Multi-second rc files turn a quick `git status` into a timeout risk.
- **TTY-expecting blocks:** anything in `.bashrc`/`.zshrc` that prompts, runs `tmux`/`screen` attach, calls `read`, or prints a menu will hang a non-interactive shell — the command appears to run forever and then times out.
- **Unconditional output:** rc files that `echo` banners pollute every command's output the agent has to parse.
The fix is the standard guard most distros already ship at the top of `.bashrc` — return early when the shell is non-interactive, and keep anything heavy or interactive below it:
```bash
# ~/.bashrc — keep this guard near the top
case $- in
*i*) ;; # interactive: continue
*) return;; # non-interactive: stop here
esac
# heavy/interactive init goes BELOW the guard
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
```
Zsh users: put login-only setup in `.zprofile` and interactive-only setup in `.zshrc`; keep `.zshenv` minimal, since it runs for every shell including non-interactive ones. If the agent genuinely needs a tool that only your rc file puts on `PATH`, export the `PATH` change *above* the guard (path exports are cheap) or symlink the binary into `~/.local/bin`.
If agent terminal commands hang or time out immediately after working in your own terminal, your shell init is the first suspect.
### Docker Backend
```yaml
terminal:
backend: docker
docker_image: python:3.11-slim
```
**One persistent container, shared across the whole process.** Hermes starts a single long-lived container on first use (`docker run -d ... sleep infinity`) and routes every terminal, file, and `execute_code` call through `docker exec` into that same container. Working-directory changes, installed packages, environment tweaks, and files written to `/workspace` all carry over from one tool call to the next, across `/new`, `/reset`, and `delegate_task` subagents, for the lifetime of the Hermes process. The container is stopped and removed on shutdown.
This means the Docker backend behaves like a persistent sandbox VM, not a fresh container per command. If you `pip install foo` once, it's there for the rest of the session. If you `cd /workspace/project`, subsequent `ls` calls see that directory. See [Configuration → Docker Backend](../configuration.md#docker-backend) for the full lifecycle details and the `container_persistent` flag that controls whether `/workspace` and `/root` survive across Hermes restarts.
### SSH Backend
Recommended for security — agent can't modify its own code:
```yaml
terminal:
backend: ssh
```
```bash
# Set credentials in ~/.hermes/.env
TERMINAL_SSH_HOST=my-server.example.com
TERMINAL_SSH_USER=myuser
TERMINAL_SSH_KEY=~/.ssh/id_rsa
```
### Singularity/Apptainer
```bash
# Pre-build SIF for parallel workers
apptainer build ~/python.sif docker://python:3.11-slim
# Configure
hermes config set terminal.backend singularity
hermes config set terminal.singularity_image ~/python.sif
```
### Modal (Serverless Cloud)
```bash
python -c "import pm; pm.sync_venv(['modal'], explicit=True)"
modal setup
hermes config set terminal.backend modal
```
### Vercel Sandbox
```bash
python -c "import pm; pm.sync_venv(['vercel'], explicit=True)"
hermes config set terminal.backend vercel_sandbox
hermes config set terminal.vercel_runtime node24
```
Authenticate with all three of `VERCEL_TOKEN`, `VERCEL_PROJECT_ID`, and `VERCEL_TEAM_ID`. This access-token setup is the supported path for deployments and normal long-running Hermes processes on Render, Railway, Docker, and similar hosts. Supported runtimes are `node24`, `node22`, and `python3.13`; Hermes defaults to `/vercel/sandbox` as the remote workspace root.
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:
```bash
VERCEL_OIDC_TOKEN="$(vc project token)" hermes chat
```
With `container_persistent: true`, Hermes uses Vercel snapshots to preserve filesystem state across sandbox recreation for the same task. This can include Hermes-synced credentials, skills, and cache files inside the sandbox. Snapshots do not preserve live processes, PID space, or the same live sandbox identity.
Background terminal commands use Hermes' generic non-local process flow: spawn, poll, wait, log, and kill work through the normal process tool while the sandbox is alive, but Hermes does not provide native Vercel detached-process recovery after cleanup or restart.
Leave `container_disk` unset or at the shared default `51200`; custom disk sizing is unsupported for Vercel Sandbox and will fail diagnostics/backend creation.
### Container Resources
Configure CPU, memory, disk, and persistence for all container backends:
```yaml
terminal:
backend: docker # or singularity, modal, daytona, vercel_sandbox
container_cpu: 1 # CPU cores (default: 1)
container_memory: 5120 # Memory in MB (default: 5GB)
container_disk: 51200 # Disk in MB (default: 50GB)
container_persistent: true # Persist filesystem across sessions (default: true)
```
When `container_persistent: true`, installed packages, files, and config survive across sessions.
### Container Security
All container backends run with security hardening:
- Read-only root filesystem (Docker)
- All Linux capabilities dropped
- No privilege escalation
- PID limits (256 processes)
- Full namespace isolation
- Persistent workspace via volumes, not writable root layer
Docker can optionally receive an explicit env allowlist via `terminal.docker_forward_env`, but forwarded variables are visible to commands inside the container and should be treated as exposed to that session.
## Background Process Management
Start background processes and manage them:
```python
terminal(command="pytest -v tests/", background=true)
# Returns: {"session_id": "proc_abc123", "pid": 12345}
# Then manage with the process tool:
process(action="list") # Show all running processes
process(action="poll", session_id="proc_abc123") # Check status
process(action="wait", session_id="proc_abc123") # Block until done
process(action="log", session_id="proc_abc123") # Full output
process(action="kill", session_id="proc_abc123") # Terminate
process(action="write", session_id="proc_abc123", data="y") # Send input
```
PTY mode (`pty=true`) enables interactive CLI tools like Codex and Claude Code.
Completed background commands retain their exit status and captured output in the
active profile. Resume the conversation that launched the command (or its
compressed continuation), then use the original `session_id` with
`process(action="log")` for output and `process(action="poll")` for exit status.
Unrelated conversations and requests without a bound owning session cannot read
retained receipts, even with an exact process handle. `process(action="list")`
also includes retained results for the current task or conversation.
Hermes keeps the newest **64 completed results**, for up to **7 days after
completion**, under `logs/process-results/` in the profile's Hermes home. Each
receipt contains at most the existing rolling **200,000-character output tail**,
with terminal secret-redaction rules always applied, even when live-output
redaction is disabled. Receipts expire on subsequent
result reads or writes. Recovery does not rerun commands or replay completion
notifications. This preserves work that finished while the parent was alive;
it does not keep unfinished children alive after a timeout or crash.
## Sudo Support
On an interactive parent session, supported sudo commands use the masked password prompt (cached for the session). This includes literal absolute or quoted executable paths and `env` prefixes with ordinary options and assignments, such as `env -u UNUSED /usr/bin/sudo id`. Passwordless sudo does not need a prompt. You can also configure `SUDO_PASSWORD` in your profile's `.env` file on the agent machine.
Shell payloads such as `bash -c 'sudo id'`, `env -S` split strings, dynamic executable paths, and unrecognized `env` options are not interpreted by the password rewriter. Invoke sudo directly when you need the interactive prompt. This handling does not change approval rules or the guard against agent-supplied sudo passwords.
Delegated subagents cannot open a password prompt: their concurrent work does not have a serialized human password channel. Run the command in the parent session instead, or provision `SUDO_PASSWORD` locally. Messaging/headless sessions do not have a secure password reply channel; never send passwords in chat.
:::warning
On messaging platforms, if sudo fails, the output includes a tip to add `SUDO_PASSWORD` to `~/.hermes/.env`.
:::
---
# Skills System
# Skills System
Skills are on-demand knowledge documents the agent can load when needed. They follow a **progressive disclosure** pattern to minimize token usage and are compatible with the [agentskills.io](https://agentskills.io/specification) open standard.
All skills live in **`~/.hermes/skills/`** — the primary directory and source of truth. On fresh install, bundled skills are copied from the repo. Hub-installed and agent-created skills also go here. The agent can modify or delete any skill.
You can also point Hermes at **external skill directories** — additional folders scanned alongside the local one. See [External Skill Directories](#external-skill-directories) below.
See also:
- [Bundled Skills Catalog](../../reference/skills-catalog.md)
- [Official Optional Skills Catalog](../../reference/optional-skills-catalog.md)
## Browse and install in Desktop
Open **Capabilities → Skills** and switch between **Installed** and **Browse**.
Search stays at the top; the tab switch and actions share one row.
**Installed** reads the selected profile's actual skills and enabled state;
it is not inferred from the public catalog. **Browse** is a native catalog UI,
not an embedded website or a second, smaller catalog.
Desktop and the public [Skills Hub](/skills) read the same published CDN
snapshot: [`/docs/api/skills.json`](https://hermes-agent.nousresearch.com/docs/api/skills.json).
The public docs alias serves the same snapshot as Desktop's fetch URL,
`https://nousresearch.github.io/hermes-agent/docs/api/skills.json`. The docs
build generates it from bundled `skills/`, `optional-skills/`, and the
centralized skills index. Browsing does not crawl GitHub or query upstream
marketplaces live; installation still retrieves the selected skill through
its source's installer.
### Install from the website
The Skills Hub has an **Install in Hermes** button on each installable card. It opens the
installed Hermes Desktop app with a URL-encoded, source-qualified skill target:
for example, `official/...` for optional skills or `clawhub/...` for ClawHub.
Bundled skills use an explicit repository path rather than an ambiguous bare
name. When an older snapshot lacks that explicit bundled target, the website
omits its install link and native Browse disables installation rather than
resolving an ambiguous name. The next docs publish supplies those targets.
The same target is used by native Browse and the card's CLI fallback:
```text
hermes://skill/install?identifier=official%2Fsecurity%2F1password
```
Hermes shows **Install “skill-name”?** with separate **Source** and **Install to**
rows. Cancel makes no changes. After confirmation, the same dialog shows
**Installing…**, then **Installed** and a completion notification. Errors stay
in the dialog so you can read them and retry. Installation uses the existing
Skills Hub pipeline, including security scanning, action logs, and installed-list
refresh. If you switch profile or connection while the confirmation is open,
reopen the link for the new destination. Changes apply to
new sessions; a link cannot bypass scanning or select a different profile.
The public links use `hermes://`, not the development-only `hermes-dev://`
scheme. The `skill/install` route requires an updated Desktop build. If the
app is missing or the link is not recognized, update Desktop or expand the
card to copy its CLI install command instead.
## Starting with a blank slate
By default every profile is seeded with the bundled skill catalog, and each `hermes update` adds any newly bundled skills. If you want a profile with **no bundled skills** — and that stays empty across updates — you have two paths:
**At install time** (applies to the default `~/.hermes` profile): the
installer has no `--no-skills` flag. Its setup stage asks whether to seed the
bundled catalog when you pick the Blank Slate setup; answering no writes the
opt-out marker described below. Non-interactive installs seed the catalog, so
run `hermes skills opt-out` afterwards if you want the profile empty.
**At profile-create time** (named profiles):
```bash
hermes profile create research --no-skills
```
**On an already-installed profile** (default or named), toggle it at runtime:
```bash
hermes skills opt-out # stop future seeding — nothing on disk is touched
hermes skills opt-out --remove # also delete UNMODIFIED bundled skills (confirms first)
hermes skills opt-in --sync # undo: remove the marker and re-seed now
```
All of these paths write a `.no-bundled-skills` marker into the profile directory. While the marker is present, the installer, `hermes update`, and any skill sync all skip bundled-skill seeding for that profile. Delete the marker (or run `hermes skills opt-in`) to re-enable.
:::note Safe by default
`hermes skills opt-out` only stops *future* seeding — it never deletes anything already on disk. The optional `--remove` flag deletes bundled skills **only** when they are unmodified (byte-identical to the version Hermes installed). Skills you have edited, skills installed from the hub, and skills you wrote yourself are always kept.
:::
## Using Skills
Every installed skill is automatically available as a slash command:
```bash
# In the CLI or any messaging platform:
/gif-search funny cats
/axolotl help me fine-tune Llama 3 on my dataset
/github-pr-workflow create a PR for the auth refactor
/songsee analyze the frequency spread of this mix
# Just the skill name loads it and lets the agent ask what you need:
/excalidraw
```
### Stacking multiple skills in one command
You can invoke several skills in a single message by chaining slash commands
at the start — every leading `/skill` token (up to 5) is loaded, and the rest
becomes your instruction:
```bash
/github-pr-workflow /test-driven-development fix issue #123 and open a PR
```
Parsing stops at the first token that isn't an installed skill, so arguments
that happen to start with `/` (like file paths) are never swallowed:
```bash
/ocr-and-documents ~/.hermes/cache/scratch/scan.pdf extract the tables # loads one skill; ~/.hermes/cache/scratch/scan.pdf is the argument
```
For combinations you use repeatedly, prefer a [skill bundle](#skill-bundles) —
same effect under one short command.
(Plan mode works the same way but is a built-in command now: `/plan [request]` tells Hermes to inspect context if needed, write a markdown implementation plan instead of executing the task, and save the result under `.hermes/plans/` relative to the active workspace/backend working directory.)
You can also interact with skills through natural conversation:
```bash
hermes chat --toolsets skills -q "What skills do you have?"
hermes chat --toolsets skills -q "Show me the axolotl skill"
```
## Learning a skill from sources (`/learn`)
`/learn` is the fast way to turn something you already know — or a pile of
reference material — into a reusable skill, without hand-writing the
`SKILL.md`. It is open-ended: point it at *anything you can describe* and the
agent gathers the material with the tools it already has, then authors a skill
that follows the [house authoring standards](#skillmd-format) (≤60-char
description, the standard section order, Hermes-tool framing, no invented
commands).
```bash
# A local SDK or doc directory — read with read_file / search_files
/learn the REST client in ~/projects/acme-sdk, focus on auth + pagination
# An online doc page — fetched with web_extract
/learn https://docs.example.com/api/quickstart
# The workflow you just walked the agent through in this conversation
/learn how I just deployed the staging server
# Pasted notes / a described procedure
/learn filing an expense: open the portal, New > Expense, attach the receipt, submit
# A whole book, paper stack, or large docs corpus — becomes a knowledge-base skill
/learn ~/books/designing-data-intensive-applications.pdf
```
### Large sources become knowledge-base skills
When the source is a book, a stack of papers, a spec, or a large docs folder,
the agent doesn't cram it into one file or reduce it to a lossy summary.
Instead it authors an **expansive knowledge-base skill**: a lean `SKILL.md`
carrying the source's core mental models plus an index, with one distilled
file per chapter or topic under `references/` (plus a glossary or cheatsheet
when the source earns them). Reference files cost nothing until a question
needs one — the agent loads them on demand with `skill_view`, so query cost
stays proportional to the answer, not the source. Re-running `/learn` with new
material on the same topic folds it into the existing skill rather than
creating a duplicate.
The distillation synthesizes structure — frameworks, definitions, decision
rules, anti-patterns — and never reproduces passages of the source text.
Because the live agent does the sourcing, `/learn` works the same in the CLI,
the messaging gateway, the TUI, and the dashboard — and on any terminal backend
(local, Docker, remote), since there is no separate ingestion engine. In the
**dashboard**, the Skills page has a **Learn a skill** button that opens a panel
with a directory field, a URL field, and an open-ended text box; it composes a
`/learn` request and runs it in chat.
There is no model-tool footprint: `/learn` builds a standards-guided prompt and
hands it to the agent as a normal turn. The agent saves the result with the
`skill_manage` tool, so the [write-approval gate](#gating-agent-skill-writes-skillswrite_approval)
applies if you have it on.
## Progressive Disclosure
Skills use a token-efficient loading pattern:
```
Level 0: skills_list() → [{name, description, category}, ...] (~3k tokens)
Level 1: skill_view(name) → Full content + metadata (varies)
Level 2: skill_view(name, path) → Specific reference file (varies)
```
The agent only loads the full skill content when it actually needs it.
## SKILL.md Format
```markdown
---
name: my-skill
description: Brief description of what this skill does
version: 1.0.0
platforms: [macos, linux] # Optional — restrict to specific OS platforms
metadata:
hermes:
tags: [python, automation]
category: devops
fallback_for_toolsets: [web] # Optional — conditional activation (see below)
requires_toolsets: [terminal] # Optional — conditional activation (see below)
config: # Optional — config.yaml settings
- key: my.setting
description: "What this controls"
default: "value"
prompt: "Prompt for setup"
---
# Skill Title
## When to Use
Trigger conditions for this skill.
## Procedure
1. Step one
2. Step two
## Pitfalls
- Known failure modes and fixes
## Verification
How to confirm it worked.
```
### Platform-Specific Skills
Skills can restrict themselves to specific operating systems using the `platforms` field:
| Value | Matches |
|-------|---------|
| `macos` | macOS (Darwin) |
| `linux` | Linux |
| `windows` | Windows |
```yaml
platforms: [macos] # macOS only (e.g., iMessage, Apple Reminders, FindMy)
platforms: [macos, linux] # macOS and Linux
```
When set, the skill is automatically hidden from the system prompt, `skills_list()`, and slash commands on incompatible platforms. If omitted, the skill loads on all platforms.
## Skill output and media delivery
When a skill response (or any agent response) includes a bare absolute path to a media file — for example `/home/user/screenshots/diagram.png` — the gateway auto-detects it, strips it from the visible text, and delivers the file natively to the user's chat (Telegram photo, Discord attachment, etc.) instead of leaving the raw path in the message.
For audio specifically, the `[[audio_as_voice]]` directive promotes audio files to native voice-message bubbles on platforms that support them (Telegram, WhatsApp).
### Forcing document-style delivery: `[[as_document]]`
Sometimes you want the **opposite** of inline preview: you want the file delivered as a downloadable attachment, not a re-compressed image bubble. The classic example is a high-resolution screenshot or chart — Telegram's `sendPhoto` recompresses it to ~200 KB at 1280 px, destroying readability. A 1-2 MB PNG sent via `sendDocument` keeps the original bytes intact.
If a response (or any text inside it — typically the last line) contains the literal directive `[[as_document]]`, every media path extracted from that response is delivered as a document/file attachment rather than an image bubble:
```
Here is your rendered chart:
/home/user/.hermes/cache/chart-q4-2025.png
[[as_document]]
```
The directive is stripped before delivery, so users never see it. Granularity is intentionally all-or-nothing per response: emit `[[as_document]]` once and every image path in the same response is delivered as a document. This mirrors the scope of `[[audio_as_voice]]`.
Use it from a skill when:
- You produce screenshots or charts the user needs as files (for editing in another tool, archiving, sharing intact).
- The default lossy preview would obscure detail (small text, pixel-accurate diagrams, color-sensitive renders).
Platforms without a separate document path (e.g. SMS) fall back to whatever attachment mechanism they have.
### Conditional Activation (Fallback Skills)
Skills can automatically show or hide themselves based on which tools are available in the current session. This is most useful for **fallback skills** — free or local alternatives that should only appear when a premium tool is unavailable.
```yaml
metadata:
hermes:
fallback_for_toolsets: [web] # Show ONLY when these toolsets are unavailable
requires_toolsets: [terminal] # Show ONLY when these toolsets are available
fallback_for_tools: [web_search] # Show ONLY when these specific tools are unavailable
requires_tools: [terminal] # Show ONLY when these specific tools are available
```
| Field | Behavior |
|-------|----------|
| `fallback_for_toolsets` | Skill is **hidden** when the listed toolsets are available. Shown when they're missing. |
| `fallback_for_tools` | Same, but checks individual tools instead of toolsets. |
| `requires_toolsets` | Skill is **hidden** when the listed toolsets are unavailable. Shown when they're present. |
| `requires_tools` | Same, but checks individual tools. |
**Example:** The built-in `duckduckgo-search` skill uses `fallback_for_toolsets: [web]`. When you have `FIRECRAWL_API_KEY` set, the web toolset is available and the agent uses `web_search` — the DuckDuckGo skill stays hidden. If the API key is missing, the web toolset is unavailable and the DuckDuckGo skill automatically appears as a fallback.
Skills without any conditional fields behave exactly as before — they're always shown.
## Secure Setup on Load
Skills can declare required environment variables without disappearing from discovery:
```yaml
required_environment_variables:
- name: TENOR_API_KEY
prompt: Tenor API key
help: Get a key from https://developers.google.com/tenor
required_for: full functionality
```
When a missing value is encountered, Hermes asks for it securely only when the skill is actually loaded in the local CLI. You can skip setup and keep using the skill. Messaging surfaces never ask for secrets in chat — they tell you to use `hermes setup` or `~/.hermes/.env` locally instead.
Once set, declared env vars are **automatically passed through** to `execute_code` and `terminal` sandboxes — the skill's scripts can use `$TENOR_API_KEY` directly. For non-skill env vars, use the `terminal.env_passthrough` config option. See [Environment Variable Passthrough](../security.md#environment-variable-passthrough) for details.
### Skill Config Settings
Skills can also declare non-secret config settings (paths, preferences) stored in `config.yaml`:
```yaml
metadata:
hermes:
config:
- key: myplugin.path
description: Path to the plugin data directory
default: "~/myplugin-data"
prompt: Plugin data directory path
```
Settings are stored under `skills.config` in your config.yaml. `hermes config migrate` prompts for unconfigured settings, and `hermes config show` displays them. When a skill loads, its resolved config values are injected into the context so the agent knows the configured values automatically.
See [Skill Settings](../configuration.md#skill-settings) and [Creating Skills — Config Settings](../../developer-guide/creating-skills.md#config-settings-configyaml) for details.
## Skill Directory Structure
```text
~/.hermes/skills/ # Single source of truth
├── mlops/ # Category directory
│ ├── axolotl/
│ │ ├── SKILL.md # Main instructions (required)
│ │ ├── references/ # Additional docs
│ │ ├── templates/ # Output formats
│ │ ├── scripts/ # Helper scripts callable from the skill
│ │ ├── examples/ # Referenced example outputs
│ │ └── assets/ # Supplementary files
│ └── vllm/
│ └── SKILL.md
├── devops/
│ └── deploy-k8s/ # Agent-created skill
│ ├── SKILL.md
│ └── references/
├── .hub/ # Skills Hub state
│ ├── lock.json
│ ├── quarantine/
│ └── audit.log
└── .bundled_manifest # Tracks seeded bundled skills
```
Third-party URL and GitHub installs include `SKILL.md` plus the exact local
files it references under `references/`, `templates/`, `scripts/`, `assets/`,
and `examples/`. Unreferenced repository files are not copied. Hermes scans the
complete quarantined bundle and records the source URL, exact content hash,
scanner version, findings, timestamp, and fresh-or-cached status in
`skills/.hub/lock.json`.
### Advisory SkillEvaluator scan
In addition to the built-in security scanner (which enforces the install
policy above), Hermes can run [NVIDIA SkillEvaluator](https://github.com/NVIDIA/SkillEvaluator)
Tier 1 checks on every hub install as a second opinion. Tier 1 is
deterministic and keyless — PII detection (leaked emails, personal paths,
connection strings), unicode-smuggling detection, script lint, license
compliance, and a static security scan via
[NVIDIA SkillSpector](https://github.com/NVIDIA/SkillSpector).
The scan is **advisory only**: findings are printed with file and line
before the install confirmation, and the install continues. Findings that
look like real credentials (private keys, cloud access keys, tokens,
credentialed connection strings) are highlighted in red so you can review
the flagged lines before deciding. PII-class findings are informational —
the upstream scanner has known false-positive classes (e.g.
`git@github.com` SSH syntax, documentation example emails), so they never
block anything.
To enable it, install the optional scanner binaries (the second one powers
the `security` check; without it that check simply reports "not run"):
```bash
uv tool install --python 3.13 \
"skillevaluator @ git+https://github.com/NVIDIA/SkillEvaluator.git@v0.1.0"
uv tool install "git+https://github.com/NVIDIA/SkillSpector.git@v2.9.5"
```
Without the binary on PATH the scan is silently skipped. To turn it off
entirely:
```yaml
skills:
tier1_advisory: false
```
The dashboard's Browse-hub scan button returns the same advisory data in
its response (`tier1` field) alongside the built-in scanner's verdict.
## External Skill Directories
If you maintain skills outside of Hermes — for example, a shared `~/.agents/skills/` directory used by multiple AI tools — you can tell Hermes to scan those directories too.
Add `external_dirs` under the `skills` section in `~/.hermes/config.yaml`:
```yaml
skills:
external_dirs:
- ~/.agents/skills
- /home/shared/team-skills
- ${SKILLS_REPO}/skills
```
Paths support `~` expansion and `${VAR}` environment variable substitution.
### How it works
- **Create locally, update in place**: New agent-created skills are written to `~/.hermes/skills/` (or `skills.create_dir` when configured — see below). Existing skills are modified where they are found, including skills under `external_dirs`, when the agent uses `skill_manage` actions such as `patch` (targeted or full rewrite), `write_file`, `remove_file`, or `delete`.
- **External dirs are not a write-protection boundary**: If an external skill directory is writable by the Hermes process, agent-managed skill updates can change files in that directory. Use filesystem permissions or a separate profile/toolset setup if shared external skills must stay read-only.
- **Local precedence**: If the same skill name exists in both the local dir and an external dir, the local version wins.
- **Full integration**: External skills appear in the system prompt index, `skills_list`, `skill_view`, and as `/skill-name` slash commands — no different from local skills.
- **Non-existent paths are silently skipped**: If a configured directory doesn't exist, Hermes ignores it without errors. Useful for optional shared directories that may not be present on every machine.
### Example
```text
~/.hermes/skills/ # Local (primary, read-write)
├── devops/deploy-k8s/
│ └── SKILL.md
└── mlops/axolotl/
└── SKILL.md
~/.agents/skills/ # External (shared, mutable if writable)
├── my-custom-workflow/
│ └── SKILL.md
└── team-conventions/
└── SKILL.md
```
All four skills appear in your skill index. If you create a new skill called `my-custom-workflow` locally, it shadows the external version.
## Redirecting Skill Creation (`skills.create_dir`)
By default the agent writes new skills to the profile-local `~/.hermes/skills/`. If you want agent-created skills to land somewhere else — a shared "brain" directory, a git-tracked repo, or a fleet-wide skills volume — set `create_dir` under the `skills` section:
```yaml
skills:
create_dir: /opt/brain/skills
```
What this changes:
- **`skill_manage` create writes there.** New skills (including category subdirectories) are created under `create_dir` instead of the local skills dir. The directory is created on first write if it doesn't exist.
- **The agent's instructions follow the config.** Every agent-facing instruction that names the skill-creation path — the `skill_manage` tool description and related prompt text — dynamically renders the configured directory, so the agent is told to create skills there. No system-prompt overrides or filesystem tricks needed.
- **The directory is fully integrated.** Skills under `create_dir` are scanned alongside the local dir: they appear in the skill index, `skills_list`, `skill_view`, slash commands, and can be patched or deleted like any local skill.
- **Everything else stays local.** Existing skills are still modified in place wherever they live; bundled skill sync, the hub, and the curator keep operating on the profile-local dir.
Paths support `~` expansion and `${VAR}` substitution; relative paths resolve against your Hermes home. Setting `create_dir` to the local skills dir is the same as leaving it unset.
## Project-Local Skills
Repos can carry their own skills, active only for sessions started inside that project — the same pattern other agent harnesses use for repo-local configuration. When you launch Hermes inside a git checkout, it looks for skills in:
```text
/.hermes/skills/ # Hermes-native location
/.agents/skills/ # cross-tool convention (shared with other agent CLIs)
```
The project root is the nearest ancestor directory containing `.git` (worktrees and submodules count).
### Trusting a project
Skills are procedure documents the agent follows, so Hermes does **not** auto-load them from arbitrary cloned repos. The first time you run Hermes in a repo with project skills, the banner shows a notice:
```text
◆ 3 project skill(s) found in /home/you/myproject but not loaded — run `hermes skills trust` to enable them.
```
Trust the repo once (from inside it, or by passing the path):
```bash
hermes skills trust # trust the current repo
hermes skills trust ~/myproject # or explicitly
hermes skills untrust # revoke
```
Trusted roots are stored in `skills.trusted_project_dirs` in `~/.hermes/config.yaml`. Set `skills.project_discovery: false` to turn the feature off entirely (no scanning, no notices).
### Precedence
Project skills are the **highest-precedence tier**: `project → local (~/.hermes/skills/) → external_dirs`. A project skill named `deploy` overrides a same-named profile or bundled skill for sessions inside that repo — that's the point: vendored repo skills win on their home turf, without touching your global profile. Project skills are tagged `[project]` in the agent's skill index so provenance stays visible.
Like external dirs, project skill directories are treated as repo-owned: autonomous skill maintenance (the curator) never modifies them, and new agent-created skills always go to `~/.hermes/skills/`.
### Scan-time quarantine
Trust is a repo-level decision, but a repo's skill content changes with every `git pull`. To close that gap, every project skill is scanned with the same security scanner used for Skills Hub installs before it enters the index. A skill whose scan verdict is **dangerous** (prompt-injection directives, credential-exfiltration commands, hidden-text tricks) is quarantined: it does not appear in the skill index, `skills_list`, slash commands, and refuses to load by name with an explanatory error. Scans are content-hash cached under `~/.hermes/cache/project_skill_scans/` (never inside your repo) and re-run automatically when the skill's content changes.
### Non-interactive surfaces (cron, API, ACP)
Cron jobs and other non-interactive surfaces inherit your interactive trust decision — they never prompt and never auto-trust. The project root resolves from the surface's working directory (a cron job's `workdir`, via the same mechanism the terminal tool uses). A cron job whose `workdir` is inside a repo you previously trusted loads that repo's project skills; a job in an untrusted or undecided repo loads none.
In the TUI and Desktop the project root follows each **session's workspace** (the directory shown in the sidebar / set with the workspace picker), so starting `hermes --tui` inside a trusted repo registers its project skills as slash commands even when `terminal.cwd` is left at the default placeholder `.`; two sessions open in two repos each see their own.
## Skill Bundles
Skill bundles are tiny YAML files that group several skills under a single slash command. When you run `/`, every skill listed in the bundle loads at once — useful when a particular task always benefits from the same set of skills together.
### Quick example
```bash
# Create a bundle for backend feature work
hermes bundles create backend-dev \
--skill github-code-review \
--skill test-driven-development \
--skill github-pr-workflow \
-d "Backend feature work — review, test, PR workflow"
```
Then in the CLI or any gateway platform:
```
/backend-dev refactor the auth middleware
```
The agent receives all three skills loaded into one user message, with any text after the slash command attached as a user instruction.
### YAML schema
Bundles live in **`~/.hermes/skill-bundles/.yaml`** and look like this:
```yaml
name: backend-dev
description: Backend feature work — review, test, PR workflow.
skills:
- github-code-review
- test-driven-development
- github-pr-workflow
instruction: |
Always start by writing failing tests, then implement.
Open the PR through the standard workflow with co-author tags.
```
Fields:
- `name` (optional — defaults to the filename stem) — the bundle's display name. Normalized to a hyphen slug for the slash command (`Backend Dev` → `/backend-dev`).
- `description` (optional) — short text shown in `/bundles` and `hermes bundles list`.
- `skills` (required, non-empty list) — skill names or paths relative to your skills directory. Use the same identifier you'd pass to `/`.
- `instruction` (optional) — extra guidance prepended to the loaded skill content. Useful for codifying "how we always use these together."
### Managing bundles
```bash
# List all installed bundles
hermes bundles list
# Inspect one bundle
hermes bundles show backend-dev
# Create a bundle interactively (omit --skill flags to enter them one per line)
hermes bundles create research
# Overwrite an existing bundle
hermes bundles create backend-dev --skill ... --force
# Delete a bundle
hermes bundles delete backend-dev
# Re-scan ~/.hermes/skill-bundles/ and report changes
hermes bundles reload
```
From inside a chat session, `/bundles` lists every installed bundle and its skills.
### Behavior
- **Bundles take precedence over individual skills** when slugs collide. If you name a bundle `research` and you also have a skill called `research`, `/research` invokes the bundle. This is intentional — you opted into the bundle by naming it.
- **Missing skills are skipped, not fatal.** If a bundle lists `skill-foo` and you haven't installed it, the bundle still loads the skills that do resolve, and the agent gets a note listing what was skipped.
- **Bundles work in every surface** — interactive CLI, TUI, dashboard chat, and every gateway platform (Telegram, Discord, Slack, …) — because dispatch is centralized in the same place as individual skill commands.
- **Bundles do not invalidate the prompt cache.** They generate a fresh user message at invocation time, the same way `/` does — no system prompt mutation.
### When bundles beat installing each skill manually
Use a bundle when:
- You always pair the same skills for a recurring task (`/backend-dev`, `/release-prep`, `/incident-response`).
- You want a one-character-shorter mental model than typing several `/skill` invocations in a row.
- You want to ship a team-wide "task profile" by checking the bundle YAML into a shared dotfiles repo and symlinking it into `~/.hermes/skill-bundles/`.
A bundle is just a YAML alias — it doesn't install skills for you. The skills themselves must already be present (in `~/.hermes/skills/` or an external skill directory). Otherwise the bundle invocation just skips the missing ones.
## Agent-Managed Skills (skill_manage tool)
The agent can create, update, and delete its own skills via the `skill_manage` tool. This is the agent's **procedural memory** — when it figures out a non-trivial workflow, it saves the approach as a skill for future reuse.
Skills and memory work together in the self-improvement loop: memory stores
small durable facts that should always be in context, while skills store longer
procedures that should load only when relevant. The background review can
suggest or stage skill changes after a session, but the write-approval gate
below lets you require human review before those changes land.
### When the Agent Creates Skills
The system prompt asks the agent to record a non-trivial workflow with `skill_manage` for
future reuse. In practice that covers:
- When it worked out a multi-step workflow worth repeating
- When it hit errors or dead ends and found the working path
- When the user corrected its approach
### What a skill entry looks like
A skill is the instructions for doing a class of task the most efficient and correct
way, to your specifications: the procedure in order, the commands and tool calls that
work, how you want the result to look, and the pitfalls that cost time. Whether written
in a foreground turn, by the background review, or by the curator's consolidation pass,
it captures **lessons, not logs**: a pitfall is a generalizable rule plus one clause of
*why* (the mechanism), attached to the step it affects, stated once. Incident narration, PR or
issue numbers, dates, and quoted chat are not skill content; the rule has to stand
without the story behind it. Always-on rules live in `SKILL.md` itself; `references/`
holds a small set of files named by topic (a decision table, a recipe, provider quirks),
extended in place rather than accumulated one file per session. Skills also do not
restate what is already loaded every turn (the repo's `AGENTS.md`, tool schemas).
`skill_manage` runs an advisory linter on `create`, on `SKILL.md` patches, and on `references/`
writes and returns its findings in the tool result. Three rules exist specifically for this shape:
`incident-log-shape` (a body dense in PR/issue numbers), `references-sprawl` (more
than 60 reference files), and `oversized-body` (a `SKILL.md` body past ~24k chars — `skill_view`
loads the whole file and it stays in context for the rest of the session). They warn; they never
block a write.
### Actions
| Action | Use for | Key params |
|--------|---------|------------|
| `create` | New skill from scratch | `name`, `content` (full SKILL.md), optional `category` |
| `patch` | Targeted fixes (preferred) | `name`, `old_string`, `new_string` |
| `patch` with `content` | Major structural rewrites (replaces the whole SKILL.md; `edit` is the legacy alias) | `name`, `content` |
| `delete` | Remove a skill entirely | `name` |
| `write_file` | Add/update supporting files | `name`, `file_path`, `file_content` |
| `remove_file` | Remove a supporting file | `name`, `file_path` |
Each action is advertised as its own shape: the text slot belongs to one action only
(`content` → create / full rewrite, `new_string` → targeted patch, `file_content` →
write_file). An op that carries another action's slot — e.g. `file_content` on a
`create` — is invalid against the tool schema (grammar-constrained local backends never
emit it) and, if it arrives anyway, is rejected **before any op in the batch is
applied**, with an error naming the key the text sits in and where to move it.
:::tip
The targeted `patch` is preferred for updates — it's more token-efficient than a full rewrite because only the changed text appears in the tool call.
:::
### Gating agent skill writes (`skills.write_approval`)
By default the agent writes skills freely — including from the [background
self-improvement review](./memory.md#controlling-memory-writes-write_approval)
that runs after a turn. If you'd rather approve every skill write first
(small models that misjudge what they learned, secure environments, or just
wanting eyes on the self-improvement loop), turn on the write-approval gate:
```yaml
skills:
write_approval: false # false = write freely (default) | true = require approval
```
When `write_approval: true`, every `skill_manage` write (create / edit /
patch / delete / write_file / remove_file) is **staged** instead of committed —
a SKILL.md is too large to review inline, so staging applies regardless of
whether the write came from a foreground turn or the background review.
Staged writes survive restarts under `~/.hermes/pending/skills/` and are
reviewed with the same familiar approve/deny flow as dangerous commands:
```
/skills pending # list staged skill writes + a one-line gist each
/skills diff # full unified diff (best viewed in CLI or dashboard)
/skills approve # apply it (or 'all')
/skills reject # drop it (or 'all')
/skills approval on # turn the gate on (or 'off') and persist it
```
The review surface works in the interactive CLI and on messaging platforms
(diff output is truncated for chat bubbles — read the full diff on the CLI or
in the pending JSON file). Memory writes have the same gate under
`memory.write_approval` — see [Controlling memory writes](./memory.md#controlling-memory-writes-write_approval).
> The separate `skills.guard_agent_created` setting is a content scanner
> (dangerous-pattern heuristics), not an approval gate — the two are
> independent. See [Guard on agent-created skill writes](../configuration.md#guard-on-agent-created-skill-writes).
## Skills Hub
Browse, search, install, and manage skills from online registries, `skills.sh`, direct well-known skill endpoints, and official optional skills.
Unfiltered searches (CLI, TUI, and the dashboard) are answered from a cached centralized index that covers the external registries. That index is rebuilt periodically, so when it has no match for your query Hermes also asks `skills.sh`, ClawHub, LobeHub and well-known endpoints directly — a skill published minutes ago still shows up. That extra pass gets at most 8 seconds of the search budget, so a slow registry cannot turn a miss into a long wait. Custom GitHub taps are not part of that fallback (search them with `--source github`, or via the index once it catches up), and provider filters such as `--source nvidia` do not trigger it (those registries carry no provider data).
### Common commands
```bash
hermes skills browse # Browse all hub skills (official first)
hermes skills browse --source official # Browse only official optional skills
hermes skills search kubernetes # Search all sources
hermes skills search react --source skills-sh # Search the skills.sh directory
hermes skills search https://mintlify.com/docs --source well-known
hermes skills inspect openai/skills/k8s # Preview before installing
hermes skills install openai/skills/k8s # Install with security scan
hermes skills install official/security/1password
hermes skills install skills-sh/vercel-labs/json-render/json-render-react --force
hermes skills install well-known:https://mintlify.com/docs/.well-known/skills/mintlify
hermes skills install https://sharethis.chat/SKILL.md # Direct URL (+ referenced support files)
hermes skills install https://example.com/SKILL.md --name my-skill # Override name when frontmatter has none
hermes skills list --source hub # List hub-installed skills
hermes skills check # Check installed hub skills for upstream updates
hermes skills update # Reinstall hub skills with upstream changes when needed
hermes skills audit # Re-scan all hub skills for security
hermes skills uninstall k8s # Remove a hub skill
hermes skills reset google-workspace # Un-stick a bundled skill from "user-modified" (see below)
hermes skills reset google-workspace --restore # Also restore the bundled version, deleting your local edits
hermes skills publish skills/my-skill --to github --repo owner/repo
hermes skills snapshot export setup.json # Export skill config
hermes skills tap add myorg/skills-repo # Add a custom GitHub source
```
### Supported hub sources
| Source | Example | Notes |
|--------|---------|-------|
| `official` | `official/security/1password` | Optional skills shipped with Hermes. |
| `skills-sh` | `skills-sh/vercel-labs/agent-skills/vercel-react-best-practices` | Searchable via `hermes skills search --source skills-sh`. Hermes resolves alias-style skills when the skills.sh slug differs from the repo folder. |
| `well-known` | `well-known:https://mintlify.com/docs/.well-known/skills/mintlify` | Skills served directly from `/.well-known/skills/index.json` on a website. Search using the site or docs URL. |
| `url` | `https://sharethis.chat/SKILL.md` | Direct HTTP(S) URL to `SKILL.md` plus explicitly referenced support files. Name resolution: frontmatter → URL slug → interactive prompt → `--name` flag. |
| `github` | `openai/skills/k8s` | Direct GitHub repo/path installs and custom taps. |
| `clawhub`, `lobehub`, `browse-sh` | Source-specific identifiers | Community or marketplace integrations. |
### Integrated hubs and registries
Hermes currently integrates with these skills ecosystems and discovery sources:
#### 1. Official optional skills (`official`)
These are maintained in the Hermes repository itself and install with built-in trust.
- Catalog: [Official Optional Skills Catalog](../../reference/optional-skills-catalog)
- Source in repo: `optional-skills/`
- Example:
```bash
hermes skills browse --source official
hermes skills install official/security/1password
```
#### 2. skills.sh (`skills-sh`)
This is Vercel's public skills directory. Hermes can search it directly, inspect skill detail pages, resolve alias-style slugs, and install from the underlying source repo.
- Directory: [skills.sh](https://skills.sh/)
- CLI/tooling repo: [vercel-labs/skills](https://github.com/vercel-labs/skills)
- Official Vercel skills repo: [vercel-labs/agent-skills](https://github.com/vercel-labs/agent-skills)
- Example:
```bash
hermes skills search react --source skills-sh
hermes skills inspect skills-sh/vercel-labs/json-render/json-render-react
hermes skills install skills-sh/vercel-labs/json-render/json-render-react --force
```
#### 3. Well-known skill endpoints (`well-known`)
This is URL-based discovery from sites that publish `/.well-known/skills/index.json`. It is not a single centralized hub — it is a web discovery convention.
- Example live endpoint: [Mintlify docs skills index](https://mintlify.com/docs/.well-known/skills/index.json)
- Reference server implementation: [vercel-labs/skills-handler](https://github.com/vercel-labs/skills-handler)
- Example:
```bash
hermes skills search https://mintlify.com/docs --source well-known
hermes skills inspect well-known:https://mintlify.com/docs/.well-known/skills/mintlify
hermes skills install well-known:https://mintlify.com/docs/.well-known/skills/mintlify
```
#### 4. Direct GitHub skills (`github`)
Hermes can install directly from GitHub repositories and GitHub-based taps. This is useful when you already know the repo/path or want to add your own custom source repo.
Default taps (browsable without any setup):
- [openai/skills](https://github.com/openai/skills)
- [anthropics/skills](https://github.com/anthropics/skills)
- [huggingface/skills](https://github.com/huggingface/skills)
- [NVIDIA/skills](https://github.com/NVIDIA/skills) — NVIDIA-verified skills (signed `skill.oms.sig` + governance `skill-card.md`)
- [garrytan/gstack](https://github.com/garrytan/gstack)
- [K-Dense-AI/scientific-agent-skills](https://github.com/K-Dense-AI/scientific-agent-skills) and [synthetic-sciences/openscience](https://github.com/synthetic-sciences/openscience) — ~480 scientific research skills (bioinformatics, chemistry, physics, ML training, scholarly tooling), grouped under one `science` category. Community trust: every install is security-scanned. Many wrap third-party tools with their own licenses (some GPL; KEGG requires a commercial license for non-academic use) — check each skill's prerequisites.
- Example:
```bash
hermes skills install openai/skills/k8s
hermes skills tap add myorg/skills-repo
```
**Category groupings (`skills.sh.json`).** A GitHub tap may ship a
`skills.sh.json` file at its repo root following the
[skills.sh schema](https://skills.sh/schemas/skills.sh.schema.json). Its
`groupings` (each with a `title` and a list of skill names) are read at index
time and become the category labels shown in the
[Skills Hub](https://hermes-agent.nousresearch.com/docs) page — instead of a
tag-derived guess. This is generic: any tap that ships the file gets real
categorization, no Hermes-side changes required.
```json
{
"$schema": "https://skills.sh/schemas/skills.sh.schema.json",
"groupings": [
{ "title": "Inference AI", "skills": ["dynamo-recipe-runner", "dynamo-router-sla"] },
{ "title": "Decision Optimization", "skills": ["cuopt-developer", "cuopt-install"] }
]
}
```
#### 5. ClawHub (`clawhub`)
A third-party skills marketplace integrated as a community source.
- Site: [clawhub.ai](https://clawhub.ai/)
- Hermes source id: `clawhub`
#### 6. LobeHub (`lobehub`)
Hermes can search and convert agent entries from LobeHub's public catalog into installable Hermes skills.
- Site: [LobeHub](https://lobehub.com/)
- Public agents index: [chat-agents.lobehub.com](https://chat-agents.lobehub.com/)
- Backing repo: [lobehub/lobe-chat-agents](https://github.com/lobehub/lobe-chat-agents)
- Hermes source id: `lobehub`
#### 7. browse.sh (`browse-sh`)
Hermes integrates with [browse.sh](https://browse.sh), Browserbase's catalog of 200+ site-specific browser-automation SKILL.md files (Airbnb, Amazon, arXiv, 12306.cn, Etsy, Xero, and many more). Each skill describes how to drive one website end-to-end and is suitable for use with Hermes' browser tools and any browser-automation skills you already have installed.
- Site: [browse.sh](https://browse.sh/)
- Catalog API: `https://browse.sh/api/skills`
- Hermes source id: `browse-sh`
- Trust level: `community`
```bash
hermes skills search airbnb --source browse-sh
hermes skills inspect browse-sh/airbnb.com/search-listings-ddgioa
hermes skills install browse-sh/airbnb.com/search-listings-ddgioa
```
Identifiers use the form `browse-sh//` and match the slug exposed by the browse.sh catalog. Content is resolved through the per-skill detail endpoint (`/api/skills/` → `skillMdUrl`), not through the catalog's GitHub `sourceUrl`.
#### 8. Direct URL (`url`)
Install `SKILL.md` directly from any HTTP(S) URL — useful when an author hosts a skill on their own site (no hub listing, no GitHub path to type). Hermes also fetches explicitly referenced files under `references/`, `templates/`, `scripts/`, `assets/`, and `examples/`, then scans and installs the complete bundle.
- Hermes source id: `url`
- Identifier: the URL itself (no prefix needed)
- Scope: `SKILL.md` plus exact referenced support files in the allowlisted directories. Hermes does not enumerate or copy unrelated files from the host.
```bash
hermes skills install https://sharethis.chat/SKILL.md
hermes skills install https://example.com/my-skill/SKILL.md --category productivity
```
Name resolution, in order:
1. `name:` field in the SKILL.md YAML frontmatter (recommended — every well-formed skill has one).
2. Parent directory name from the URL path (e.g. `.../my-skill/SKILL.md` → `my-skill`, or `.../my-skill.md` → `my-skill`), when it's a valid identifier (`^[a-z][a-z0-9_-]*$`).
3. Interactive prompt on a terminal with a TTY.
4. On non-interactive surfaces (the `/skills install` slash command inside the TUI, gateway platforms, scripts), a clean error pointing at the `--name` override.
```bash
# Frontmatter has no name and the URL slug is unhelpful — supply one:
hermes skills install https://example.com/SKILL.md --name sharethis-chat
# Or inside a chat session:
/skills install https://example.com/SKILL.md --name sharethis-chat
```
Trust level is always `community` — the same security scan runs as for every other source. The URL is stored as the install identifier, so `hermes skills update` re-fetches from the same URL automatically when you want to refresh.
### Security scanning and `--force`
All hub-installed skills go through a **security scanner** that checks for data exfiltration, prompt injection, destructive commands, supply-chain signals, and other threats.
`hermes skills inspect ...` now also surfaces upstream metadata when available:
- repo URL
- skills.sh detail page URL
- install command
- weekly installs
- upstream security audit statuses
- well-known index/endpoint URLs
Use `--force` when you have reviewed a third-party skill and want to override a non-dangerous policy block:
```bash
hermes skills install skills-sh/anthropics/skills/pdf --force
```
Important behavior:
- `--force` can override policy blocks for caution/warn-style findings.
- `--force` does **not** override a `dangerous` scan verdict.
- Official optional skills (`official/...`) are treated as built-in trust and do not show the third-party warning panel.
### Trust levels
| Level | Source | Policy |
|-------|--------|--------|
| `builtin` | Ships with Hermes | Always trusted |
| `official` | `optional-skills/` in the repo | Built-in trust, no third-party warning |
| `trusted` | Trusted registries/repos such as `openai/skills`, `anthropics/skills`, `huggingface/skills`, `NVIDIA/skills` | More permissive policy than community sources |
| `community` | Everything else (`skills.sh`, well-known endpoints, custom GitHub repos, most marketplaces) | Non-dangerous findings can be overridden with `--force`; `dangerous` verdicts stay blocked |
### Update lifecycle
The hub now tracks enough provenance to re-check upstream copies of installed skills:
```bash
hermes skills check # Report which installed hub skills changed upstream
hermes skills update # Reinstall only the skills with updates available
hermes skills update react # Update one specific installed hub skill
hermes skills update react --force # Overwrite a skill you've edited locally
```
This uses the stored source identifier plus the current upstream bundle content hash to detect drift.
Checks skip network requests for missing or non-directory installs (`orphaned`) and unsafe or unresolvable recorded paths (`invalid_install`). Missing-directory entries can be removed with `hermes skills uninstall `; invalid paths require inspecting and repairing the active profile's `skills/.hub/lock.json` before retrying. No entries are removed automatically.
Valid installs continue to use their source adapter’s existing synchronous fetch and transport timeouts. There is no strict total deadline for an update check: an unreachable or slow source for an existing install can still delay later entries.
Skills you have edited locally (the on-disk content no longer matches the hash recorded at install time) are **skipped** by `hermes skills update` so your changes are never silently overwritten. Pass `--force` to replace them with the upstream version anyway.
:::tip GitHub rate limits
Skills hub operations use the GitHub API, which has a rate limit of 60 requests/hour for unauthenticated users. If you see rate-limit errors during install or search, set `GITHUB_TOKEN` in your `.env` file to increase the limit to 5,000 requests/hour. The error message includes an actionable hint when this happens.
:::
### Publishing a custom skill tap
If you want to share a curated set of skills — for your team, your org, or publicly — you can publish them as a **tap**: a GitHub repository other Hermes users add with `hermes skills tap add `. No server, no registry sign-up, no release pipeline. Just a directory of `SKILL.md` files.
#### Repo layout
A tap is any GitHub repo (public or private — private needs `GITHUB_TOKEN`) laid out like this:
```
owner/repo
├── skills/ # default path; configurable per-tap
│ ├── my-workflow/
│ │ ├── SKILL.md # required
│ │ ├── references/ # optional supporting files
│ │ ├── templates/
│ │ └── scripts/
│ ├── another-skill/
│ │ └── SKILL.md
│ └── third-skill/
│ └── SKILL.md
└── README.md # optional but helpful
```
Rules:
- Each skill lives in its own directory under the tap's root path (default `skills/`).
- The directory name becomes the skill's install slug.
- Each skill directory must contain a `SKILL.md` with standard [SKILL.md frontmatter](#skillmd-format) (`name`, `description`, plus optional `metadata.hermes.tags`, `version`, `author`, `platforms`, `metadata.hermes.config`).
- Subdirectories like `references/`, `templates/`, `scripts/`, `assets/` are downloaded alongside `SKILL.md` at install time.
- Skills whose directory name starts with `.` or `_` are ignored.
Hermes discovers skills by listing every subdirectory of the tap path and probing each for `SKILL.md`.
#### Minimal tap example
```
my-org/hermes-skills
└── skills/
└── deploy-runbook/
└── SKILL.md
```
`skills/deploy-runbook/SKILL.md`:
```markdown
---
name: deploy-runbook
description: Our deployment runbook — services, rollback, Slack channels
version: 1.0.0
author: My Org Platform Team
metadata:
hermes:
tags: [deployment, runbook, internal]
---
# Deploy Runbook
Step 1: ...
```
After pushing that to GitHub, any Hermes user can subscribe and install:
```bash
hermes skills tap add my-org/hermes-skills
hermes skills search deploy
hermes skills install my-org/hermes-skills/deploy-runbook
```
#### Non-default paths
If your skills don't live under `skills/` (common when you're adding a `skills/` subtree to an existing project), edit the tap entry in `~/.hermes/skills/.hub/taps.json`:
```json
{
"taps": [
{"repo": "my-org/platform-docs", "path": "internal/skills/"}
]
}
```
The `hermes skills tap add` CLI defaults new taps to `path: "skills/"`; edit the file directly if you need a different path. `hermes skills tap list` shows the effective path per tap.
#### Installing individual skills directly (without adding a tap)
Users can also install a single skill from any public GitHub repo without adding the whole repo as a tap:
```bash
hermes skills install owner/repo/skills/my-workflow
```
Useful when you want to share one skill without asking the user to subscribe to your whole registry.
#### Trust levels for taps
New taps are assigned `community` trust by default. Skills installed from them run through the standard security scan and show the third-party warning panel on first install. If your org or a widely-trusted source should get higher trust, add its repo to `TRUSTED_REPOS` in `tools/skills_guard.py` (requires a Hermes core PR).
#### Tap management
```bash
hermes skills tap list # show all configured taps
hermes skills tap add myorg/skills-repo # add (default path: skills/)
hermes skills tap remove myorg/skills-repo # remove
```
Inside a running session:
```
/skills tap list
/skills tap add myorg/skills-repo
/skills tap remove myorg/skills-repo
```
Taps are stored in `~/.hermes/skills/.hub/taps.json` (created on demand).
## Bundled skill updates (`hermes skills reset`)
Hermes ships with a set of bundled skills in `skills/` inside the repo. On install and on every `hermes update`, a sync pass copies those into `~/.hermes/skills/` and records a manifest at `~/.hermes/skills/.bundled_manifest` mapping each skill name to the content hash at the time it was synced (the **origin hash**).
On each sync, Hermes recomputes the hash of your local copy and compares it to the origin hash:
- **Unchanged** → safe to pull upstream changes, copy the new bundled version in, record the new origin hash.
- **Changed** → treated as **user-modified** and skipped forever, so your edits never get stomped.
Generated runtime caches inside a skill (`__pycache__/`, `.pytest_cache/`, `.mypy_cache/`, `.ruff_cache/`, and a `.pyc` sitting next to its `.py`) are not part of the hash, so running a skill's helper script never marks it user-modified or hides it from `hermes skills list-modified` / `diff`.
The protection is good, but it has one sharp edge. If you edit a bundled skill and then later want to abandon your changes and go back to the bundled version by just copy-pasting from `~/.hermes/hermes-agent/skills/`, the manifest still holds the *old* origin hash from whenever the last successful sync ran. Your fresh copy-paste contents (current bundled hash) won't match that stale origin hash, so sync keeps flagging it as user-modified.
`hermes skills reset` is the escape hatch:
```bash
# Safe: clears the manifest entry for this skill. Your current copy is preserved,
# but the next sync re-baselines against it so future updates work normally.
hermes skills reset google-workspace
# Full restore: also deletes your local copy and re-copies the current bundled
# version. Use this when you want the pristine upstream skill back.
hermes skills reset google-workspace --restore
# Non-interactive (e.g. in scripts or TUI mode) — skip the --restore confirmation.
hermes skills reset google-workspace --restore --yes
```
The same command works in chat as a slash command:
```text
/skills reset google-workspace
/skills reset google-workspace --restore
```
:::note Profiles
Each profile has its own `.bundled_manifest` under its own `HERMES_HOME`, so `hermes -p coder skills reset ` only affects that profile.
:::
### Slash commands (inside chat)
All the same commands work with `/skills`:
```text
/skills browse
/skills search react --source skills-sh
/skills search https://mintlify.com/docs --source well-known
/skills inspect skills-sh/vercel-labs/json-render/json-render-react
/skills install openai/skills/skill-creator --force
/skills check
/skills update
/skills reset google-workspace
/skills list
```
Official optional skills still use identifiers like `official/security/1password` and `official/migration/openclaw-migration`.
---
# Curator
# Curator
The curator is a background maintenance pass for **agent-created skills**. It tracks how often each skill is viewed, used, and patched, moves long-unused skills through `active → stale → archived` states, and periodically spawns a short auxiliary-model review that proposes consolidations or patches drift.
It exists so that skills created via the [self-improvement loop](./skills.md#agent-managed-skills-skill_manage-tool) don't pile up forever. Every time the agent solves a novel problem and saves a skill, that skill lands in `~/.hermes/skills/`. Without maintenance, you end up with dozens of narrow near-duplicates that pollute the catalog and waste tokens.
By default the curator manages only agent-created skills. With `curator.prune_builtins: true` it can also archive **unused bundled built-in skills** (shipped with the repo) after `archive_after_days` of non-use; this is opt-in because shipped skills silently disappearing from `skills_list` is easy to mistake for a broken install. Hub-installed skills (from [agentskills.io](https://agentskills.io)) are always off-limits. The curator also **never auto-deletes** — the worst outcome is archival into `~/.hermes/skills/.archive/`, which is recoverable.
Tracks [issue #7816](https://github.com/NousResearch/hermes-agent/issues/7816).
## How it runs
The curator is triggered by an inactivity check, not a cron job. On CLI session start, during gateway housekeeping, and on the Desktop/`hermes serve` maintenance timer, Hermes checks whether:
1. Enough time has passed since the last curator run (`interval_hours`, default **7 days**), and
2. The agent has been idle long enough (`min_idle_hours`, default **2 hours**).
Desktop and other `hermes serve` backends share the existing hourly maintenance timer (first poll after 90 seconds), independently of cron jobs. They measure inactivity from process startup and the most recent chat activity in the same profile, retaining that activity timestamp after a session closes or is reaped, and skip curator while a turn in that profile is running. A connected but inactive window does not block maintenance. This timer also polls personal and organization Skill Sync, subject to those features' own opt-in gates. A running messaging gateway for the same profile owns these chores instead.
The timer services its backend's profile. In-flight maintenance runs in a worker thread; closing the backend does not cooperatively interrupt that pass. Starting multiple independent serve processes for the same profile can still race the curator's interval check.
If both are true, it spawns a background fork of `AIAgent` — the same pattern used by the memory/skill self-improvement nudges. The fork runs in its own prompt cache and never touches the active conversation.
:::info First-run behavior
On a brand-new install (or the first time a pre-curator install ticks after `hermes update`), the curator **does not run immediately**. The first observation seeds `last_run_at` to "now" and defers the first real pass by one full `interval_hours`. This gives you a full interval to review your skill library, pin anything important, or opt out entirely before the curator ever touches it.
If you want to see what the curator *would* do before it runs for real, run `hermes curator run --dry-run` — it produces the same review report without mutating the library.
:::
A run has two phases:
1. **Automatic transitions** (deterministic, no LLM). Skills unused for `stale_after_days` (14) become `stale`; skills unused for `archive_after_days` (30) are moved to `~/.hermes/skills/.archive/`. This is the always-on pruning behavior — it runs whenever the curator is enabled, with no aux-model cost.
- **Pinned skills** and **skills referenced by any cron job** (including paused/disabled jobs) are skipped entirely — treated like pin for auto-transitions so a slow or paused schedule cannot archive a skill out from under a job. Consolidation also rewrites cron skill references when it merges umbrellas.
- **Never-used skills** (`use_count == 0`) get a grace floor: they are not archived until they are at least `stale_after_days` old. Zero uses is absence of evidence, not proof the skill is disposable.
2. **LLM consolidation** (single aux-model pass with a high iteration ceiling — a full curation sweep typically takes 50–100 API calls) — **OFF by default**. When `curator.consolidate: true`, the forked agent surveys the agent-created skills, can read any of them with `skill_view`, and decides per-skill whether to keep, patch (via `skill_manage`), consolidate overlapping ones into class-level umbrellas, or archive (via `skill_manage action=delete` with an `absorbed_into` target — the pass has no terminal). The candidate list it is given contains only skills it can actually read and write: bundled built-ins (even with `prune_builtins: true`, which only affects the deterministic archival pass) and skills in `skills.disabled` (which `skill_view` refuses) are never offered to it, so the pass cannot burn its tool budget on refused reads or writes. Consolidation treats a skill as a full package: if a skill has `references/`, `templates/`, `scripts/`, `assets/`, or relative links to those paths, the curator must either keep it standalone, re-home the needed support files and rewrite paths, or archive the entire package unchanged — not flatten only `SKILL.md` into another skill's `references/` file.
:::info Consolidation is opt-in
By default the curator only **prunes** — the deterministic inactivity pass marks skills stale and archives long-unused ones. The opinionated LLM **consolidation** pass (umbrella-building, merging overlapping skills) is off by default because it costs aux-model tokens on every run and makes broad structural changes to your library. Turn it on with `curator.consolidate: true`, or run it once on demand with `hermes curator run --consolidate`.
:::
Pinned skills are off-limits to both the curator's auto-transitions and the agent's own `skill_manage` tool. See [Pinning a skill](#pinning-a-skill) below.
## Configuration
All settings live in `config.yaml` under `curator:` (not `.env` — this isn't a secret). Defaults:
```yaml
curator:
enabled: true
interval_hours: 168 # 7 days
min_idle_hours: 2
stale_after_days: 14
archive_after_days: 30
consolidate: false # LLM umbrella-building pass — opt-in (prune-only by default)
prune_builtins: false # opt in to archiving unused bundled built-in skills too (hub skills always exempt)
```
To disable entirely, set `curator.enabled: false`. To keep the always-on pruning but opt into LLM consolidation, set `curator.consolidate: true`.
### Running the review on a cheaper aux model
The curator's LLM review pass is a regular auxiliary task slot — `auxiliary.curator` — alongside Vision, Compression, Session Search, etc. "Auto" means "use my main chat model"; override the slot to pin a specific provider + model for the review pass instead.
**Easiest — `hermes model`:**
```bash
hermes model # → "Auxiliary models — side-task routing"
# → pick "Curator" → pick provider → pick model
```
The same picker is available in the web dashboard under the **Models** tab.
**Direct config.yaml (equivalent):**
```yaml
auxiliary:
curator:
provider: openrouter
model: google/gemini-3-flash-preview
timeout: 600 # generous — reviews can take several minutes
```
Leaving `provider: auto` (the default) routes the review pass through whatever your main chat model is, matching the behavior of every other auxiliary task.
:::note Legacy config
Earlier releases used a one-off `curator.auxiliary.{provider,model}` block. That path still works but emits a deprecation log line — please migrate to `auxiliary.curator` above so the curator shares the same plumbing (`hermes model`, dashboard Models tab, `base_url`, `api_key`, `timeout`, `extra_body`) as every other aux task.
:::
## CLI
```bash
hermes curator status # last run, counts, pinned list, LRU top 5
hermes curator run # trigger a run now (blocks until done). Prune-only unless curator.consolidate: true
hermes curator run --consolidate # force the LLM consolidation pass on for this run, overriding the config default
hermes curator run --background # fire-and-forget: start the run in a background thread
hermes curator run --dry-run # preview only — report without any mutations
hermes curator backup # take a manual snapshot of ~/.hermes/skills/
hermes curator rollback # restore from the newest snapshot
hermes curator rollback --list # list available snapshots
hermes curator rollback --id # restore a specific snapshot
hermes curator rollback -y # skip the confirmation prompt
hermes curator pause # stop runs until resumed
hermes curator resume
hermes curator pin # never auto-transition this skill
hermes curator unpin
hermes curator adopt # hand an unmanaged skill to the curator
hermes curator adopt --all-unmanaged # hand over every unmanaged skill
hermes curator list-unmanaged # itemize skills with no provenance marker
hermes curator restore