Technical Documentation: NousResearch/hermes-agent
ℹ️ Provenance: Multi-Tier Ingestion · CodeWiki Reference
Overview (README.md)
Hermes Agent ☤
The self-improving AI agent built by Nous Research. It's the only agent with a built-in learning loop — it creates skills from experience, improves them during use, nudges itself to persist knowledge, searches its own past conversations, and builds a deepening model of who you are across sessions. Run it on a $5 VPS, a GPU cluster, or serverless infrastructure that costs nearly nothing when idle. It's not tied to your laptop — talk to it from Telegram while it works on a cloud VM.
Use any model you want — Nous Portal, OpenRouter, OpenAI, your own endpoint, and many others. Switch with hermes model — no code changes, no lock-in.
| A real terminal interface | Full TUI with multiline editing, slash-command autocomplete, conversation history, interrupt-and-redirect, and streaming tool output. |
|---|---|
| Lives where you do | Telegram, Discord, Slack, WhatsApp, Signal, and CLI — all from a single gateway process. Voice memo transcription, cross-platform conversation continuity. |
| A closed learning loop | Agent-curated memory with periodic nudges. Autonomous skill creation after complex tasks. Skills self-improve during use. FTS5 session search with LLM summarization for cross-session recall. Honcho dialectic user modeling. Compatible with the agentskills.io open standard. |
| Scheduled automations | Built-in cron scheduler with delivery to any platform. Daily reports, nightly backups, weekly audits — all in natural language, running unattended. |
| Delegates and parallelizes | Spawn isolated subagents for parallel workstreams. In Desktop, choose their provider, model, and limits under Settings → Model → Subagents, then Apply the complete route. Write Python scripts that call tools via RPC, collapsing multi-step pipelines into zero-context-cost turns. |
| Runs anywhere, not just your laptop | Seven terminal backends — local, Docker, SSH, Singularity, Modal, Daytona, and Vercel Sandbox. Daytona and Modal offer serverless persistence — your agent's environment hibernates when idle and wakes on demand, costing nearly nothing between sessions. Run it on a $5 VPS or a GPU cluster. |
| Research-ready | Batch trajectory generation, trajectory compression for training the next generation of tool-calling models. |
Quick Install
Linux, macOS, WSL2
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bashWindows (native, PowerShell)
Heads up: Native Windows runs Hermes without WSL — CLI, gateway, TUI, and tools all work natively. If you'd rather use WSL2, the Linux/macOS one-liner above works there too. Found a bug? Please file issues.
Run this in PowerShell:
iex (irm https://hermes-agent.nousresearch.com/install.ps1)The source installer delegates Python 3.14, Node.js, npm, ripgrep, FFmpeg,
and Python dependencies to PM. If Git is absent, it stages the verified Git
for Windows archive in Hermes' tool store. It does not replace your system Git.
See installation methods
for the separate MSIX/App Installer package and its update ownership.
Android / Termux: A signed APT repository is available for aarch64 devices, with a
stablechannel (tagged releases) and a prereleasecanarychannel. The package includes Python, Node.js, and the TUI. Use the Termux guide, not the desktop/server installer script.Windows: Native Windows is fully supported — the PowerShell one-liner above installs everything. If you'd rather use WSL2, the Linux command works there too. Native Windows install lives under
%LOCALAPPDATA%\hermes; WSL2 installs under~/.hermesas on Linux.
After installation:
source ~/.bashrc # reload shell (or: source ~/.zshrc)
hermes # start chatting!Troubleshooting
Windows Defender or antivirus flags `uv.exe` as malware
If your antivirus (Bitdefender, Windows Defender, etc.) quarantines uv.exe from the Hermes bin folder (%LOCALAPPDATA%\hermes\bin\uv.exe), this is a false positive. The file is Astral's uv — the Rust Python package manager Hermes bundles to manage its Python environment. ML-based antivirus engines commonly flag unsigned Rust binaries that download and install packages.
To verify your copy is authentic:
# Install GitHub CLI if needed
winget install --id GitHub.cli
# Login to GitHub
gh auth login
# Run verification
$uv = "$env:LOCALAPPDATA\hermes\bin\uv.exe"
$ver = (& $uv --version).Split(' ')[1]
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$zip = "$env:TEMP\uv.zip"
Invoke-WebRequest "https://github.com/astral-sh/uv/releases/download/$ver/uv-x86_64-pc-windows-msvc.zip" -OutFile $zip -UseBasicParsing
gh attestation verify $zip --repo astral-sh/uv
Expand-Archive $zip "$env:TEMP\uv_x" -Force
(Get-FileHash "$env:TEMP\uv_x\uv.exe").Hash -eq (Get-FileHash $uv).HashIf attestation says "Verification succeeded" and the last line prints True, you're good.
To whitelist Hermes:
- Windows Defender: Run PowerShell as Admin →
Add-MpPreference -ExclusionPath "$env:LOCALAPPDATA\hermes\bin" - Bitdefender: Add an exception in the Bitdefender console (Protection > Antivirus > Settings > Manage Exceptions)
- Whitelist the folder, not the file hash — Hermes updates
uvand the hash changes every version
For more context, see the upstream Astral reports: astral-sh/uv#13553, astral-sh/uv#15011, astral-sh/uv#10079.
Getting Started
hermes # Interactive CLI — start a conversation
hermes model # Choose your LLM provider and model
hermes tools # Configure which tools are enabled
hermes config set # Set individual config values
hermes config get # Print individual config values
hermes gateway # Start the messaging gateway (Telegram, Discord, etc.)
hermes setup # Run the full setup wizard (configures everything at once)
hermes claw migrate # Migrate from OpenClaw (if coming from OpenClaw)
hermes update # Update to the latest version
hermes doctor # Diagnose any issuesSkip the API-key collection — Nous Portal
Hermes works with whatever provider you want — that's not changing. But if you'd rather not collect five separate API keys for the model, web search, image generation, TTS, and a cloud browser, Nous Portal covers all of them under one subscription:
- 300+ models — pick any of them with
/model <name> - Tool Gateway — web search, image generation (FAL), text-to-speech (OpenAI), cloud browser (Browser Use), all routed through your sub. No extra accounts.
One command from a fresh install:
hermes setup --portalThat logs you in via OAuth, sets Nous as your provider, and turns on the Tool Gateway. Check what's wired up any time with hermes portal info. Full details on the Tool Gateway docs page.
You can still bring your own keys per-tool whenever you want — the gateway is per-backend, not all-or-nothing.
CLI vs Messaging Quick Reference
Hermes has two entry points: start the terminal UI with hermes, or run the gateway and talk to it from Telegram, Discord, Slack, WhatsApp, Signal, or Email. Once you're in a conversation, many slash commands are shared across both interfaces.
| Action | CLI | Messaging platforms |
|---|---|---|
| Start chatting | hermes |
Run hermes gateway setup + hermes gateway start, then send the bot a message |
| Start fresh conversation | /new or /reset |
/new or /reset |
| Change model | /model [provider:model] |
/model [provider:model] |
| Set a personality | /personality [name] |
/personality [name] |
| Retry or undo the last turn | /retry, /undo |
/retry, /undo |
| Compress context / check usage | /compress, /usage, /insights [--days N] |
/compress, /usage, /insights [days] |
| Browse skills | /skills or /<skill-name> |
/<skill-name> |
| Interrupt current work | Ctrl+C or send a new message |
/stop or send a new message |
| Platform-specific status | /platforms |
/status, /sethome |
For the full command lists, see the CLI guide and the Messaging Gateway guide.
Documentation
All documentation lives at hermes-agent.nousresearch.com/docs:
| Section | What's Covered |
|---|---|
| Quickstart | Install → setup → first conversation in 2 minutes |
| CLI Usage | Commands, keybindings, personalities, sessions |
| Configuration | Config file, providers, models, all options |
| Messaging Gateway | Telegram, Discord, Slack, WhatsApp, Signal, Home Assistant |
| Security | Command approval, DM pairing, container isolation |
| Tools & Toolsets | 40+ tools, toolset system, terminal backends |
| Skills System | Procedural memory, Skills Hub, creating skills |
| Memory | Persistent memory, user profiles, best practices |
| MCP Integration | Connect any MCP server for extended capabilities |
| Cron Scheduling | Scheduled tasks with platform delivery |
| Context Files | Project context that shapes every conversation |
| Architecture | Project structure, agent loop, key classes |
| Contributing | Development setup, PR process, code style |
| CLI Reference | All commands and flags |
| Environment Variables | Complete env var reference |
Migrating from OpenClaw
If you're coming from OpenClaw, Hermes can automatically import your settings, memories, skills, and API keys.
During first-time setup: The setup wizard (hermes setup) automatically detects ~/.openclaw and offers to migrate before configuration begins.
Anytime after install:
hermes claw migrate # Interactive migration (full preset)
hermes claw migrate --dry-run # Preview what would be migrated
hermes claw migrate --preset user-data # Migrate without secrets
hermes claw migrate --overwrite # Overwrite existing conflictsWhat gets imported:
- SOUL.md — persona file
- Memories — MEMORY.md and USER.md entries
- Skills — user-created skills →
~/.hermes/skills/openclaw-imports/ - Command allowlist — approval patterns
- Messaging settings — platform configs, allowed users, working directory
- API keys — allowlisted secrets (Telegram, OpenRouter, OpenAI, Anthropic, ElevenLabs)
- TTS assets — workspace audio files
- Workspace instructions — AGENTS.md (with
--workspace-target)
See hermes claw migrate --help for all options, or use the openclaw-migration skill for an interactive agent-guided migration with dry-run previews.
Contributing
We welcome contributions! See the Contributing Guide for development setup, code style, and PR process.
Start with the PM developer workflow
for activation, daily use, dependency changes, and leaving the environment.
Development Setup covers the separate test environment and verification commands.
Community
- 💬 Discord
- 📚 Skills Hub
- 🐛 Issues
- 🔌 computer-use-linux — Linux desktop-control MCP server for Hermes and other MCP hosts, with AT-SPI accessibility trees, Wayland/X11 input, screenshots, and compositor window targeting.
- 🔌 HermesClaw — Community WeChat bridge: Run Hermes Agent and OpenClaw on the same WeChat account.
License
MIT — see LICENSE.
Built by Nous Research.
Index (`website/docs/index.mdx`)
slug: / sidebar_position: 0 title: "Hermes Agent Documentation" hide_table_of_contents: true displayed_sidebar: docs
import Link from "@docusaurus/Link";
Hermes Agent
The self-improving AI agent built by Nous Research. The only agent with a built-in learning loop — it creates skills from experience, improves them during use, nudges itself to persist knowledge, and builds a deepening model of who you are across sessions.
Get Started → Explore Bot Mode Download Desktop View on GitHubInstall
Windows or macOS
To easily install the command-line and desktop applications, download the Hermes Desktop installer 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.
Linux / macOS / WSL2
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bashWindows (native)
Run in powershell:
iex (irm https://hermes-agent.nousresearch.com/install.ps1)See the full Installation Guide for what the installer does, installation ownership and data locations, and Windows-specific notes. For the complete platform support matrix, see Platform Support.
:::tip Fastest path to a working agent
After installing, run hermes setup --portal — one OAuth covers a model plus all four Tool Gateway tools (web search, image generation, TTS, browser). See Nous Portal.
:::
What is Hermes Agent?
It's not a coding copilot tethered to an IDE or a chatbot wrapper around a single API. It's an autonomous agent that gets more capable the longer it runs. It lives wherever you put it — a $5 VPS, a GPU cluster, or serverless infrastructure (Daytona, Modal) that costs nearly nothing when idle. Talk to it from Telegram while it works on a cloud VM you never SSH into yourself. It's not tied to your laptop.
Quick Links
| 🚀 Installation | Install in 60 seconds on Linux, macOS, WSL2, native Windows, Nix & NixOS or Android |
| 📖 Quickstart Tutorial | Your first conversation and key features to try |
| 🗺️ Learning Path | Find the right docs for your experience level |
| ⚙️ Configuration | Config file, providers, models, and options |
| 💬 Messaging Gateway | Set up Telegram, Discord, Slack, WhatsApp, Teams, or more |
| 🤖 Bot Mode | Named Bots with their own model, memory, skills, routines, and chats |
| 🔧 Tools & Toolsets | 60+ built-in tools and how to configure them |
| 🧠 Memory System | Persistent memory that grows across sessions |
| 📚 Skills System | Procedural memory the agent creates and reuses |
| 🔌 MCP Integration | Connect to MCP servers, filter their tools, and extend Hermes safely |
| 🧭 Use MCP with Hermes | Practical MCP setup patterns, examples, and tutorials |
| 🎙️ Voice Mode | Real-time voice interaction in CLI, Telegram, Discord, and Discord VC |
| 🗣️ Use Voice Mode with Hermes | Hands-on setup and usage patterns for Hermes voice workflows |
| 🎭 Personality & SOUL.md | Define Hermes' default voice with a global SOUL.md |
| 📄 Context Files | Project context files that shape every conversation |
| 🔒 Security | Command approval, authorization, container isolation |
| 💡 Tips & Best Practices | Quick wins to get the most out of Hermes |
| 🏗️ Architecture | How it works under the hood |
| ❓ FAQ & Troubleshooting | Common questions and solutions |
Key Features
- A closed learning loop — Agent-curated memory with periodic nudges, autonomous skill creation, skill self-improvement during use, FTS5 cross-session recall with LLM summarization, and Honcho dialectic user modeling
- Runs anywhere, not just your laptop — 7 terminal backends: local, Docker, SSH, Daytona, Singularity, Modal, Vercel Sandbox. Daytona and Modal offer serverless persistence — your environment hibernates when idle, costing nearly nothing
- Lives where you do — CLI, Telegram, Discord, Slack, WhatsApp, Signal, Matrix, Mattermost, Email, SMS, DingTalk, Feishu, WeCom, Weixin, QQ Bot, Yuanbao, BlueBubbles, Home Assistant, Microsoft Teams, Google Chat, and more — 20+ platforms from one gateway
- Built by model trainers — Created by Nous Research, the lab behind Hermes, Nomos, and Psyche. Works with Nous Portal, OpenRouter, OpenAI, or any endpoint
- Scheduled automations — Built-in cron with delivery to any platform
- Bot Mode — Build a durable team of specialist Bots that work together in group chats and through
@mentions - Delegates & parallelizes — Spawn isolated subagents for parallel workstreams. Programmatic Tool Calling via
execute_codecollapses multi-step pipelines into single inference calls - Open standard skills — Compatible with agentskills.io. Skills are portable, shareable, and community-contributed via the Skills Hub
- Full web control — Search, extract, browse, vision, image generation, TTS — one subscription via Nous Portal bundles all of them
- MCP support — Connect to any MCP server for extended tool capabilities
- Research-ready — Batch processing, trajectory export, RL training with Atropos. Built by Nous Research — the lab behind Hermes, Nomos, and Psyche models
For LLMs and coding agents
Machine-readable entry points to this documentation:
/llms.txt— curated index of every doc page with short descriptions. ~17 KB, safe to load into an LLM context./llms-full.txt— every doc page concatenated into a single markdown file for one-shot ingestion. ~1.8 MB.
Both files also resolve at /docs/llms.txt and /docs/llms-full.txt. Generated fresh on every deploy.
Getting Started Quickstart (`website/docs/getting-started/quickstart.md`)
sidebar_position: 1 title: "Hermes Agent Quickstart"
Hermes Agent Quickstart
Python dependency commands on this page use a
PM-prepared source checkout.
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 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 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.
Linux / macOS / WSL2
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bashWindows (native)
Run in powershell:
iex (irm https://hermes-agent.nousresearch.com/install.ps1)After it finishes, reload your shell:
source ~/.bashrc # or source ~/.zshrcFor detailed installation options, prerequisites, and troubleshooting, see the Installation guide.
2. Choose a Provider
The single most important setup step. Use hermes model to walk through the choice interactively:
hermes model:::tip Easiest path: Nous Portal
One subscription covers 300+ models plus the Tool Gateway (web search, image generation, TTS, cloud browser). On a fresh install:
hermes setup --portalThat 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. 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) |
| 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 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.
:::
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:
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
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 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:
hermes --continue # Resume the most recent session
hermes -c # Short formThat 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
hermes gateway setup # Interactive platform configurationConnect Telegram, Discord, Slack, WhatsApp, Signal, Email, Home Assistant (plugin), or Microsoft Teams.
Automation and tools
hermes tools— tune tool access per platformhermes 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:
hermes config set terminal.backend docker # Docker isolation
hermes config set terminal.backend ssh # Remote serverFor 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. 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.
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:
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:
/k8s deploy the staging manifest # run the skill with a request
/k8s # load it and let Hermes ask what you needThis 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 for writing your own, external skill directories, and the full hub source list.
MCP servers
# 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:
hermes acp(If you installed without [all], run cd ~/.hermes/hermes-agent && python -c "import pm; pm.sync_venv(['acp'], explicit=True)" first.)
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:
hermes doctorhermes modelhermes setuphermes sessions listhermes --continuehermes 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
- User Stories: real setups from the community, a good place to start if you're not sure what to hand Hermes first
- CLI Guide — Master the terminal interface
- Configuration — Customize your setup
- Messaging Gateway — Connect Telegram, Discord, Slack, WhatsApp, Signal, Email, Home Assistant, Teams, and more
- Tools & Toolsets — Explore available capabilities
- AI Providers — Full provider list and setup details
- Skills System — Reusable workflows and knowledge
- Tips & Best Practices — Power user tips
- Moving to another machine —
hermes backupmigrates your whole setup (or a single profile); no need to rebuild from scratch
Integrations Index (`website/docs/integrations/index.md`)
title: "Integrations" sidebar_label: "Overview" sidebar_position: 0
Integrations
Hermes Agent connects to external systems for AI inference, tool servers, IDE workflows, programmatic access, and more. These integrations extend what Hermes can do and where it can run.
:::tip Start here
If you only have time to set up one integration, set up Nous Portal — a single OAuth login covers 300+ models plus the four Tool Gateway tools (web search, image generation, TTS, and browser automation).
:::
AI Providers & Routing
Hermes supports multiple AI inference providers out of the box. Use hermes model to configure interactively, or set them in config.yaml.
- AI Providers — OpenRouter, Anthropic, OpenAI, Google, and any OpenAI-compatible endpoint. Hermes auto-detects capabilities like vision, streaming, and tool use per provider.
- Provider Routing — Fine-grained control over which underlying providers handle your OpenRouter requests. Optimize for cost, speed, or quality with sorting, whitelists, blacklists, and explicit priority ordering.
- Fallback Providers — Automatic failover to backup LLM providers when your primary model encounters errors. Includes primary model fallback and independent auxiliary task fallback for vision, compression, and web extraction.
Tool Servers (MCP)
- MCP Servers — Connect Hermes to external tool servers via Model Context Protocol. Access tools from GitHub, databases, file systems, browser stacks, internal APIs, and more without writing native Hermes tools. Supports both stdio and SSE transports, per-server tool filtering, and capability-aware resource/prompt registration.
Web Search Backends
The web_search and web_extract tools support eight backend providers, configured via config.yaml or hermes tools:
| Backend | Env Var | Search | Extract | Crawl |
|---|---|---|---|---|
| Firecrawl (default) | FIRECRAWL_API_KEY |
✔ | ✔ | ✔ |
| SearXNG | SEARXNG_URL |
✔ | — | — |
| Brave (free tier) | BRAVE_SEARCH_API_KEY |
✔ | — | — |
| DuckDuckGo (ddgs) | (none) | ✔ | — | — |
| Exa | EXA_API_KEY |
✔ | ✔ | — |
| Parallel | PARALLEL_API_KEY |
✔ | ✔ | — |
| xAI | XAI_API_KEY |
✔ | — | — |
Quick setup example:
web:
backend: firecrawl # firecrawl | searxng | brave-free | ddgs | tavily | perplexity | keenable | exa | parallel | xaiIf web.backend is not set, the backend is auto-detected from whichever API key is available. Self-hosted Firecrawl is also supported via FIRECRAWL_API_URL.
Browser Automation
Hermes includes full browser automation with multiple backend options for navigating websites, filling forms, and extracting information:
- Browser Use Cloud — Managed Chromium with stealth, residential proxies, CAPTCHA solving, and reusable browser profiles
- Browserbase — Alternative cloud browser provider with managed browsers, anti-bot tooling, CAPTCHA solving, and residential proxies
- Local Chromium-family CDP — Connect to your running Chrome, Brave, Chromium, or Edge browser using
/browser connect - Local Chromium — Headless local browser via the
agent-browserCLI
See Browser Automation for setup and usage.
Voice & TTS Providers
Text-to-speech and speech-to-text across all messaging platforms:
| Provider | Quality | Cost | API Key |
|---|---|---|---|
| Edge TTS (default) | Good | Free | None needed |
| ElevenLabs | Excellent | Paid | ELEVENLABS_API_KEY |
| OpenAI TTS | Good | Paid | VOICE_TOOLS_OPENAI_KEY |
| MiniMax | Good | Paid | MINIMAX_API_KEY |
| xAI TTS | Good | Paid | XAI_API_KEY |
| NeuTTS | Good | Free | None needed |
Speech-to-text supports eight providers: local faster-whisper (free, runs on-device), a local command wrapper, Groq, OpenAI Whisper API, Mistral, xAI, ElevenLabs Scribe, and DeepInfra. Voice message transcription works across Telegram, Discord, WhatsApp, and other messaging platforms. See Voice & TTS and Voice Mode for details.
IDE & Editor Integration
- IDE Integration (ACP) — Use Hermes Agent inside ACP-compatible editors such as VS Code, Zed, and JetBrains. Hermes runs as an ACP server, rendering chat messages, tool activity, file diffs, and terminal commands inside your editor.
Programmatic Access
- API Server — Expose Hermes as an OpenAI-compatible HTTP endpoint. Any frontend that speaks the OpenAI format — Open WebUI, LobeChat, LibreChat, NextChat, ChatBox — can connect and use Hermes as a backend with its full toolset.
Memory & Personalization
- Built-in Memory — Persistent, curated memory via
MEMORY.mdandUSER.mdfiles. The agent maintains bounded stores of personal notes and user profile data that survive across sessions. - Memory Providers — Plug in external memory backends for deeper personalization. Two providers ship with Hermes: RetainDB (hybrid search) and ByteRover (CLI-based); Honcho (dialectic reasoning), Hindsight (knowledge graphs), Supermemory (semantic recall), Mem0 (cloud extraction), OpenViking (tiered retrieval) and Holographic (local SQLite) are available from the plugin catalog via
hermes plugins install <name>.
Messaging Platforms
Hermes runs as a gateway bot on 27+ messaging platforms, all configured through the same gateway subsystem:
- Telegram, Discord, Slack, WhatsApp, Signal, Matrix, Mattermost, Email, SMS, DingTalk, Feishu/Lark, WeCom, WeCom Callback, Weixin, BlueBubbles, Buzz, QQ Bot, Yuanbao, Home Assistant (plugin), Microsoft Teams, Microsoft Teams Meetings, Microsoft Graph Webhook, Google Chat, LINE, ntfy, SimpleX, Open WebUI, Webhooks
See the Messaging Gateway overview for the platform comparison table and setup guide.
Quick connect links
The big platforms have a canonical "create your bot/app" URL, and some accept parameters that pre-open the right form. Skip the console-hunting and go straight there:
| Platform | Direct link | What it opens |
|---|---|---|
| Telegram | t.me/BotFather | Chat with BotFather — send /newbot to mint a bot token |
| Discord | discord.com/developers/applications?new_application=true | Developer Portal with the New Application dialog pre-opened |
| Slack | api.slack.com/apps?new_app=1 | The Create New App dialog — pick From an app manifest and paste the manifest hermes slack manifest --agent-view generates |
| LINE | developers.line.biz/console | LINE Developers Console for creating a Messaging API channel |
| Feishu/Lark | open.feishu.cn/app | Feishu open-platform console for creating a custom app |
Each platform's setup page walks through what to do once you're there.
Collaboration Workspaces
- Buzz — Block's Nostr-based human+agent workspace. Three integration paths: Buzz Desktop spawns Hermes as a managed ACP runtime, the
buzz-acprelay bridge hosts a Hermes identity server-side, or the native gateway platform joins Buzz channels with full Hermes memory/skills/approvals/cron. The overview page compares all three.
Home Automation
- Home Assistant — Control smart home devices via four dedicated tools (
ha_list_entities,ha_get_state,ha_list_services,ha_call_service) and react to real-time state changes through the gateway. Ships as thehomeassistantcatalog plugin (hermes plugins install homeassistant); its toolset activates automatically whenHASS_TOKENis configured.
Plugins
- Plugin System — Extend Hermes with custom tools, lifecycle hooks, and CLI commands without modifying core code. Plugins are discovered from
~/.hermes/plugins/, project-local.hermes/plugins/, and pip-installed entry points. - Build a Plugin — Step-by-step guide for creating Hermes plugins with tools, hooks, and CLI commands.
Training & Evaluation
- Batch Processing — Run the agent across hundreds of prompts in parallel, generating structured ShareGPT-format trajectory data for training data generation or evaluation.
User Guide Windows Wsl Quickstart (`website/docs/user-guide/windows-wsl-quickstart.md`)
title: "Windows (WSL2) Guide" sidebar_label: "Windows (WSL2)" sidebar_position: 2
Windows (WSL2) Guide
Hermes Agent now supports both native Windows and WSL2. This page covers the WSL2 path; for the native PowerShell install see the dedicated Windows (Native) Guide.
When to pick WSL2 over native:
- You want to use the dashboard's embedded terminal (
/chattab) — that pane requires a POSIX PTY and is WSL2-only. - You're doing POSIX-heavy development work and want your Hermes sessions to share the same filesystem / paths as your dev tools.
- You already have a WSL2 environment and don't want to maintain a second install.
When native is fine (or better):
- Interactive chat, gateway (Telegram/Discord/etc.), cron scheduler, browser tool, MCP servers, and most Hermes features all run natively on Windows.
- You don't want to think about crossing the WSL↔Windows boundary every time you reference a file or open a URL.
In WSL2 there are effectively two computers in play: your Windows host, and a Linux VM managed by WSL. Most confusion comes from not being sure which one you're on at any moment.
This guide covers the parts of that split that specifically affect Hermes: installing WSL2, getting files back and forth between Windows and Linux, networking in both directions, and the pitfalls people actually hit.
:::info 简体中文
A Chinese-language walkthrough of the minimum install path is maintained on this same page — switch via the language menu (top right) and select 简体中文.
:::
Why WSL2 (vs. native Windows)
The native Windows install runs in Windows directly: your Windows terminal (PowerShell, Windows Terminal, etc.), Windows filesystem paths (C:\Users\…), and Windows processes. Hermes uses Git Bash to run shell commands, which is how Claude Code and other agents handle Windows today — it sidesteps the POSIX-vs-Windows gap without a full rewrite.
WSL2 runs a real Linux kernel in a lightweight VM, so Hermes inside it is essentially identical to running on Ubuntu. That's valuable when you want a real POSIX environment: fork, /tmp, UNIX sockets, signal semantics, PTY-backed terminals, shells like bash/zsh, and tools like rg, git, ffmpeg that behave the way they do on Linux.
Practical consequences of WSL2:
- The Hermes CLI, gateway, sessions, memory, skills, and tool runtimes all live inside the Linux VM.
- Windows programs (browsers, native apps, Chrome with your logged-in profile) live outside it.
- Every time you want the two to talk — share files, open URLs, control Chrome, hit a local model server, expose the Hermes gateway to your phone — you cross a boundary. Those boundaries are what this guide is about.
Install WSL2
From an Admin PowerShell or Windows Terminal:
wsl --installOn a fresh Windows 10 22H2+ or Windows 11 box this installs the WSL2 kernel, the Virtual Machine Platform feature, and a default Ubuntu distro. Reboot when prompted. After reboot Ubuntu will open and ask for a Linux username + password — this is a new Linux user, unrelated to your Windows account.
Verify you're actually on WSL2 (not legacy WSL1):
wsl --list --verboseYou should see VERSION 2. If a distro shows VERSION 1, convert it:
wsl --set-version Ubuntu 2
wsl --set-default-version 2Hermes does not work reliably on WSL1 — WSL1 translates Linux syscalls on the fly and some behaviors (procfs, signals, network) diverge from real Linux.
Distro choice
Ubuntu (LTS) is what we test against. Debian works. Arch and NixOS work for people who want them, but the one-line installer assumes a Debian-derived apt system — see the Nix setup guide for that path.
Enable systemd (recommended)
The hermes gateway (and anything else you want to keep running) is easier to manage with systemd. On modern WSL, enable it once inside your distro:
sudo tee /etc/wsl.conf >/dev/null <<'EOF'
[boot]
systemd=true
[interop]
enabled=true
appendWindowsPath=true
[automount]
options = "metadata,umask=22,fmask=11"
EOFThen from PowerShell:
wsl --shutdownReopen your WSL terminal. ps -p 1 -o comm= should print systemd.
The metadata mount option above is important — without it, files on /mnt/c/... can't store real Linux permission bits, which breaks things like chmod +x on scripts under Windows paths.
Install Hermes inside WSL
Once you have a WSL2 shell open:
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
source ~/.bashrc
hermesThe installer treats WSL2 as plain Linux — nothing WSL-specific is needed. See Installation for the full layout.
Filesystem: crossing the Windows ↔ WSL2 boundary
This is the part that trips up the most people. There are two filesystems, and where you put your files matters — for performance, correctness, and what tools can see.
The two directions
| Direction | Path inside | Path you use |
|---|---|---|
| Windows disk, seen from WSL | C:\Users\you\Documents |
/mnt/c/Users/you/Documents |
| WSL disk, seen from Windows | /home/you/code |
\\wsl$\Ubuntu\home\you\code (or \\wsl.localhost\Ubuntu\... on newer builds) |
Both are real, both work, but they are not the same filesystem — they're bridged by a 9P network protocol under the hood. That has real performance and semantic consequences.
Where to put Hermes and your projects
Rule of thumb: keep everything Linux-ish inside the Linux filesystem.
- Your Hermes install (
~/.hermes/) — Linux side. The installer already does this. - Your git repos that you work on from WSL — Linux side (
~/code/...,~/projects/...). - Your models, datasets, venvs — Linux side.
What you get by following this rule:
- Fast I/O. Operations on
/mnt/c/...go through 9P and are 10–100× slower than native ext4.git statuson a 10k-file repo that feels instant under~/codecan take 15+ seconds under/mnt/c. - Correct permissions. Linux permission bits are a best-effort emulation on
/mnt/c. Things likesshrefusing a key with "bad permissions" orchmod +xsilently failing are common. - Reliable file watchers. inotify across 9P is flaky — file watchers (dev servers, test runners) routinely miss changes on
/mnt/c. - No case-sensitivity surprises. Windows paths are case-insensitive by default; Linux is case-sensitive. Projects with both
Readme.mdandREADME.mdbehave differently depending which side you're on.
Put things on /mnt/c only when you need a file to live on the Windows side — e.g., you want to open it from a Windows GUI app, or Windows Chrome's DevTools MCP needs the current directory to be a Windows-reachable path.
Getting files back and forth
From Windows → into WSL: easiest is to open Explorer and type \\wsl.localhost\Ubuntu in the address bar. You can then drag-drop into \home\<you>\.... Or from PowerShell:
wsl cp /mnt/c/Users/you/Downloads/file.pdf ~/incoming/From WSL → into Windows: copy to /mnt/c/Users/<you>/... and it shows up in Windows Explorer immediately:
cp ~/reports/output.pdf /mnt/c/Users/you/Desktop/Open a WSL file in a Windows app (GUI editor, browser, etc.): use explorer.exe or wslview:
sudo apt install wslu # once — gives you wslview, wslpath, wslopen, etc.
wslview ~/reports/output.pdf # opens with the Windows default handler
explorer.exe . # opens the current WSL dir in Windows ExplorerConvert paths between the two universes:
wslpath -w ~/code/project # → \\wsl.localhost\Ubuntu\home\you\code\project
wslpath -u 'C:\Users\you' # → /mnt/c/Users/youLine endings, BOMs, and git
If you edit files on the Windows side with a Windows editor, they may get CRLF line endings. When bash or Python on the Linux side reads them, shell scripts break with bad interpreter: /bin/bash^M and Python can fail on BOM'd .env files.
The fix is a sane git config inside WSL (not on Windows):
git config --global core.autocrlf input
git config --global core.eol lfFor files that already have CRLF:
sudo apt install dos2unix
dos2unix path/to/script.sh"Clone inside WSL or on `/mnt/c`?"
Clone inside WSL. Always, unless you have a specific reason not to. A typical Hermes workflow (hermes chat, tool calls that rg/ripgrep the repo, file watchers, background gateway) will be dramatically faster and more reliable against ~/code/myrepo than /mnt/c/Users/you/myrepo.
One exception: MCP bridges that launch Windows binaries. If you're using chrome-devtools-mcp through cmd.exe (see MCP guide: WSL → Windows Chrome), Windows may complain with a UNC warning if Hermes's current working directory is ~. In that case, start Hermes from somewhere under /mnt/c/ so the Windows process has a drive-letter cwd.
Networking: WSL ↔ Windows
WSL2 runs in a lightweight VM with its own network stack. That means localhost inside WSL is not the same as localhost on Windows — they're two separate hosts from the network's point of view. You need to decide, for each service, which direction traffic flows and pick the right bridge.
Two cases come up constantly.
Case 1 — Hermes in WSL talks to a service on Windows
Most common: you're running Ollama, LM Studio, or a llama-server on Windows, and Hermes (inside WSL) needs to hit it.
The canonical how-to for this lives in the providers guide: WSL2 Networking for Local Models →
Short version:
- Windows 11 22H2+: turn on mirrored networking mode (
networkingMode=mirroredin%USERPROFILE%\.wslconfig, thenwsl --shutdown).localhostthen works in both directions. - Windows 10 or older builds: use the Windows host IP (the default gateway of WSL's virtual network) and make sure the server on Windows binds to
0.0.0.0, not just127.0.0.1. Windows Firewall usually also needs a rule for the port.
For the full table (Ollama / LM Studio / vLLM / SGLang bind addresses, firewall rule one-liners, dynamic IP helpers, Hyper-V firewall workaround), follow the link above — don't duplicate it.
Case 2 — Something on Windows (or your LAN) talks to Hermes in WSL
This is the reverse direction and is less documented elsewhere, but it's what you need for:
- Using the Hermes web dashboard from a Windows browser.
- Using the OpenAI-compatible API server (exposed by
hermes gatewaywhenAPI_SERVER_ENABLED=true) from a Windows-side tool. See the API Server feature page. - Testing a messaging gateway (Telegram, Discord, etc.) where the platform pings a local webhook URL — usually you'd use
cloudflared/ngrokrather than raw port forwarding.
Subcase 2a: from the Windows host itself
On Windows 11 22H2+ with mirrored mode enabled, there is nothing to do. A process in WSL that binds to 0.0.0.0:8080 (or even 127.0.0.1:8080) is reachable from a Windows browser at http://localhost:8080. WSL publishes the bind back to the host automatically.
On NAT mode (Windows 10 / older Windows 11), the default "localhost forwarding" in WSL2 will generally forward Linux-side 127.0.0.1 binds to Windows localhost, so a Hermes service started with --host 127.0.0.1 is usually reachable as http://localhost:PORT from Windows. If it isn't:
- Bind to
0.0.0.0explicitly inside WSL. - Find the WSL VM's IP with
ip -4 addr show eth0 | grep inetand hit that from Windows.
Subcase 2b: from another device on your LAN (phone, tablet, another PC)
This is the real pain. Traffic flows LAN device → Windows host → WSL VM, and you have to set up both hops:
Bind on all interfaces inside WSL. A process listening on
127.0.0.1will never be reachable from outside the VM. Use0.0.0.0.Port-forward Windows → WSL VM. In mirrored mode this is automatic. In NAT mode you have to do it yourself, per port, in Admin PowerShell:
powershell# Grab the WSL VM's current IP (it changes on every WSL restart under NAT) $wslIp = (wsl hostname -I).Trim().Split(' ')[0] # Forward Windows port 8080 → WSL:8080 netsh interface portproxy add v4tov4 ` listenaddress=0.0.0.0 listenport=8080 ` connectaddress=$wslIp connectport=8080 # Allow it through Windows Firewall New-NetFirewallRule -DisplayName "Hermes WSL 8080" ` -Direction Inbound -Protocol TCP -LocalPort 8080 -Action AllowRemove later with
netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=8080.Point the LAN device at
http://<windows-lan-ip>:8080.
Because the WSL VM IP drifts on each restart in NAT mode, a one-shot rule survives only until the next wsl --shutdown. For anything persistent, either use mirrored mode or put the port-proxy step in a script that runs at Windows login.
For webhooks from cloud messaging providers (Telegram setWebhook, Slack events, etc.), don't fight port-forwarding — use cloudflared tunnels. See the webhooks guide.
Running Hermes services long-term on Windows
The Hermes Tool Gateway and the API server are long-lived processes. In WSL2 you have a few options for keeping them up.
Desktop shortcut for opening Hermes quickly
If you just want a double-click launcher for an interactive Hermes shell, create
it on the Windows side and have it jump into WSL for you:
Right-click the Windows desktop and choose New -> Shortcut.
For the target, use your distro name (replace
Ubuntuif needed):textwt.exe -w 0 -p "Ubuntu" wsl.exe -d Ubuntu --cd ~ -- bash -ic "hermes"Name it something obvious like
Hermes.
That opens Windows Terminal, starts your WSL distro, drops you in your Linux
home directory, and launches Hermes. If hermes is not on PATH yet, open WSL
once manually and run source ~/.bashrc, or replace the command withpython hermes inside your PM-activated project checkout.
Optional polish:
- Custom icon: open Properties -> Change Icon and point it at an
.ico
file, such as the Hermes favicon from the repo. - Pinned launcher: once the shortcut works, pin it to Start or Taskbar so
you do not have to browse for it again.
Inside WSL with systemd (recommended)
If you enabled systemd per the setup section above, hermes gateway and the API server work the way they do on any Linux machine. Use the gateway setup wizard:
hermes gateway setupIt will offer to install a systemd user unit so the gateway comes up automatically when WSL starts.
Making WSL itself start on Windows login
WSL's VM only stays alive while something is using it. To keep your gateway reachable without a terminal window open, boot a WSL process at Windows login via Task Scheduler:
- Trigger: At log on (your user).
- Action: Start a program
- Program:
C:\Windows\System32\wsl.exe - Arguments:
-d Ubuntu --exec /bin/sh -c "sleep infinity"
- Program:
That keeps the VM alive so the systemd-managed gateway stays running. On Windows 11, the newer wsl --install --no-launch + auto-start flows also work; the sleep infinity trick is the portable version.
GPU passthrough (local models)
WSL2 supports NVIDIA GPUs natively since WSL kernel 5.10.43+ — install the standard NVIDIA driver on Windows (do not install a Linux NVIDIA driver inside WSL), and nvidia-smi inside WSL will see the GPU. From there, CUDA toolkits, torch, vllm, sglang, and llama-server build against the real GPU as usual.
AMD ROCm and Intel Arc support inside WSL2 is still evolving and outside Hermes's test matrix — it may work with current drivers but we don't have a recipe to recommend.
If you're running a Windows-native local-model server (Ollama for Windows, LM Studio) that already uses your GPU through Windows drivers, you don't need WSL GPU passthrough at all — just follow Case 1 above and hit it over the network from WSL.
Common pitfalls
"Connection refused" to my Windows-hosted Ollama / LM Studio.
See WSL2 Networking. Ninety percent of the time the server is bound to 127.0.0.1 and needs 0.0.0.0 (Ollama: OLLAMA_HOST=0.0.0.0), or you're missing a firewall rule.
Massive slowness on git status / hermes chat in a repo.
You're probably working under /mnt/c/.... Move the repo to ~/code/... (Linux side). Order-of-magnitude faster.
bad interpreter: /bin/bash^M on scripts.
CRLF line endings from a Windows editor. dos2unix script.sh, and set core.autocrlf input in your WSL git config.
"UNC paths are not supported" warning from Windows binaries launched via MCP.
Hermes's cwd is inside the Linux filesystem, and Windows cmd.exe doesn't know what to do with it. Start Hermes from /mnt/c/... for that session, or use a wrapper that cds to a Windows-reachable path before invoking the Windows executable.
Clock drift after sleep/hibernate.
WSL2's clock can lag by minutes after the host resumes from sleep, which breaks anything cert-based (OAuth, HTTPS APIs). Fix it on demand:
sudo hwclock -sOr install ntpdate and run it at login.
DNS stops working after enabling mirrored mode, or when a VPN is connected.
Mirrored mode proxies host network settings into WSL — if Windows DNS is funky (VPN split-tunnel, corporate resolver), WSL inherits that. Workaround: override resolv.conf manually (set generateResolvConf=false in /etc/wsl.conf, then write your own /etc/resolv.conf with 1.1.1.1 or your VPN's DNS).
hermes not found after running the installer.
The installer adds ~/.local/bin to your shell's PATH via ~/.bashrc. You need to source ~/.bashrc (or open a new terminal) for it to take effect in the current session.
Windows Defender is slow on WSL files.
Defender scans files via the 9P bridge when accessed from Windows, which magnifies the slowness of /mnt/c-style cross-boundary access. If you only touch WSL files from inside WSL, this doesn't matter. If you use Windows tools against \\wsl$\... frequently, consider excluding the WSL distro path from real-time scanning.
Running out of disk.
WSL2 stores its VM disk as a sparse VHDX under %LOCALAPPDATA%\Packages\.... It grows but doesn't auto-shrink when you delete files. To reclaim space: wsl --shutdown, then from an Admin PowerShell run Optimize-VHD -Path -Mode Full (requires Hyper-V tools) — or the simpler diskpart path documented on the WSL docs.
Where to go next
- Installation — actual install steps (Linux/WSL2 use the same installer).
- Integrations → Providers → WSL2 Networking — the canonical networking deep-dive for local model servers.
- MCP guide → WSL → Windows Chrome — controlling your signed-in Windows Chrome from Hermes in WSL.
- Tool Gateway and Web Dashboard — the long-lived services you'll most often want to expose from WSL to the rest of your network.
Developer Guide Plugins Index (`website/docs/developer-guide/plugins/index.md`)
sidebar_label: "Build a Plugin" slug: /developer-guide/plugins title: "Build a Hermes Plugin"
Build a Hermes Plugin
This guide walks through building a complete Hermes plugin from scratch. By the end you'll have a working plugin with multiple tools, lifecycle hooks, shipped data files, and a bundled skill — everything the plugin system supports.
:::info Not sure which guide you need?
Hermes has several distinct pluggable interfaces — some use Python register_* APIs, others are config-driven or drop-in directories. Use this map first:
| If you want to add… | Read |
|---|---|
| Custom tools, hooks, slash commands, skills, or CLI subcommands | This guide (the general plugin surface) |
| A native desktop app extension (panes, pages, status bar, palette, themes) | Desktop Plugin SDK |
| A web dashboard extension (tabs, shell slots, themes) | Extending the Dashboard |
| An LLM / inference backend (new provider) | Model Provider Plugins |
| A gateway channel (Discord/Telegram/IRC/Teams/etc.) | Adding Platform Adapters |
| A memory backend (Honcho/Mem0/Supermemory/etc.) | Memory Provider Plugins |
| A context-compression engine | Context Engine Plugins |
| An image-generation backend | Image Generation Provider Plugins |
| A video-generation backend | Video Generation Provider Plugins |
| A web-search / extract backend | Web Search Provider Plugins |
| A cloud browser backend (Browserbase-style CDP session provider) | Browser Provider Plugins |
A computer-use driver (desktop control behind the computer_use tool) |
Computer-use backend plugins — ctx.register_computer_use_provider() |
| A secret-manager backend (vault / password manager / OS keystore) | Secret Source Plugins |
| A dashboard OIDC/auth provider | Web Dashboard — custom providers — ctx.register_dashboard_auth_provider() |
| A TTS backend (any CLI — Piper, VoxCPM, Kokoro, voice cloning, …) | TTS custom command providers — config-driven, no Python needed |
| An STT backend (custom whisper / ASR CLI) | Voice Message Transcription — set HERMES_LOCAL_STT_COMMAND to an argv-tokenized template |
| External tools via MCP (filesystem, GitHub, Linear, any MCP server) | MCP — declare mcp_servers.<name> in config.yaml |
| Gateway event hooks (fire on startup, session events, commands) | Event Hooks — drop HOOK.yaml + handler.py into ~/.hermes/hooks/<name>/ |
| Shell hooks (run a shell command on events) | Shell Hooks — declare under hooks: in config.yaml |
| Additional skill sources (custom GitHub repos, private skill indexes) | Skills — hermes skills tap add <repo> · Publishing a tap |
| A first-class core inference provider (not a plugin) | Adding Providers |
See the full Pluggable interfaces table for a consolidated view of every extension surface including config-driven (TTS, STT, MCP, shell hooks) and drop-in directory (gateway hooks) styles.
:::
:::caution Third-party-product plugins ship standalone — not into the core tree
Plugins that integrate someone else's product or project — observability/metrics backends, vendor SaaS connectors, analytics dashboards, paid-service tie-ins — are built and distributed as standalone plugin repos, not merged into NousResearch/hermes-agent. Users install them into ~/.hermes/plugins/ or via a pip entry point; everything in this guide works the same way from a standalone repo. This is a coupling-and-maintenance decision (the core moves fast and we don't own your backend), not a quality bar — a plugin can be excellent and still belong in its own repo. Promote it in the Nous Research Discord #plugins-skills-and-skins channel. See CONTRIBUTING.md for the policy.
:::
:::tip Already built in: check here before you hand-roll it
Hermes already ships these for plugin authors:
- Run the catalog check locally:
hermes plugins validate /path/to/your-plugin --install-depsruns the same check catalog CI runs. See Submitting to the Plugin Catalog. - Test your plugin in isolation:
hermes plugins doctor [path-or-id]runs the same discovery, manifest parser,register(ctx)and registries Hermes uses, with a temporaryHERMES_HOME. See Validate with Plugin Doctor. - Keep state across updates:
plugin_data_dir()andplugin_db()give your plugin a data directory that surviveshermes plugins updateandremoveand follows the active profile. See Store durable state. - Declare Python dependencies: list them under
python_dependenciesinplugin.yaml, or in apyproject.tomlnext to it. See Python dependencies. - Ship skills with your plugin: register them with
ctx.register_skill(). See Bundle skills. - Ask for privileged host surfaces: declare them under
capabilities:so users get a single consent screen. See Declaring capabilities. - Make LLM calls: use
ctx.llm, which comes with host-owned credentials and a fail-closed trust gate. See Plugin LLM Access.
:::
Portable Agent Plugins v1 packages
Hermes can also install and load directory packages that target the Agent
Plugins v1.0.0 format. This is a compatibility adapter for the portable
components Hermes already owns. It does not replace native plugin.yaml plusregister(ctx) plugins.
my-portable-plugin/
├── plugin.json
├── skills/
│ └── summarize/
│ ├── SKILL.md
│ └── references/
└── mcp.jsonInstall and activate a portable package through the normal workflow:
hermes plugins install owner/repository --no-enable
hermes plugins list
hermes plugins enable <plugin-name>Portable packages are disabled after installation unless you explicitly enable
them. An enabled package may provide immediate skills/*/SKILL.md directories
and stdio MCP servers from root mcp.json. Skills are read-only, namespaced,
and loaded through skills_list plus skill_view. MCP commands are passed as
one executable token with a separate argument list, never through a shell.
Use skills_list to discover the full qualified skill name. Portable skill
namespaces have the deterministic form agent-plugin-<slug>-<hash>, derived
from the discovered plugin key so sanitized names cannot collide. A portable
package's MCP servers keep the names their mcp.json gives them, the same rule
as a user's own mcp_servers block, so the model-facing mcp__<server>__<tool>
name keeps the tool verb inside the 64-character provider cap. A duplicate server
name is a load-time conflict: a config.yaml server wins over a package, and the
first-loaded package wins over the next; the loser is skipped with a warning
naming both.
Hermes validates plugin.json, Agent Skills frontmatter, fixed component
locations, mcp.json, resolved paths, and symlink containment locally. It does
not fetch JSON schemas while loading a package. A bad skill or MCP entry is
skipped at its own boundary when valid sibling components can still load.PLUGIN_ROOT points to the resolved package root. PLUGIN_DATA points to a
profile-scoped writable directory managed by Hermes.
Values declared in portable MCP env are visible package data, not a secret
storage mechanism. Do not place credentials in mcp.json.
To pass a user's variable through to a stdio server (Hermes otherwise hands MCP
servers a filtered environment), declare it as "NAME": "${NAME}". The value
comes from the user's shell or profile .env; when the variable is unset (or
empty), the entry is treated as if it weren't there: the server doesn't get the
literal ${NAME}, and NAME keeps whatever value the server would have without
that line (usually unset). Optional variables are safe to forward.
The current portable subset supports stdio and Streamable HTTP MCP entries.
Portable streamable-http entries are routed through Hermes' existing native
remote MCP client (the same runtime that powers URL-based mcp_servers
config), with the v1 boundary rules enforced: the URL must be absolute
http(s) with no user information or fragment, plain HTTP is accepted only
for localhost/loopback hosts, and configured headers are never forwarded
across a cross-origin redirect. Legacy sse entries are reported and
skipped. Agent Plugins v1 does not define trust, permissions, provenance, or a
sandbox. Enabling a package grants its instructions and local executable the
same full-trust posture as other installed Hermes plugins.
A package can ask Hermes to gate one of its MCP servers, the same way a user'strust: untrusted does in config.yaml. Use it for servers whose tools spend
money, trade, send messages or change accounts, so the user approves each
write-capable call instead of relying on the skill's instructions alone:
{
"extensions": {
"com.nousresearch.hermes": {
"servers": {
"trade": { "trust": "untrusted" }
}
}
}
}The server name must match an mcp.json entry. With untrusted, every tool
call to that server that is not annotated readOnlyHint: true asks the user
first, and fails closed where nobody can answer (cron, unattended runs). The
only other accepted value is full, the default, so a package can narrow
access but never widen it. A config.yaml server with the same name replaces
the package's entry, including its trust, but keeps the package's declared costs
and spend limit (below) unless it sets its own. Other harnesses ignore this extension.trust can sit beside app, requires and liveness in the same server
entry (see Application declarations).
A server whose tools spend money or credits can declare what each call costs
and a budget per chat. Hermes then names the cost in the approval prompt along
with the chat's running total, and a call that would take the chat pastspend_limit asks first even when the user has made the server trusted:
{
"extensions": {
"com.nousresearch.hermes": {
"servers": {
"renders": {
"trust": "untrusted",
"costs": {
"create_image": 20,
"animate": { "credits": [5, 20], "note": "per animation" }
},
"spend_limit": 100,
"cost_unit": "credits"
}
}
}
}
}Keys of costs are MCP tool names as the server lists them. A cost is a
non-negative number, a [min, max] range, or an object with credits (number
or range) and a short note. spend_limit is optional (0 or absent = no
limit) and cost_unit defaults to credits. A malformed table makes the
package fail to load with an error naming the key. These map to the nativetool_costs, spend_limit and cost_unit keys (see
Tool costs and spend limits).
Costs are facts about the server, so they survive a user's same-nameconfig.yaml entry (the way a user widens trust) unless that entry sets the
key itself; the user's spend_limit wins, including 0 to turn it off.
A long-running server can also declare a Hermes-specific tool-call timeout
(in seconds) in plugin.json, without changing other servers' defaults:
{
"extensions": {
"com.nousresearch.hermes": {
"servers": { "analysis": { "timeout": 1000 } }
}
}
}The value must be a positive finite number within the platform's thread-wait
limit. It maps to the native per-server timeout; omission preserves the
existing timeouts.mcp.tool_call fallback. A same-name native mcp_servers
entry still replaces the entire package entry, including its timeout. This
extends the wait budget, not the cancellation or permission policy. Configure
any server-internal analysis deadline separately and leave time for its result
to reach the client. Other harnesses ignore this extension.
A server entry key this Hermes does not know (one a newer release added) is
ignored with a warning, which hermes plugins validate lists, and the package
still installs. Releases from before this rule reject the package instead, and a
release that ignores a key may behave less safely than intended, so declare a new
key together with a catalog requires_hermes floor.
An explicit per-server MCP call budget also reaches Hermes' outer tool executor. A call
whose budget exceeds the shared concurrent-batch deadline becomes a sequential
barrier, whether called directly or through tool search. It gets its own budget
without extending unrelated tools in the same model response. Existing shorter
MCP calls retain their normal parallel-admission policy; interruption and
approval handling are unchanged.
The rendered specification currently
labels v1.0.0 a Working Draft, while the
versioned specification repository
records it as Published. Hermes keys behavior on the canonical v1.0.0 schema
identifiers and normative text, not either mutable status label. This is an
explicit supported subset, not a claim of full Agent Plugins conformance.
Native plugin compatibility contract
Native plugin.yaml plus register(ctx) plugins are protected by behavior,
not by one global plugin API number. Hermes does not expose aPLUGIN_API_VERSION, require a manifest-wide api: match, or attach an API
version to unrelated values. A plugin that uses a documented behavior should
continue to work after a normal Hermes upgrade.
The compatibility rules are:
- Evolve additively. Documented
PluginContextmethods are not removed or
renamed. New parameters are optional, have defaults, and should be
keyword-only. Existing return fields are not removed or silently retyped. - Hook payloads are keyword payloads. New hook data is added as keyword
fields, never by changing the meaning or position of an existing field.
Hermes inspects callback signatures: a legacy callback receives the fields it
declares, while a callback with**kwargsreceives the complete current
payload. New plugins should accept**kwargsso they can opt into additive
data without another signature change. - Manifests are open to additions. Unknown
plugin.yamlfields are ignored.
Older Hermes releases can therefore load a plugin whose manifest contains
metadata introduced by a newer release, provided the plugin code itself uses
supported runtime behavior. - Provider interfaces grow through defaults. New provider methods have a
default implementation. New callback context is optional and forwarded only
when signature inspection shows that a provider accepts it. Adding an
abstract method or an unconditionally forwarded argument requires a
migration window rather than a flag-day signature change. - Version the contract that crosses a boundary. A capability may carry its
own schema version when it defines a wire payload or persisted format (for
example, observer payloads or secret-source state). Keep fields additive
within that local schema. Persisted plugin state and config must remain
readable, or ship an explicit migration; resumed sessions written by the old
format must still replay. Do not add version literals to unrelated callback
or context values.
The contract covers documented surfaces only. Replacing or wrapping core
functions, methods, module attributes or private tables at runtime (assigningAIAgent.<method>, setattr on a Hermes module, writing intosys.modules or a core dict) is not a supported extension point. It breaks
whenever the internals move, and it collides with every other plugin patching
the same seam. The plugin catalog refuses it at admission (hermes plugins validate, no core override check). If a public hook you need is missing,
open an issue describing it.
Deprecation policy
A documented native plugin behavior may be deprecated only with all of the
following:
- a replacement and migration instructions in the plugin guide and release
notes; - a warning emitted at most once per process, naming the replacement and the
earliest removal release; - support for the old behavior through at least two subsequent minor
releases; and - behavior-based compatibility coverage for both the legacy path and the
replacement throughout that window.
Removal after the window must include any migration needed for persisted data
or resumable sessions. In practice, additive aliases and adapters are preferred
to removal.
Hermes enforces this contract with frozen external-plugin fixtures discovered
from an isolated HERMES_HOME. Those tests load and invoke the plugin throughPluginManager; they assert real registration and callback outcomes rather
than internal symbol lists or source-code shape.
Sep 2026 module decomposition: old import paths removed
Hermes's internals were split into <stem>_<topic> sibling modules in Sep 2026 (PR #102117). Internal
import paths were never part of the plugin contract above. A temporary compatibility layer kept the old
paths resolving until 2026-09-14; it has been removed, so a plugin that still imports an old path fails to
load with an ImportError (the reason shows in hermes plugins list).
To fix such a plugin, import the name from the module that defines it now, or better, use ctx and the
documented ABCs instead of internals. The full old-to-new map is theCOMPAT_MANIFEST.md
from the last commit that shipped the layer.
What you're building
A calculator plugin with two tools:
calculate— evaluate math expressions (2**16,sqrt(144),pi * 5**2)unit_convert— convert between units (100 F → 37.78 C,5 km → 3.11 mi)
Plus a hook that logs every tool call, and a bundled skill file.
Step 1: Create the plugin directory
Create a directory and continue with Step 2:
mkdir -p ~/.hermes/plugins/calculator
cd ~/.hermes/plugins/calculatorValidate with Plugin Doctor
hermes plugins doctor [path-or-id] runs the same directory discovery,
manifest parser, namespaced import, register(ctx), hook registry, and tool
registry used by Hermes itself. It reports invalid hook names, callbacks that do
not accept **kwargs, registration failures, and drift between declared and
registered tools/hooks. For a package with a desktop/plugin.js, it also warns
when the Desktop app is running a stale copy of it (see
Developing a unified package).
Pass --ci to exit non-zero on an error:
hermes plugins doctor . --ciDoctor uses a temporary HERMES_HOME, restores plugin registration state after
the check, and blocks direct Python socket connections to catch accidental
network access while registration runs. This is not a sandbox: plugin code still
executes in-process with the current user's permissions and can spawn subprocesses,
so only run Doctor on code you trust enough to import.
Step 2: Write the manifest
Create plugin.yaml:
name: calculator
version: 1.0.0
description: Math calculator — evaluate expressions and convert units
provides_tools:
- calculate
- unit_convert
provides_hooks:
- post_tool_callThis tells Hermes: "I'm a plugin called calculator, I provide tools and hooks." The provides_tools and provides_hooks fields are lists of what the plugin registers.
List every tool your register() registers in provides_tools. The field does not decide whether a user-installed plugin's tools load: once the plugin is enabled, everything register() registers is available, declared or not. What it does drive:
hermes plugins validate: the "declared tools" check fails when the registered tools don't match the list, which blocks catalog admission.- Catalog listing: the "N tools" chips and tool-name search in the catalog and the dashboard/Desktop Plugins page.
- Dashboard auth hint: only declared tools' availability checks are used to show "needs auth" and the
hermes auth <name>command. - Bundled
kind: platformplugins only: the field is the switch that loadstools.pyin CLI/TUI sessions while the adapter stays deferred. See Outbound client tools.
Optional fields you could add:
author: Your Name
requires_env: # gate loading on env vars; prompted during install
- SOME_API_KEY # simple format — plugin disabled if missing
- name: OTHER_KEY # rich format — shows description/url during install
description: "Key for the Other service"
url: "https://other.com/keys"
secret: true
capabilities: # privileged host surfaces you request (consent flow)
- tools.override # replace built-in tools (needs user consent)
- llm.model_override # choose the model for host-owned LLM callsDeclaring capabilities
If your plugin needs a privileged host surface — overriding a built-in tool,
picking the model for ctx.llm calls, etc. — declare it in capabilities:.
At install/enable time the user sees the list and consents once; if a later
version adds a capability, the update flow asks again for just the addition.
Undeclared or unconsented capabilities are simply off (fail closed), so
probe before using them and degrade gracefully:
def register(ctx):
if ctx.has_capability("tools.override"):
ctx.register_tool(..., override=True)
else:
ctx.register_tool(...) # register under a non-conflicting nameKnown capability ids: tools.override, llm.provider_override,llm.model_override, llm.agent_id_override, llm.profile_override,llm.task_override (see hermes_cli/plugin_capabilities.py for the
canonical registry). Unknown ids are ignored. The older per-capability
config keys (plugins.entries..allow_tool_override, …) still work but
are deprecated — declare capabilities instead so users get a single,
auditable consent screen. Capabilities are consent + audit, not a
sandbox: they gate host API surfaces, nothing more.
Pip-distributed plugins have no plugin.yaml directory once installed,
so declare capabilities in distribution metadata instead, via the companionhermes_agent.plugin_capabilities entry-point group. Each declaration is
named .<capability-id> and points at the same object as yourhermes_agent.plugins entry point:
[project.entry-points."hermes_agent.plugins"]
calculator = "my_pkg:register"
[project.entry-points."hermes_agent.plugin_capabilities"]
"calculator.tools.override" = "my_pkg:register"Hermes reads these from installed metadata without importing your code, sohermes plugins capabilities and the consent flow stay accurate for pip
installs.
Use the user's Codex sign-in (core-made requests)
A plugin that needs the user's ChatGPT / Codex subscription (minting an OpenAI
Realtime client secret, calling a Codex backend endpoint) must not read,
refresh or rewrite ~/.codex/auth.json or Hermes's auth.json. Codex refresh
tokens are single-use: a plugin that refreshes on its own races Hermes's locked
refresh, and the loser's rotation chain gets revoked, signing the user out.
Ask Hermes to make the request instead. Hermes resolves the credential through
its own refresh path, attaches Authorization itself and returns only the
response. Your code never sees the token.
Declare the sign-in in plugin.yaml:
name: live-voice
requires_auth:
- openai-codexThen call the public helper. It is a plain function, so dashboard plugins
(dashboard/plugin_api.py) and vendored modules can import it as well asregister(ctx) code:
from hermes_cli.plugin_provider_requests import ProviderNotSignedIn, credentialed_provider_request
def mint_client_secret(session: dict) -> dict:
try:
resp = credentialed_provider_request(
"openai-codex", "POST", "https://api.openai.com/v1/realtime/client_secrets",
json={"session": session}, timeout=15)
except ProviderNotSignedIn as exc:
return {"ok": False, "error": str(exc)} # tells the user how to sign in
if resp.status != 200:
return {"ok": False, "error": f"client secret failed ({resp.status}): {resp.text[:300]}"}
return {"ok": True, **resp.json()}This replaces a hand-rolled helper that read ~/.codex/auth.json, POSTed toauth.openai.com/oauth/token when the token was near expiry and wrote the
result back. Delete that code; there is no refresh step left for the plugin.
credentialed_provider_request(provider, method, url, *, json=None, headers=None, timeout=30.0)
returns a ProviderResponse (status, headers, body, .text, .json()).
The rules:
- Declared plugins only. The caller is the plugin whose file is on the call
stack. Hermes finds it under the plugins directory and reads itsplugin.yaml. A plugin that does not list the provider inrequires_auth,
and code outside any installed plugin, getsPermissionError. Call the helper
from your own function. Handing the bare helper torun_in_executorleaves no
plugin frame on the stack, so the call is refused. - Provider origins only. For
openai-codexthe token is sent only tohttps://chatgpt.comandhttps://api.openai.com. Any other URL raisesPermissionErrorbefore a request is made. A profile whose Codex credential
routes to a custom gateway is refused too, because that key belongs to the
gateway. - No redirects. A 3xx response comes back as-is and is never followed, so
the header cannot reach a third host. - Profile-scoped. The credential is the active profile's Codex sign-in, the
same one chat uses (hermes auth add openai-codex, or the Codex sign-in in the
desktop app). When there is none you getProviderNotSignedIn, whose message
names the sign-in command. - Your own
headersare sent too, except any that would replace theAuthorization(or account) headers Hermes attaches.
requires_auth appears in hermes plugins show <name> and at install time, and
a catalog re-pin that adds it asks the user before updating. Like capabilities,
this is consent and visibility. It is not a sandbox: in-process plugin code
can still read any file the user can, and reviewers reject plugins that do.
Usage panels: don't call the usage endpoints yourself. Useagent.account_usage.fetch_account_usage("openai-codex", read_only=True). It
returns an AccountUsageSnapshot (plan, session/weekly windows withused_percent and reset_at) and never refreshes or rotates a credential.
Sign-out belongs to Hermes, not to a plugin: point users athermes auth logout openai-codex instead of deleting credential files.
Use your own OAuth client (Google and other APIs)
A plugin that calls a third-party API with the user's own OAuth client (a
Google Cloud "Desktop app" client, for example) does not run the
consent flow, store the refresh token or refresh it. Declare the client and
Hermes owns the grant:
name: gemini-voice
requires_env:
- name: GEMINI_VOICE_CLIENT_ID
- name: GEMINI_VOICE_CLIENT_SECRET
secret: true
oauth:
- id: google
authorize_url: https://accounts.google.com/o/oauth2/v2/auth
token_url: https://oauth2.googleapis.com/token
scopes:
- https://www.googleapis.com/auth/calendar.events.owned
- https://www.googleapis.com/auth/documents
client_id_env: GEMINI_VOICE_CLIENT_ID
client_secret_env: GEMINI_VOICE_CLIENT_SECRET # optional: a public PKCE client has none
hosts: [www.googleapis.com, docs.googleapis.com]
authorize_params: {access_type: offline, prompt: consent} # Google issues a refresh token only with theseThe user signs in once from a terminal with hermes auth add gemini-voice:google
(the plugin's key, a colon, the entry's id). That is the same PKCE login the
model-provider helper runs: a loopback redirect on 127.0.0.1, with an SSH
tunnel hint when Hermes runs on a remote host. If the IdP grants fewer scopes
than declared, nothing is saved. hermes auth status|refresh|logout gemini-voice:google show, refresh and remove the grant. It lives in the active
profile's auth.json; Hermes refreshes it on use once less than two minutes
remain and never starts a sign-in under the gateway or cron.
The plugin calls the API through the same helper, with the entry's id as the
provider:
from hermes_cli.auth_constants import AuthError
from hermes_cli.plugin_provider_requests import ProviderNotSignedIn, credentialed_provider_request
def create_document(title: str) -> dict:
try:
resp = credentialed_provider_request(
"google", "POST", "https://docs.googleapis.com/v1/documents", json={"title": title}, timeout=20)
except ProviderNotSignedIn as exc:
return {"ok": False, "error": str(exc)} # names `hermes auth add gemini-voice:google`
except AuthError as exc:
return {"ok": False, "error": f"{exc.code}: {exc}"}
return {"ok": resp.status < 300, "status": resp.status, "body": resp.json()}The Codex rules above apply, plus:
- HTTPS only.
authorize_url,token_urland everyhostsentry arehttps(there is no loopback exception);hermes plugins validateand
catalog admission fail anything else. - Declared hosts only. The bearer goes to
https://<host>(port 443) for
the entry'shosts, matched exactly (no subdomains). Any other URL,http,
or a URL with user info raisesPermissionErrorbefore a request is made.
A host outside the sign-in provider's domains is a warning reviewers check. - Your own client variables.
client_id_envandclient_secret_envname
the plugin's own variables; a variable Hermes itself uses (a provider API key,
a platform token) is refused, since Hermes sends these totoken_url. - The plugin's own grant. The grant is stored under the calling plugin's
key, so another plugin declaring the sameidcannot use it, and the plugin's
own entry wins over a Hermes sign-in of the same name. - The grant follows the declaration. It remembers the entry it was made
for: a plugin version that changes the token URL, hosts, scopes or client
variables getsProviderNotSignedInuntil the user signs in again, and a
catalog re-pin that adds anoauthentry or changes its authorize host, token
host, hosts or scopes asks before updating. - Errors.
ProviderNotSignedIn: not signed in, revoked, or the entry
changed since sign-in.AuthErrorcodes:oauth_client_id_missingandoauth_client_secret_missing(the variable is not set),invalid_client,unauthorized_clientandinvalid_scope(the IdP rejected the client; the
user has to fix it),oauth_refresh_failed(network or server error; try
later). A revoked grant (invalid_grant) is retired: nothing goes to the
network until the next sign-in. Errors reach the plugin without a traceback,
chain or request object, so none leads back to the token, refresh token or
client secret. - Sign-in and removal.
hermes auth addprints which hosts get the code,
the client secret and the token before opening the browser, and refuses to
run from the gateway, cron or an agent session.hermes plugins removedrops
the plugin's grants, andhermes auth logout :still works after
the plugin is gone.
hermes plugins validate checks the oauth: block and that its client
variables are declared, and warns when plugin code names the token URL. Likerequires_auth, this is consent and visibility, not a sandbox.
Manifest v2 reference
plugin.yaml also supports an additive v2 schema (#64165). Every field is
optional; a manifest without manifest_version is a v1 manifest and stays
fully supported forever. Unknown fields never break loading — they are ignored
with a warning (forward compatibility), and a manifest_version newer than
this Hermes understands still loads with a warning.
| Field | Type | Meaning |
|---|---|---|
manifest_version |
int | Manifest file-format version. Absent = 1. Current max: 2. Independent from api_version. |
api_version |
int | Runtime plugin API generation the plugin targets (ctx surface / hook signatures). Deliberately a separate axis from manifest_version — an api_version: 1 plugin can use a v2 manifest. |
requires_plugins |
list | Inter-plugin dependencies: - id: other-plugin with optional version_range: ">=1.0,<2". Advisory: a missing dependency logs a clear warning but the plugin still loads — probe at runtime with ctx.has_plugin("other-plugin"). Load order honors these edges: when A requires B, B's register() runs before A's (topological sort, alphabetical tiebreak; cycles warn and fall back to alphabetical order). |
python_dependencies |
list of str | Declared Python requirements (e.g. "requests>=2.0,<3"). Installation requests consent; enabling admits the candidate through PM with the existing core, extras, and enabled-plugin union. Successful preparation publishes the environment and configuration transactionally; failure preserves the previous selection and enabled set. Declining leaves the installed plugin disabled. Pin upper bounds. |
python_runtime |
str | external — the plugin manages its own interpreter/venv (sidecar pattern); Hermes installs nothing and leaves any pyproject.toml alone. |
config_schema |
mapping | JSON-schema-ish description of keys under plugins.entries..settings: api_url: {type: str, default: "", description: "...", required: false}. Validated at load; mismatches log actionable warnings naming the key and expected type — never load failures. Types: str, int, float, bool, list, dict (plus JSON-schema aliases) and secret. Also drives the settings form in the Desktop Plugins tab — see Settings form in the Desktop. |
license |
str | SPDX-style license id (e.g. MIT). |
homepage |
str | Project URL. |
tags |
list of str | Free-form discovery tags (e.g. [gateway, telegram]). |
provides_locales |
list | Language pack declaration: ids (- pl) or {id, endonym, rtl} mappings whose locales/[.tui|.desktop].yaml the loader registers automatically — see Ship a language pack. |
requires_auth |
list of str | Providers whose sign-in the plugin uses through Hermes (today: openai-codex). Required by credentialed_provider_request; shown at install and in hermes plugins show. See Use the user's Codex sign-in. |
oauth |
list of mappings | OAuth clients the plugin uses through Hermes: id, authorize_url, token_url, scopes, client_id_env, optional client_secret_env, hosts, optional authorize_params. Signed in with hermes auth add :, used through credentialed_provider_request. See Use your own OAuth client. |
# plugin.yaml — manifest v2 example
name: my-plugin
version: 1.2.0
manifest_version: 2
api_version: 1
license: MIT
homepage: https://github.com/owner/my-plugin
tags: [gateway, demo]
requires_plugins:
- id: other-plugin
version_range: ">=1.0,<2"
python_dependencies:
- "somepkg>=1.0,<2" # consent before PM admission
config_schema:
api_url: {type: str, default: "", description: "Service endpoint"}:::note Shared dependency admission
Plugin installation requests Python dependency consent. Enabling the plugin
prepares its requirements with core dependencies, extras, and enabled plugins
through PM. Pack enables use the same admission transaction. Reinstalling an
active plugin requests consent against its staged declaration before publication.
A refusal preserves the installed plugin and selected environment.
The installer and PM admission reject unsupported manifest_version values
and unmet requires_hermes constraints before publication.
:::
Python dependencies
A directory plugin can bring its own PyPI packages. Declare them either in the manifest
(python_dependencies, above) or, preferably, in a pyproject.toml next to plugin.yaml:
[project]
name = "my-plugin"
version = "1.0.0"
requires-python = ">=3.11"
dependencies = [
"somepkg>=1.0,<2",
"other[extra]>=3.11",
]When both exist the pyproject.toml wins. What Hermes does with them:
- Install / enable — PM resolves core, selected extras, and the enabled plugin union
across every profile sharing the dependency home, including customHERMES_HOMEroots.
A new plugin is downloaded disabled; Python dependency consent precedes enablement.pyproject.tomltakes precedence overpython_dependenciesand legacypip_dependencies. - Atomic publication — PM prepares a fresh environment generation before publishing
an enablement or an active plugin replacement. Resolution, download, or build failure
preserves the previous environment and plugin selection; no existing plugin is sacrificed. - Updates retain the union —
hermes updateincludes enabled plugins while preparing
its new generation. There is no post-update pip reinstall.hermes plugins updateprepares
active replacements before swapping their code and dependency generation together. - Requirement hygiene — malformed PEP 508 requirements are refused. Environment markers
remain intact for the target interpreter to evaluate.hermes-agentself-dependencies are
omitted because the checkout supplies Hermes. Direct-URL requirements are not managed;
use a plugin-owned external runtime for them. --no-depsdownloads a new plugin without dependency consent and leaves it disabled,
even with--enable. It cannot bypass PM admission when replacing an active plugin.--yes-depsanswers the dependency question up front, so a headless install (CI, SSH
automation, a container entrypoint) prepares the declared dependencies instead of being refused.
It is mutually exclusive with--no-deps.python_runtime: externalkeeps a sidecar's dependencies out of the shared union.
Hermes does not install that Python runtime or modify its declaration.- Nothing to load is an error —
hermes plugins validaterejectsplugin.yamlwithout__init__.py,desktop/plugin.js, orplugin.jsonbeside it. Pip-layout packages need
a directory-plugin wrapper. security.allow_lazy_installs: falseblocks on-demand acquisition. Explicit dependency
consent and explicit enablement authorize PM preparation; discovery never installs.
HERMES_HOME/plugins/ survives hermes update and Desktop updates: the updater only rebuilds the
venv and the checkout, never the home directory.
Dependency security policy
Hermes checks the age of its own dependencies when it locks them: the checkout's[tool.uv] exclude-newer = "3 days" keeps any release younger than three days out of uv.lock,
so a hijacked upload has time to be caught upstream before it reaches users. Users install from
that vetted lock; their machines never re-check dates. The check does not apply to your
plugin's dependencies. When Hermes resolves your plugin into its environment, Hermes's exact
pins stay put, other packages Hermes uses may move within the ranges Hermes declares, and
everything else follows your requirements, so a plugin can floor on a release published
yesterday and install today. The plugin's author, not Hermes, is responsible for what that pulls
in. A plugin that requires a different version of a package Hermes pins exactly fails to install
until Hermes's pin moves.
Set your own policy and hold yourself to it. Strongly recommended:
- Upper bounds on every dependency —
>=floor,<next_majorfor stable packages,>=0.29,<0.32for pre-1.0 ones. A bare>=X.Yadopts every future release unreviewed. - Floor on the oldest API-compatible version, not the release of the week. A floor on a
fresh wheel forces every installer onto it the day it appears;>=old,!=broken,<nextkeeps the
wide range and skips the one bad release. - Adopt a release-age check of your own: wait a few days before floors move to a new
release, and resolve withuv --exclude-newer "3 days"(orUV_EXCLUDE_NEWER) in your own CI so
the lock you test is the one users get. - Pin your lock, review your bumps. Treat a dependency bump as a code change: read the
upstream diff, then re-pin.
The plugin catalog review reads your dependency list at the pinned SHA (plugin.yaml orpyproject.toml) and flags bare floors and missing bounds; an entry is not held for a floor that
is merely recent.
Step 3: Write the tool schemas
Create schemas.py — this is what the LLM reads to decide when to call your tools:
/* Detailed source-code truncated for AI context efficiency. */Why schemas matter: The description field is how the LLM decides when to use your tool. Be specific about what it does and when to use it. The parameters define what arguments the LLM passes.
Step 4: Write the tool handlers
Create tools.py — this is the code that actually executes when the LLM calls your tools:
/* Detailed source-code truncated for AI context efficiency. */Key rules for handlers:
- Signature:
def my_handler(args: dict, **kwargs) -> str - Return: Always a JSON string. Success and errors alike.
- Never raise: Catch all exceptions, return error JSON instead.
- Accept
**kwargs: Hermes injects context keywords (task_id,session_id,user_task,parent_agent, ...) and only forwards the ones your signature names, sodef handler(args)
works;**kwargsis how you opt into the full, additively growing context.
Step 5: Write the registration
Create __init__.py — this wires schemas to handlers:
"""Calculator plugin — registration."""
import logging
from . import schemas, tools
logger = logging.getLogger(__name__)
# Track tool usage via hooks
_call_log = []
def _on_post_tool_call(tool_name, args, result, task_id, **kwargs):
"""Hook: runs after every tool call (not just ours)."""
_call_log.append({"tool": tool_name, "session": task_id})
if len(_call_log) > 100:
_call_log.pop(0)
logger.debug("Tool called: %s (session %s)", tool_name, task_id)
def register(ctx):
"""Wire schemas to handlers and register hooks."""
ctx.register_tool(name="calculate", toolset="calculator",
schema=schemas.CALCULATE, handler=tools.calculate)
ctx.register_tool(name="unit_convert", toolset="calculator",
schema=schemas.UNIT_CONVERT, handler=tools.unit_convert)
# This hook fires for ALL tool calls, not just ours
ctx.register_hook("post_tool_call", _on_post_tool_call)What register() does:
- Called exactly once at startup
ctx.register_tool()puts your tool in the registry — the model sees it immediatelyctx.register_hook()subscribes to lifecycle eventsctx.register_cli_command()registers a CLI subcommand (e.g.hermes my-plugin <subcommand>)ctx.register_command()registers an in-session slash command (e.g./myplugin <args>inside CLI / gateway chat) — see Register slash commands belowctx.dispatch_tool(name, arguments)— call any other tool (built-in or from another plugin) with the parent agent's context (approvals, credentials, task_id) wired up automatically. Useful from slash-command handlers that need to invoketerminal,read_file, or any other tool as if the model had called it directly.ctx.get_config()/ctx.set_config()access only this plugin's settings namespace;ctx.statestores plugin-owned runtime data under the active profile.- If this function crashes, the plugin is disabled but Hermes continues fine
dispatch_tool example — a slash command that runs a tool:
def handle_scan(ctx, raw_args: str):
"""Implement /scan by invoking the terminal tool through the registry."""
result = ctx.dispatch_tool("terminal", {"command": f"find . -name '{raw_args}'"})
return result # returned to the caller's chat UI
def register(ctx):
# Handlers receive a single raw_args string; close over ctx via a lambda.
ctx.register_command(
"scan",
lambda raw: handle_scan(ctx, raw),
description="Find files matching a glob",
)The dispatched tool goes through the normal approval, redaction, and budget pipelines — it's a real tool invocation, not a shortcut around them.
Store settings and runtime state
Use plugin-relative config keys for user-visible behavior. Hermes resolves them
under plugins.entries..settings and rejects global, cross-plugin,
and traversal paths:
def register(ctx):
endpoint = ctx.get_config("endpoint", default="https://example.invalid")
retries = ctx.get_config("retry.attempts", default=3)
ctx.set_config("endpoint", endpoint)
ctx.set_config("retry.attempts", retries)Use ctx.state for plugin-owned cursors, caches, and deduplication data rather
than placing runtime bookkeeping in config.yaml:
def register(ctx):
cursor = ctx.state.get("cursor", default={"page": 0})
ctx.state.set("cursor", {"page": cursor["page"] + 1})State is profile-scoped, atomically replaced, safe across concurrent writers,
and limited to 10 MiB per plugin. Portable packages share the same directory as
their PLUGIN_DATA; native plugins receive a collision-resistant,
Windows-safe namespace. Malformed existing state is reported and preserved.
Config and state have different owners: settings are user-visible behavior inconfig.yaml, while state is plugin-owned runtime data under<HERMES_HOME>/plugin-data/. Neither API exposes another plugin's namespace.
Settings form in the Desktop
Every key you declare in the manifest's config_schema renders as a row on the
plugin's own page under the Desktop app's Settings → Plugins (the gear on the
plugin's Capabilities → Plugins row opens it). No
Desktop code is needed: the backend's plugins.manage list returns the schema
plus each key's current value, and saving writes through the same writer asctx.set_config(), so plugins.entries..settings.<key> is what your plugin
reads back. The form is table-driven by type:
Manifest type |
Field | Extra keys |
|---|---|---|
str (default) |
text input | choices: [a, b] (or enum:) turns it into a dropdown |
int, float |
number input | |
bool |
switch | |
list, dict |
JSON editor | |
secret |
masked input | env: MY_PLUGIN_TOKEN — the .env variable it is stored under (default _<KEY> upper-snaked) |
Every entry also accepts label (or title; without one the key is shown in
sentence case, maps_api_key → "Maps API key"), description (help text under
the label), default and required (marks the row Required).
config_schema:
api_url: {type: str, default: "https://api.example.com", label: "API URL", description: "Service endpoint"}
retries: {type: int, default: 3}
mode: {type: str, choices: [fast, careful], default: fast}
api_key: {type: secret, env: MY_PLUGIN_API_KEY, description: "Personal access token"}Secrets never touch config.yaml. A secret field carries only the .env
name and whether a value is set; the Desktop stores the value through the same
credential route as provider API keys (PUT /api/env), and your plugin reads it
with get_secret("MY_PLUGIN_API_KEY") from agent.secret_scope — exactly like a requires_env entry.
Never read .env, auth.json or another tool's credential files yourself (catalog rule 11).
The plugins.manage settings action refuses secret keys and any value whose type
or choices disagree with the schema.
Portable packages (plugin.json) declare the same schema under the Hermes
vendor namespace, since Agent Plugins v1 rejects unknown top-level fields:
{
"extensions": {
"com.nousresearch.hermes": {
"config_schema": {
"api_key": { "type": "secret", "env": "MY_PLUGIN_API_KEY" },
"show_chip": { "type": "bool", "default": true, "label": "Show the status-bar chip" }
}
}
}
}A secret whose env names a variable your mcp.json passes through
("env": { "MY_PLUGIN_API_KEY": "${MY_PLUGIN_API_KEY}" }) reaches the MCP server
right away: after a save, Hermes reconnects the plugin's servers whose resolved
launch inputs changed (a new key does, a toggle does not), so the form is also
where users set the server's key.
React to a save without polling. Every save broadcastsplugin..settings.changed (payload { written: [...keys] }; a secret-only
save sends an empty list), so a Desktop half re-reads its preferences at once:ctx.onEvent('plugin.my-plugin.settings.changed', reload). Read the values from
your own plugin_api.py with load_config_readonly() from hermes_cli.config
(plugins.entries..settings), falling back to the schema defaults.
Step 6: Test it
Start Hermes:
hermesYou should see calculator: calculate, unit_convert in the banner's tool list.
Try these prompts:
What's 2 to the power of 16?
Convert 100 fahrenheit to celsius
What's the square root of 2 times pi?
How many gigabytes is 1.5 terabytes?Check plugin status:
/pluginsOutput:
Plugins (1):
✓ calculator v1.0.0 (2 tools, 1 hooks)Debugging plugin discovery
If your plugin doesn't show up — or shows up but isn't loading — set HERMES_PLUGINS_DEBUG=1 to get verbose discovery logs on stderr:
HERMES_PLUGINS_DEBUG=1 hermes plugins listYou'll see, for every plugin source (bundled, user, project, entry-points):
- which directories were scanned and how many manifests each yielded
- per manifest: resolved key, name, kind, source, on-disk path
- skip reasons:
disabled via config,not enabled in config,exclusive plugin,no plugin.yaml, depth cap reached - on load: the plugin being imported, plus a one-line summary of what
register(ctx)registered (tools, hooks, slash commands, CLI commands) - on parse failure: a full traceback for the exception (YAML scanner errors, etc.)
- on
register()failure: a full traceback pointing at the line in your__init__.pythat raised
The same logs are always written to ~/.hermes/logs/agent.log at WARNING level (failures only) and DEBUG level (everything) when the env var is set. So if you can't run with the env var (e.g. from inside the gateway), tail the log file instead:
hermes logs --level WARNING | grep -i pluginCommon reasons a plugin doesn't appear:
- Not enabled in config — plugins are opt-in. Run
hermes plugins enable <name>(the name comes from theplugins listoutput, which can be<category>/for nested layouts). - Wrong directory layout: Native packages use
~/.hermes/plugins//plugin.yaml(flat) or one category level. Portable packages use rootplugin.jsonin the same locations. Anything deeper is ignored. - Missing
__init__.py: Native packages need bothplugin.yamland__init__.pywith aregister(ctx)function. Portable packages do not import Python and do not require__init__.py. - Wrong
kind— gateway adapters needkind: platformin their manifest. Memory providers are auto-detected askind: exclusiveand routed through thememory.providerconfig instead ofplugins.enabled.
Your plugin's final structure
~/.hermes/plugins/calculator/
├── plugin.yaml # "I'm calculator, I provide tools and hooks"
├── __init__.py # Wiring: schemas → handlers, register hooks
├── schemas.py # What the LLM reads (descriptions + parameter specs)
└── tools.py # What runs (calculate, unit_convert functions)Four files, clear separation:
- Manifest declares what the plugin is
- Schemas describe tools for the LLM
- Handlers implement the actual logic
- Registration connects everything
What else can plugins do?
Ship data files
Put any files in your plugin directory and read them at import time:
# In tools.py or __init__.py
from pathlib import Path
from ruamel.yaml import YAML
_PLUGIN_DIR = Path(__file__).parent
_DATA_FILE = _PLUGIN_DIR / "data" / "languages.yaml"
with open(_DATA_FILE) as f:
_DATA = YAML(typ="safe").load(f)That's for files you ship. State you write is different — see the next
section.
Store durable state
Never write runtime state into your plugin directory: that's the install
tree, and hermes plugins update / remove git-pull or delete it — your
users' data dies with it. The sanctioned home is the per-plugin data root,
which survives both and follows the active profile:
from plugins.plugin_storage import plugin_data_dir, plugin_db
# <hermes home>/plugin-data/<name>/ — created on first use
state_file = plugin_data_dir("my-plugin") / "state.json"
# Or a SQLite database at <data dir>/data.db (WAL mode, thread-friendly)
conn = plugin_db("my-plugin")
conn.execute("CREATE TABLE IF NOT EXISTS runs (id TEXT PRIMARY KEY)")One directory per plugin means every plugin's data is inspectable in one
predictable place. Secrets don't belong here — credential reads go through
the standard .env / secret-scope path like everywhere else.
Bundle skills
Plugins can ship skill files that the agent loads via skill_view("plugin:skill"). Register them in your __init__.py:
~/.hermes/plugins/my-plugin/
├── __init__.py
├── plugin.yaml
└── skills/
├── my-workflow/
│ └── SKILL.md
└── my-checklist/
└── SKILL.mdfrom pathlib import Path
def register(ctx):
skills_dir = Path(__file__).parent / "skills"
for child in sorted(skills_dir.iterdir()):
skill_md = child / "SKILL.md"
if child.is_dir() and skill_md.exists():
ctx.register_skill(child.name, skill_md)The agent can now load your skills with their namespaced name:
skill_view("my-plugin:my-workflow") # → plugin's version
skill_view("my-workflow") # → built-in version (unchanged)Key properties:
- Plugin skills are read-only — they don't enter
~/.hermes/skills/and can't be edited viaskill_manage. - Plugin skills are not listed in the system prompt's
<available_skills>index — they're opt-in explicit loads. - Bare skill names are unaffected — the namespace prevents collisions with built-in skills.
- When the agent loads a plugin skill, a bundle context banner is prepended listing sibling skills from the same plugin.
:::tip Legacy pattern
The old shutil.copy2 pattern (copying a skill into ~/.hermes/skills/) still works but creates name collision risk with built-in skills. Prefer ctx.register_skill() for new plugins.
:::
Read the skill catalog
A plugin that shows which skills the agent can see (a catalog view, "open this skill", drift checks
against the installed copy) should call the same helpers the skills_list and skill_view tools use,
not walk the skill directories itself. These names are public and covered by the compatibility
contract:
| Import | What it gives you |
|---|---|
tools.skills_tool.find_all_skills(*, skip_disabled=False) |
Loadable skills as {"name", "description", "category"} dicts: the cached, first-wins, platform/environment-gated walk skills_list uses. name is what skill_view() accepts. |
tools.skills_tool.sort_skills(skills) |
The one listing order (category, then name). |
tools.skills_tool.skill_search_dirs() |
(roots, active_dir): (tier, dir) roots in precedence order (project, local, create_dir, external) and the live profile's skills dir. |
tools.skills_tool.skill_lookup_path_error(name) |
An error string if name is absolute or contains .., else None. Check it before any lookup. |
tools.skills_tool.locate_skill(name, local_category_name, roots) |
(error_json, skill_dir, skill_md) for one name over roots: tier precedence, same-tier collision refusal and the project-skill quarantine gate. local_category_name is an extra relative path to try (it is not a category filter), so pass None unless you need one. skill_md is set only when error_json is None. |
tools.skills_sync.dir_hash(directory) |
The package hash the bundled-skill sync stores, so you can tell whether a user's copy still matches what was installed. |
tools.skill_usage.read_skill_name(skill_md, fallback) |
The frontmatter name: of a SKILL.md, else fallback. |
from tools.skills_tool import find_all_skills, locate_skill, skill_lookup_path_error, skill_search_dirs, sort_skills
def list_skills():
return sort_skills(find_all_skills())
def open_skill(name):
if err := skill_lookup_path_error(name):
return err
roots, _active = skill_search_dirs()
error, _skill_dir, skill_md = locate_skill(name, None, roots)
return error or skill_md.read_text(encoding="utf-8-sig", errors="replace")Plugin-registered skills (ctx.register_skill) are not on disk under these roots; find_all_skills()
returns the on-disk catalog only.
Ship a language pack
A plugin can add a UI language or override the wording of an existing one for every surface at once —
Python (agent.i18n.t(): approval prompts, gateway replies, tool verbs, tips), the hermes --tui
interface and the Desktop app. Declare provides_locales and ship the YAML; no Python is needed:
~/.hermes/plugins/hermes-lang-pl/
├── plugin.yaml
└── locales/
├── pl.yaml # core (Python) strings — same key tree as the bundled locales/en.yaml
├── pl.tui.yaml # optional: TUI strings (keys in locales/_keys.tui.json)
└── pl.desktop.yaml # optional: Desktop strings (keys in locales/_keys.desktop.json)name: hermes-lang-pl
version: 1.0.0
description: Polish language pack
provides_locales:
- id: pl # lowercase BCP-47-style id: pl, pt-br, zh-hant
endonym: Polski # what language switchers show
rtl: falseWhen provides_locales is declared the loader calls ctx.register_locale_dir(/locales) beforeregister() (a manifest-only pack with no __init__.py loads like a manifest-only Desktop plugin).
Catalogs are layered and partial: pack → user overlay (<HERMES_HOME>/locales/) → bundled →
English → key; a pack only needs the keys it changes, and the last pack loaded wins per key.
Core values keep English's named {placeholders}; TUI/Desktop entries whose English value is a
function are written as strings with positional {0}, {1} placeholders.
Plugins with code can register programmatically — the handles are PluginRegistrations, so unloading
the plugin removes the layer, and registration never changes display.language:
def register(ctx):
here = Path(__file__).parent
ctx.register_locale("pl", here / "locales" / "pl.yaml", endonym="Polski") # YAML path
ctx.register_locale("pl", {"approval": {"denied": " ✗ Odrzucono"}}) # mapping, nested or flat
ctx.register_locale("pl", here / "locales" / "pl.tui.yaml", surface="tui") # core | tui | desktop
ctx.register_locale_dir(here / "locales") # every <lang>[.surface].yamlhermes plugins validate checks each declared id has a parseable, text-only locales/.yaml
(a non-text leaf is an error) and warns with the names of keys absent from the English catalog of
that surface. Renderers fetch the pack layer through the i18n.languages / i18n.catalog RPCs.
User-facing guide: Language Packs.
Gate on environment variables
If your plugin needs an API key:
# plugin.yaml — simple format (backwards-compatible)
requires_env:
- WEATHER_API_KEYIf WEATHER_API_KEY isn't set, the plugin is disabled with a clear message. No crash, no error in the agent — just "Plugin weather disabled (missing: WEATHER_API_KEY)".
When users run hermes plugins install, they're prompted interactively for any missing requires_env variables. Values are saved to .env automatically.
For a better install experience, use the rich format with descriptions and signup URLs:
# plugin.yaml — rich format
requires_env:
- name: WEATHER_API_KEY
description: "API key for OpenWeather"
url: "https://openweathermap.org/api"
secret: true| Field | Required | Description |
|---|---|---|
name |
Yes | Environment variable name |
description |
No | Shown to user during install prompt |
url |
No | Where to get the credential |
secret |
No | If true, input is hidden (like a password field) |
Both formats can be mixed in the same list. Already-set variables are skipped silently.
Lazy-install optional Python dependencies
For an SDK covered by a Hermes project extra, use pm.ensure_import at the
operation that needs it. Use pm.available for a read-only availability check.
Do not install dependencies from a frequently polled check_fn.
This example requests the existing bedrock extra:
from pm import InstallError, ensure_import
def my_tool_handler(args, **kwargs):
try:
ensure_import("bedrock")
except InstallError as exc:
return {"error": str(exc)}
import boto3
# Use the SDK here.The argument is a pyproject.toml extra name. It is not an arbitrary package
specification or a plugin-qualified key. The old LAZY_DEPS registry andFeatureUnavailable exception no longer exist.
If a new environment is selected, the helper can report a required restart.
Return that error instead of importing from a second environment inside the
running process. Already available dependencies need no installation, even
when security.allow_lazy_installs is false.
For a directory plugin's own Python dependencies, declare dependencies under[project] in its pyproject.toml. Without an authored project file, PM combines
legacy pip_dependencies and python_dependencies lists from plugin.yaml orplugin.yml. Old PM-generated project files do not override these lists.
Consent, workspace membership, and currency checks use the same declaration.
PM prepares their dependencies together with core requirements before enabling
the plugin. The generated workspace does not rewrite the plugin directory or
the shipped lockfile. Dependency conflicts refuse admission and preserve the
previous selection. PM does not automatically disable other plugins.
Dependencies installed manually with pip are not durable PM declarations.
A later environment replacement need not retain them. Wrapper plugins whose
Python runtimes remain outside PM can use the
memory-provider survival contract.
See Package management for the
runtime layout and lazy-install policy.
Thread-safe lazy singletons
Plugins often cache an expensive object — an SDK client, an HTTP session, a connection pool — in a module-level variable built on first use:
_client = None
def get_client():
global _client
if _client is not None:
return _client
_client = ExpensiveClient(...) # ← TOCTOU race
return _clientThis is a footgun. Hermes runs multiple threads in one process (delegated tool calls, background workers, the self-improvement fork), so two threads can hit get_client() before _client is set, both pass the is not None check, both run the expensive build, and the second write clobbers the first — leaking whatever resource the loser opened (connection, file handle, background thread).
Don't hand-roll the lock. Use the helpers in plugins/plugin_utils.py:
from plugins.plugin_utils import lazy_singleton, SingletonSlot
# Zero-arg accessor → decorate it:
@lazy_singleton
def get_client():
return ExpensiveClient(load_config()) # runs exactly once
client = get_client() # safe across threads
get_client.reset() # drop the instance (tests / teardown)
# Accessor that takes a build argument → use a slot:
_slot: SingletonSlot = SingletonSlot()
def get_client(config=None):
return _slot.get(lambda: ExpensiveClient(resolve(config)))
def reset_client():
_slot.reset()Both serialize concurrent first calls with double-checked locking and run the factory at most once. If the factory raises, nothing is cached and the next call retries. The Honcho memory plugin (client.py) is the reference consumer.
Rule of thumb: any time you write
global _somethingfollowed by ais Nonecheck and a build, reach for one of these instead.
Conditional tool availability
For tools that depend on optional libraries:
ctx.register_tool(
name="my_tool",
schema={...},
handler=my_handler,
check_fn=lambda: _has_optional_lib(), # False = tool hidden from model
)Overriding a built-in tool
To replace a built-in tool with your own implementation (e.g. swap the
default browser tool for a headed-Chrome CDP backend, or replaceweb_search with a custom corporate index), pass override=True:
def register(ctx):
ctx.register_tool(
name="browser_navigate", # same name as the built-in
toolset="plugin_my_browser", # your own toolset namespace
schema={...},
handler=my_custom_navigate,
override=True, # explicit opt-in
)Without override=True, the registry rejects any registration that would
shadow an existing tool from a different toolset — this prevents
accidental overwrites. Overriding a built-in tool additionally
requires the operator to opt in viaplugins.entries..allow_tool_override: true in config.yaml;
without that gate, register_tool(override=True) raisesPluginToolOverrideError. The override is logged so it's
auditable in ~/.hermes/logs/agent.log. Plugins load after built-in
tools, so the registration order is correct: your handler replaces the
built-in one.
Non-bundled plugins also need an operator grant. For any plugin that
does not ship with Hermes core (user, project, or pip source),override=True against an existing built-in tool additionally requires a
per-plugin opt-in in config.yaml:
plugins:
entries:
my-plugin: # the plugin's registry key from `hermes plugins list`
allow_tool_override: trueWithout the grant, ctx.register_tool(..., override=True) raisesPluginToolOverrideError; since register() exceptions are caught by the
loader, the plugin is disabled and Hermes continues. The gate exists
because an enabled plugin that silently replaces a privileged built-in
like shell_exec or write_file could intercept everything the model
routes through it. Bundled plugins are exempt: an override there is a
maintainer decision. If config cannot be loaded, the gate fails closed.
You normally never edit this key by hand. hermes plugins enable <name>
asks whether to grant the capability only when the plugin's manifest
declares it under capabilities: (the consent screen, defaulting to no);
a plugin that declares no capabilities is enabled without any grant
prompt. The --allow-tool-override / --no-allow-tool-override flags set
or revoke the grant explicitly in either case, for scripted installs or
for pre-authorizing a plugin that has not adopted the manifest block.
The same grant also gates deregister(): without it, a plugin cannot
remove a tool it does not own (which would otherwise be a way around the
override check).
Register multiple hooks
def register(ctx):
ctx.register_hook("pre_tool_call", before_any_tool)
ctx.register_hook("post_tool_call", after_any_tool)
ctx.register_hook("pre_llm_call", inject_memory)
ctx.register_hook("on_session_start", on_new_session)
ctx.register_hook("on_session_end", on_session_end)Hook reference
Each hook is documented in full on the Event Hooks reference — callback signatures, parameter tables, exactly when each fires, and examples. Here's the summary:
| Hook | Fires when | Callback signature | Returns |
|---|---|---|---|
pre_tool_call |
Before any tool executes | tool_name: str, args: dict, task_id: str |
optional directive: {"action": "block", "message": ...} vetoes the call; {"action": "approve", "message": ...} escalates to the human-approval gate |
post_tool_call |
After any tool returns | tool_name: str, args: dict, result: str, task_id: str, duration_ms: int |
ignored |
pre_llm_call |
Once per turn, before the tool-calling loop | session_id: str, user_message: str, conversation_history: list, is_first_turn: bool, model: str, platform: str |
context injection |
post_llm_call |
Once per turn, after the tool-calling loop (successful turns only) | session_id: str, user_message: str, assistant_response: str, conversation_history: list, model: str, platform: str |
ignored |
pre_api_request |
Before each raw provider API request (several per turn when the model calls tools) | session_id: str, model: str, provider: str, base_url: str, api_mode: str, api_call_count: int, message_count: int, tool_count: int, approx_input_tokens: int, max_tokens: int, request: dict |
ignored |
post_api_request |
After each raw provider API request returns | pre_api_request fields plus api_duration: float, finish_reason: str, response_model: str | None, usage: dict, response: dict, assistant_content_chars: int, assistant_tool_call_count: int |
ignored |
api_request_error |
A provider API call raised | correlation fields plus status_code: int | None, retry_count: int | None, max_retries: int | None, retryable: bool | None, reason: str | None, error: dict, request: dict |
ignored |
pre_auxiliary_call |
Before each provider attempt of an auxiliary LLM call (titling, compression, MoA, vision, approval, ...); not a pre_api_request |
aux_task: str plus the pre_api_request fields (session_id/task_id/turn_id are the parent turn's or empty, api_request_id: str, retry_count: int, streaming: bool, request: dict) |
ignored |
post_auxiliary_call |
After that attempt returns or raises | pre_auxiliary_call fields plus api_duration: float, finish_reason, response_model, usage: dict | None, response: dict | None, error: str | None, error_type: str | None |
ignored |
on_session_start |
New session created (first turn only) | session_id: str, model: str, platform: str |
ignored |
on_session_end |
End of every run_conversation call + CLI exit |
session_id: str, completed: bool, interrupted: bool, model: str, platform: str |
ignored |
on_session_finalize |
CLI/gateway tears down an active session | session_id: str | None, platform: str |
ignored |
on_session_reset |
Gateway swaps in a new session key (/new, /reset) |
session_id: str, platform: str |
ignored |
on_session_delete |
A session delete or deliberate archive commits (every surface, sweeps, prune) | session_id: str, reason: str, profile: str | None, optional deleted_ids: list[str], sessions_dir: str |
ignored |
post_compaction |
A compaction committed (any path: auto, /compress, gateway, Codex, micro, prune) |
session_id: str, old_session_id: str, kind: str, reason: str, before_tokens: int | None, after_tokens: int | None, messages_removed: int | None |
ignored |
gateway_platform_event |
An authorized platform-native event is normalized at the gateway boundary (Telegram reactions currently) | platform: str, event_type: str, payload: dict |
ignored |
kanban_task_claimed |
A kanban task is claimed (dispatcher process, before the worker spawns) | task_id: str, board: str | None, assignee: str | None, run_id: int | None, profile_name: str |
ignored |
kanban_task_completed |
A kanban task completes (worker process) | task_id, board, assignee, run_id, profile_name, summary: str | None |
ignored |
kanban_task_blocked |
A kanban task is blocked (worker process) | task_id, board, assignee, run_id, profile_name, reason: str | None |
ignored |
Most hooks are fire-and-forget observers — their return values are ignored. The exceptions are pre_llm_call, which can inject context into the conversation, and pre_tool_call, which can return a block/approve directive.
All callbacks should accept **kwargs for forward compatibility. If a hook callback crashes, it's logged and skipped. Other hooks and the agent continue normally.
The kanban lifecycle hooks fire after the board DB change commits, so a callback always sees durable state and can never hold the SQLite write lock. Because kanban workers run as separate hermes -p chat -q subprocesses, kanban_task_claimed fires in the dispatcher process while kanban_task_completed / kanban_task_blocked fire in the worker process — hook in the dispatcher to observe every transition centrally, or in the worker for per-task in-session context.
The API request hooks are observers for the raw provider request, one level below the per-turn pre_llm_call / post_llm_call pair: a single turn that calls tools makes several API requests, and these hooks fire around each one. They exist for observability plugins (tracing, cost accounting, latency dashboards). The request and response kwargs are sanitized, size-capped JSON views of the provider payload (sensitive keys redacted, long strings truncated, SDK objects normalized), and usage is a plain token-summary dict. Every payload carries the correlation fields turn_id, api_request_id, task_id, session_id, and api_call_count, so a plugin can stitch requests, tool calls, and turns together. api_request_error fires when a provider call raises and adds status_code, retry_count / max_retries, retryable, reason, and an error dict with type and message.
`pre_llm_call` context injection
This is the only hook whose return value matters. When a pre_llm_call callback returns a dict with a "context" key (or a plain string), Hermes injects that text into the current turn's user message. This is the mechanism for memory plugins, RAG integrations, guardrails, and any plugin that needs to provide the model with additional context.
Return format
# Dict with context key
return {"context": "Recalled memories:\n- User prefers dark mode\n- Last project: hermes-agent"}
# Plain string (equivalent to the dict form above)
return "Recalled memories:\n- User prefers dark mode"
# Return None or don't return → no injection (observer-only)
return NoneAny non-None, non-empty return with a "context" key (or a plain non-empty string) is collected and appended to the user message for the current turn.
Oversized-context spill
Per-hook context is capped at 10,000 characters by default. Anything above the cap is written to $HERMES_HOME/hook_outputs/<session_id>/.txt and replaced with a head/tail preview plus the saved path. The model can read the full content via read_file or terminal if it genuinely needs it. This keeps a runaway plugin from inflating every subsequent turn's prompt and blowing out the prompt cache prefix. Tune in config.yaml:
hooks:
output_spill:
enabled: true # default: true
max_chars: 10000 # default; set higher to opt out of spilling
preview_head: 500 # chars shown at the top of the preview
preview_tail: 500 # chars shown at the bottom of the preview
# directory: null # default: $HERMES_HOME/hook_outputsHow injection works
Injected context is appended to the user message, not the system prompt. This is a deliberate design choice:
- Prompt cache preservation — the system prompt stays identical across turns. Anthropic and OpenRouter cache the system prompt prefix, so keeping it stable saves 75%+ on input tokens in multi-turn conversations. If plugins modified the system prompt, every turn would be a cache miss.
- Ephemeral — the injection happens at API call time only. The original user message in the conversation history is never mutated, and nothing is persisted to the session database.
- The system prompt is Hermes's territory — it contains model-specific guidance, tool enforcement rules, personality instructions, and cached skill content. Plugins contribute context alongside the user's input, not by altering the agent's core instructions.
Example: Memory recall plugin
"""Memory plugin — recalls relevant context from a vector store."""
import httpx
MEMORY_API = "https://your-memory-api.example.com"
def recall_context(session_id, user_message, is_first_turn, **kwargs):
"""Called before each LLM turn. Returns recalled memories."""
try:
resp = httpx.post(f"{MEMORY_API}/recall", json={
"session_id": session_id,
"query": user_message,
}, timeout=3)
memories = resp.json().get("results", [])
if not memories:
return None # nothing to inject
text = "Recalled context from previous sessions:\n"
text += "\n".join(f"- {m['text']}" for m in memories)
return {"context": text}
except Exception:
return None # fail silently, don't break the agent
def register(ctx):
ctx.register_hook("pre_llm_call", recall_context)Example: Guardrails plugin
"""Guardrails plugin — enforces content policies."""
POLICY = """You MUST follow these content policies for this session:
- Never generate code that accesses the filesystem outside the working directory
- Always warn before executing destructive operations
- Refuse requests involving personal data extraction"""
def inject_guardrails(**kwargs):
"""Injects policy text into every turn."""
return {"context": POLICY}
def register(ctx):
ctx.register_hook("pre_llm_call", inject_guardrails)Example: Observer-only hook (no injection)
"""Analytics plugin — tracks turn metadata without injecting context."""
import logging
logger = logging.getLogger(__name__)
def log_turn(session_id, user_message, model, is_first_turn, **kwargs):
"""Fires before each LLM call. Returns None — no context injected."""
logger.info("Turn: session=%s model=%s first=%s msg_len=%d",
session_id, model, is_first_turn, len(user_message or ""))
# No return → no injection
def register(ctx):
ctx.register_hook("pre_llm_call", log_turn)Multiple plugins returning context
When multiple plugins return context from pre_llm_call, their outputs are joined with double newlines and appended to the user message together. The order follows plugin discovery order (alphabetical by plugin directory name).
Middleware: change what happens
Hooks observe the agent loop (with the few documented steering shapes above). Middleware changes what happens: request middleware rewrites the effective payload before anything downstream sees it, and execution middleware wraps the actual call. Register it from the same register(ctx) entry point:
def cap_find_output(tool_name, args, **kwargs):
"""Rewrite terminal find commands to cap their output."""
command = args.get("command", "")
if tool_name == "terminal" and command.startswith("find "):
return {
"args": {**args, "command": command + " | head -100"},
"source": "my-plugin",
"reason": "cap find output",
}
return None # leave the call unchanged
def register(ctx):
ctx.register_middleware("tool_request", cap_find_output)The canonical list of kinds is VALID_MIDDLEWARE in hermes_cli/middleware.py:
| Kind | Receives | Return contract |
|---|---|---|
tool_request |
tool_name, args, original_args, context kwargs |
Return {"args": {...}} to replace the effective tool arguments before hooks, guardrails, approvals, and execution see them. Return None to leave the call unchanged. |
llm_request |
request, original_request, context kwargs |
Return {"request": {...}} to replace the effective provider kwargs before Hermes sends them. |
tool_execution |
the payload plus next_call |
Wraps tool execution. Call next_call(payload) exactly once to run the downstream chain (or skip it to short-circuit) and return the result. |
llm_execution |
the payload plus next_call |
Same shape, wrapping the provider call. |
Rules that matter in practice:
- Request middleware chains: each callback sees the payload as rewritten by earlier callbacks, while
original_args/original_requestalways carries the pre-middleware copy. Payloads are copied between callbacks, so mutate freely. - You can include
source,reason, andnamestrings in the returned dict. They land in the middleware trace, which downstream observer hooks receive as themiddleware_tracekwarg. next_callin execution middleware is single-use. Calling it twice raises, because it would re-run the provider or tool.- A middleware callback that raises is logged and skipped; the chain continues. A downstream failure raised after your
next_callpropagates as itself. Middleware can never break the base runtime path. - Middleware payloads carry
middleware_schema_version(hermes.middleware.v1) alongside the observer telemetry fields. - Unknown kinds register with a warning instead of failing, so a plugin written against a newer Hermes still loads on an older one.
Register CLI commands
Plugins can add their own hermes subcommand tree:
def _my_command(args):
"""Handler for hermes my-plugin <subcommand>."""
sub = getattr(args, "my_command", None)
if sub == "status":
print("All good!")
elif sub == "config":
print("Current config: ...")
else:
print("Usage: hermes my-plugin <status|config>")
def _setup_argparse(subparser):
"""Build the argparse tree for hermes my-plugin."""
subs = subparser.add_subparsers(dest="my_command")
subs.add_parser("status", help="Show plugin status")
subs.add_parser("config", help="Show plugin config")
subparser.set_defaults(func=_my_command)
def register(ctx):
ctx.register_tool(...)
ctx.register_cli_command(
name="my-plugin",
help="Manage my plugin",
setup_fn=_setup_argparse,
handler_fn=_my_command,
)After registration, users can run hermes my-plugin status, hermes my-plugin config, etc.
Memory provider plugins use a convention-based approach instead: add a register_cli(subparser) function to your plugin's cli.py file. The memory plugin discovery system finds it automatically — no ctx.register_cli_command() call needed. See the Memory Provider Plugin guide for details.
Active-provider gating: Memory plugin CLI commands only appear when their provider is the active memory.provider in config. If a user hasn't set up your provider, your CLI commands won't clutter the help output.
Register slash commands
Plugins can register in-session slash commands — commands users type during a conversation (like /lcm status or /ping). These work in both CLI and gateway (Telegram, Discord, etc.).
def _handle_status(raw_args: str) -> str:
"""Handler for /mystatus — called with everything after the command name."""
if raw_args.strip() == "help":
return "Usage: /mystatus [help|check]"
return "Plugin status: all systems nominal"
def register(ctx):
ctx.register_command(
"mystatus",
handler=_handle_status,
description="Show plugin status",
)After registration, users can type /mystatus in any session. The command appears in autocomplete, /help output, and the Telegram bot menu.
Signature: ctx.register_command(name: str, handler: Callable, description: str = "", args_hint: str = "")
| Parameter | Type | Description |
|---|---|---|
name |
str |
Command name without the leading slash (e.g. "lcm", "mystatus") |
handler |
Callable[[str], str | None] |
Called with the raw argument string, plus any calling-session keywords it declares (see below). May also be async. |
description |
str |
Shown in /help, autocomplete, and Telegram bot menu |
Key differences from register_cli_command():
register_command() |
register_cli_command() |
|
|---|---|---|
| Invoked as | /name in a session |
hermes name in a terminal |
| Where it works | CLI sessions, Telegram, Discord, etc. | Terminal only |
| Handler receives | Raw args string (+ opt-in session context) | argparse Namespace |
| Use case | Diagnostics, status, quick actions | Complex subcommand trees, setup wizards |
Conflict protection: If a plugin tries to register a name that conflicts with a built-in command (help, model, new, etc.), the registration is silently rejected with a log warning. Built-in commands always take precedence.
Calling-session context: a handler can learn which chat and session invoked it by declaring any of these keyword parameters (or **kwargs for all of them). Handlers that declare none keep the one-argument handler(raw_args) call, so existing plugins are unaffected:
| Keyword | Value |
|---|---|
session_id |
The calling session's id (the one on_session_reset later reports as old_session_id). Empty when the chat has no session yet. |
session_key |
The routing key of the calling chat. |
platform |
Where the command came from: telegram, discord, …, local (CLI/Desktop sessions owned by the gateway), cli, tui, desktop. |
source |
The adapter-derived gateway.session.SessionSource (user_id, chat_id, chat_type, thread_id, …) when the gateway dispatched the command, else None. |
Every surface passes them: messaging platforms, the classic CLI, the TUI and Desktop. Sync handlers run in the gateway executor and async handlers are awaited, as before.
def _handle_whoami(raw_args: str, *, session_id: str = "", platform: str = "", source=None) -> str:
user = source.user_id if source is not None else "local user"
return f"{user} on {platform}, session {session_id or '(none yet)'}"
def register(ctx):
ctx.register_command("whoami", handler=_handle_whoami, description="Show the calling session")source is transport identity, not an authorization grant: authorize from stable ids (source.user_id, source.chat_id), never from display names or the command text, and fail closed when a sensitive command gets source=None. Handler exceptions propagate; Hermes never retries the call with fewer arguments.
Where handlers run: a slash command handler runs in the gateway process that owns the session, also when the user types the command in the classic hermes chat CLI. The handler is not running in the user's terminal or directory, so:
- Return the text to show.
print()writes to the gateway's stdout, and the user never sees it. - Take the session's working directory from
agent.runtime_cwd.resolve_agent_cwd(), notos.getcwd(), which is the gateway's own directory. Resolve relative paths against it. - Read session identity with
gateway.session_context.get_session_env("HERMES_SESSION_PLATFORM")and the otherHERMES_SESSION_*names, notos.environ.
from agent.runtime_cwd import resolve_agent_cwd
def _handle_todo(raw_args: str) -> str:
todo = resolve_agent_cwd() / "TODO.md" # the session's folder, not the gateway's
return todo.read_text() if todo.exists() else "No TODO.md here."Dispatch tools from slash commands
Slash command handlers that need to orchestrate tools (spawn a subagent via delegate_task, call file_edit, etc.) should use ctx.dispatch_tool() instead of reaching into framework internals. The parent-agent context (workspace hints, spinner, model inheritance) is wired up automatically.
def register(ctx):
def _handle_deliver(raw_args: str):
result = ctx.dispatch_tool(
"delegate_task",
{
"goal": raw_args,
"toolsets": ["terminal", "file", "web"],
},
)
return result
ctx.register_command(
"deliver",
handler=_handle_deliver,
description="Delegate a goal to a subagent",
)Signature: ctx.dispatch_tool(name: str, args: dict, *, parent_agent=None) -> str
| Parameter | Type | Description |
|---|---|---|
name |
str |
Tool name as registered in the tool registry (e.g. "delegate_task", "file_edit") |
args |
dict |
Tool arguments, same shape the model would send |
parent_agent |
Agent | None |
Optional override. When omitted, resolves from the current CLI agent (or degrades gracefully in gateway mode) |
Runtime behavior:
- CLI mode:
parent_agentis resolved from the active CLI agent so workspace hints, spinner, and model selection inherit as expected. - Gateway mode: There is no CLI agent, so tools degrade gracefully — workspace is read from the configured terminal working directory and no spinner is shown.
- Explicit override: If the caller passes
parent_agent=explicitly, it is respected and not overwritten.
This is the public, stable interface for tool dispatch from plugin commands. Plugins should not reach into ctx._cli_ref.agent or similar private state.
Act from inside a hook (profile + tools)
ctx._cli_ref is only populated in an interactive CLI session. It is None in the gateway, in non-interactive hermes chat -q runs, and in kanban-spawned worker sessions — so any plugin logic that reaches through _cli_ref silently no-ops in exactly those contexts. Two stable, session-agnostic APIs cover what hooks actually need:
ctx.profile_name— the active profile name (e.g."default", or the assignee profile in a kanban worker). Derived fromHERMES_HOME, so it works everywhere with no_cli_refdependency.ctx.dispatch_tool(name, args)— invoke any registered tool (built-in or plugin), including thekanban_*tools,delegate_task,terminal,read_file, etc. Works from hook callbacks regardless of which process the hook fires in.
Together these let a kanban lifecycle hook observe a transition and act on the board without touching framework internals:
def register(ctx):
def on_blocked(*, task_id, reason=None, **kw):
# Runs in the worker process; ctx._cli_ref is None here.
ctx.dispatch_tool("kanban_comment", {
"task_id": task_id,
"comment": f"[{ctx.profile_name}] auto-noted block: {reason}",
})
ctx.register_hook("kanban_task_blocked", on_blocked)For running a full hermes <subcommand> (e.g. hermes kanban show), shell out with the terminal tool via ctx.dispatch_tool("terminal", {"command": "hermes kanban show ..."}) — there is no in-process slash-command bridge for headless worker sessions, and tools are the supported way to drive Hermes from a hook.
Send raw CDP commands to the agent's browser
A plugin that must drive the agent's browser below the browser tools can get a
CDP handle pinned to a task's live supervisor connection withSUPERVISOR_REGISTRY.capture(task_id) and send commands withcall(method, params=None, *, session_id=None, timeout=10.0). The handle
raises CapturedCDPInvalid once that connection is gone and never retargets a
newer one. Use it instead of reading or writing CDPSupervisor private
attributes, which the catalog rejects. It is trusted in-process transport only:
it performs no origin, consent or ownership checks for you. Full contract:
Browser CDP Supervisor.
Know which cron run you are in
ctx.current_cron_execution() returns the scheduled run the current code executes inside, or None outside cron. It works from any hook that fires during the run (pre_tool_call, post_tool_call, pre_llm_call, ...) and from tool handlers. The value is a frozen CronExecution:
| Field | Meaning |
|---|---|
job_id, job_name |
The cron job. |
execution_id |
This run's row in the executions ledger (hermes cron runs). |
source |
Which path fired the run: "builtin" (the built-in scheduler), "direct" (a run fired outside it, e.g. hermes cron run), or an external scheduler's name. |
scheduled_instant |
The schedule occurrence this run fires. None for a manual or other off-schedule run, so check this field to tell a scheduled run from a manual one. |
started_at |
When the run started. |
profile |
The profile that owns the job. |
The scheduler sets it only after the run has won its execution claim, and clears it when the run ends. It is per-run, so two jobs or profiles firing at the same time never see each other's value. The model cannot forge it: hook arguments come from Hermes, and tool subprocesses (terminal, execute_code) run in a separate interpreter. Subagents spawned with delegate_task get None because they are not the scheduled run itself.
def register(ctx):
def guard(*, tool_name, args, **kw):
run = ctx.current_cron_execution()
if tool_name == "deploy" and (run is None or run.scheduled_instant is None):
return {"action": "block", "message": "deploy only runs from its scheduled cron job"}
ctx.register_hook("pre_tool_call", guard)Add Automation Blueprints
ctx.register_automation_blueprint(key, *, title, description, schedule_template, prompt_template, category="general", slots=(), deliver_default="origin", skills=(), tags=()) adds a fill-in-the-blanks automation to the Automation Blueprints catalog. It appears next to the built-ins in /blueprint (CLI, TUI, messengers), the dashboard's Blueprints tab, and the Desktop Cron page, labelled with your plugin's name. The arguments are the fields of the built-in AutomationBlueprint (cron/blueprint_catalog.py); each slot is a dict of BlueprintSlot fields.
def register(ctx):
ctx.register_automation_blueprint(
"standup",
title="Team standup digest",
description="Every weekday, summarize what changed in a repo since yesterday.",
category="work",
schedule_template="{minute} {hour} * * 1-5",
prompt_template="Summarize yesterday's commits and open PRs for {repo} as a short standup digest.",
slots=[
{"name": "repo", "type": "text", "label": "Which repo?", "default": "acme/app"},
{"name": "time", "type": "time", "label": "What time?", "default": "09:15"},
{"name": "deliver", "type": "enum", "label": "Where to deliver?", "default": "origin",
"options": ["origin", "local"], "strict": False},
],
tags=["work", "daily"],
)Users then run /blueprint teamtools:standup (or just /blueprint standup) or pick it in the Desktop/dashboard form.
- Keys are namespaced. The catalog key is
:<key>(teamtools:standup), so a plugin can never replace a built-in or another plugin's blueprint. Pass the bare key; a key containing:is rejected. - Slots and templates. Slot
typeistime(HH:MM),enum,weekdays, ortext.prompt_templatemay use any slot as{name}.schedule_templatemay use slot names plus{minute}/{hour}(from a slot namedtime) and{dow}(from arecurrence/dayslot, else*). A slot nameddelivergets the profile's real delivery targets in the Desktop and dashboard forms. - Validated at registration. An unknown placeholder, bad slot type, duplicate key, or schedule that does not parse logs a warning and skips that blueprint; the rest of your plugin still loads. When every slot has a default, Hermes fills the blueprint once at load, so a broken template fails then instead of on the user's first Schedule it.
- Per profile. Plugins load per profile, so the blueprint is listed only in profiles where your plugin is enabled.
- Jobs outlive the plugin. Scheduling a blueprint creates an ordinary cron job with the prompt and schedule already filled in. Disabling or uninstalling the plugin removes the blueprint from the catalog but leaves those jobs running. If a job's
skillsname a skill your plugin bundles, the job skips that skill (and logs it) while the plugin is disabled.
This needs no manifest capability. It also works under plugin host isolation, because slots are plain dicts.
Handle Slack Block Kit button clicks
Plugins that post Block Kit messages with interactive elements (buttons, overflow menus, datepickers, etc.) can register the click handlers directly with the Slack adapter — no monkey-patching of slack_bolt.AsyncApp required.
def register(ctx):
async def _on_approve(ack, body, action):
# ack within 3 seconds — slack_bolt requirement.
await ack()
# body["channel"]["id"], body["user"]["id"], body["message"]["ts"]
# action["action_id"], action["value"]
sweep_id = (action.get("value") or "").split("|", 1)[-1]
# ...do the deterministic work, then post a follow-up.
ctx.register_slack_action_handler("inbox_sweep_approve", _on_approve)Signature: ctx.register_slack_action_handler(action_id, callback) -> None
| Parameter | Type | Description |
|---|---|---|
action_id |
str | re.Pattern | dict |
Whatever slack_bolt.App.action() accepts: a literal action_id, a compiled regex matching multiple ids, or a constraint dict like {"action_id": "...", "block_id": "..."} |
callback |
async callable | Receives (ack, body, action) per the slack_bolt convention |
Runtime behavior:
- The handler is queued at plugin-load time and wired into the adapter's
slack_bolt.AsyncAppwhen the Slack platform connects. - Each callback is wrapped defensively: if your handler raises, the gateway logs the error and best-effort-acks the click so Slack stops retrying.
- Standard slack_bolt rules apply —
await ack()within 3 seconds, then do longer work. - For multi-workspace deployments the handler fires for clicks from any connected workspace; use
body["team"]["id"]if you need to scope behaviour.
This is the public way for plugins to participate in Slack interactivity. Older plugins may patch SlackAdapter.connect; prefer this API instead. For the full slack_bolt surface (events, shortcuts, commands — not just Block Kit actions), use the generic register_platform_handler("slack", ...) below.
Register native platform handlers (any platform)
Plugins that need to receive platform events the core adapter doesn't route — extra update types, native button callbacks, reaction/member events, webhook routes — can register a handler factory that the platform's adapter invokes at connect time. This works on every gateway platform.
def register(ctx):
def _wire(native, adapter):
# native: the platform's client/app object (see table below)
# adapter: the platform adapter instance (treat as read-only)
# Import platform SDKs HERE so register() works without them.
...
ctx.register_platform_handler("discord", _wire)Signature: ctx.register_platform_handler(platform, factory) -> None
| Parameter | Type | Description |
|---|---|---|
platform |
str |
Gateway platform name, lowercase ("telegram", "discord", "slack", "matrix", ...) |
factory |
callable | Receives (native, adapter) at connect time |
What native is, per platform:
| Platform | native object |
Typical hooks |
|---|---|---|
telegram |
PTB Application |
add_handler — any update type, pattern-scoped callbacks |
discord |
discord.ext.commands.Bot |
add_listener — reactions, member events, threads, voice |
slack |
slack_bolt.AsyncApp |
app.event() / app.action() / app.command() |
matrix |
Matrix client | event callbacks |
teams |
Teams App |
on_message / on_card_action decorators |
dingtalk |
DingTalkStreamClient |
register_callback_handler for other stream topics |
feishu |
lark_oapi client | API calls; event routing |
line, api_server, msgraph_webhook |
aiohttp web.Application |
router.add_get/post — custom routes (wired before the router freezes) |
| everything else (whatsapp, signal, irc, email, sms, ntfy, wecom, weixin, bluebubbles, yuanbao, ...) | None |
connect-time hook; work through the adapter handle |
Runtime behavior:
- Factories are queued at plugin-load time and invoked when the platform connects — for platforms where dispatch order matters (Telegram, Slack, Teams, aiohttp routers) they run before the core handlers register, so scoped plugin handlers take precedence and everything else falls through.
- Always scope handlers you add to first-match dispatch tables. On Telegram, use
CallbackQueryHandler(..., pattern=r"^myplugin:")— an unscoped handler would swallow the core button flows (exec approvals, model picker, clarify prompts). - Each factory is isolated: if it raises, the error is logged and the platform still connects.
- Import platform SDKs inside the factory body, not at module level —
register()must work when the SDK isn't installed. - One plugin can register factories for several platforms; each fires only when its platform connects.
Telegram alias: ctx.register_telegram_handler(factory) is a back-compat alias for ctx.register_platform_handler("telegram", factory).
Example — Telegram, pattern-scoped inline buttons:
def register(ctx):
def _wire(application, adapter):
from telegram.ext import CallbackQueryHandler
async def _on_button(update, context):
query = update.callback_query
await query.answer()
# ...handle "myplugin:*" callbacks
application.add_handler(
CallbackQueryHandler(_on_button, pattern=r"^myplugin:")
)
ctx.register_platform_handler("telegram", _wire)Example — Discord, reaction events:
def register(ctx):
def _wire(bot, adapter):
async def on_raw_reaction_add(payload):
... # e.g. reaction-based voting / moderation
bot.add_listener(on_raw_reaction_add, "on_raw_reaction_add")
ctx.register_platform_handler("discord", _wire)Mid-run plugin loading: what activates now vs next session
A plugin can load while the gateway (or the TUI/Desktop server) is already running: hermes plugins install/enable, a Desktop or dashboard install, a catalog re-pin, or a tool-triggered force
re-discovery. Every one of those paths runs a real forced rescan (discover_plugins(force=True)) andPluginManager.on_plugin_loaded(callback) fires from inside it with one summary per newly loaded plugin
(hermes_cli/plugins_activation.py):
{"name": "late-mcp", "key": "late-mcp",
"activated_now": {"gateway_commands": ["late"], "callbacks": ["telegram"]},
"deferred": {"tools": ["late_tool"], "prompt": ["late.section"], "mcp_servers": ["worker"]}}- Active immediately — gateway slash commands, gateway transform hooks / other hooks, and platform
callbacks: the gateway runner subscribes at boot and calls every live adapter's idempotentrewire_plugin_handlers(), so aregister_platform_handlerfactory (or Slack action handler) registered
by a late plugin is wired without a restart. Re-wiring is deduped per native client by(plugin, factory qualname); on Telegram the late handlers are hoisted ahead of core's catch-allfilters.COMMAND/CallbackQueryHandler(PTB dispatches the first match per group), exactly as they would sit at connect. - Deferred —
toolsandpromptsections apply from the next session (the running session's
prompt/tool schema is cache-stable, same rule as/skills install);mcp_servers(the plugin'smcp.jsonservers, by their mcp.json names) connect onmcp.reloador the next session. - There is no un-wire: disabling a plugin mid-run keeps its already-wired handlers until the gateway
restarts, and the surfaces say so.
Install surfaces report exactly this split: hermes plugins install/enable prints it after nudging the running
gateway (reload-plugins control-socket verb), plugins.manage install/toggle/update returns activation +gateway_reloaded (restart_required is true only when no gateway answered).
:::tip
This guide covers general plugins (tools, hooks, slash commands, CLI commands). The sections below sketch the authoring pattern for each specialized plugin type; each links to its full guide for field reference and examples.
:::
Specialized plugin types
Hermes has five specialized plugin types beyond the general surface. Each ships as a directory under plugins/<category>/<name>/ (bundled) or ~/.hermes/plugins/<category>/<name>/ (user). The contract differs by category — pick the one you need, then read its full guide.
Model provider plugins — add an LLM backend
Drop a profile into plugins/model-providers/<name>/:
# plugins/model-providers/acme/__init__.py
from providers import register_provider
from providers.base import ProviderProfile
register_provider(ProviderProfile(
name="acme",
aliases=("acme-inference",),
display_name="Acme Inference",
env_vars=("ACME_API_KEY", "ACME_BASE_URL"),
base_url="https://api.acme.example.com/v1",
auth_type="api_key",
default_aux_model="acme-small-fast",
fallback_models=("acme-large-v3", "acme-medium-v3"),
))# plugins/model-providers/acme/plugin.yaml
name: acme-provider
kind: model-provider
version: 1.0.0
description: Acme Inference — OpenAI-compatible direct APILazy-discovered the first time anything calls get_provider_profile() or list_providers() — auth.py, config.py, doctor.py, models.py, runtime_provider.py, and the chat_completions transport auto-wire to it. User plugins override bundled ones by name.
Full guide: Model Provider Plugins — field reference, overridable hooks (prepare_messages, build_extra_body, build_api_kwargs_extras, fetch_models), api_mode selection, auth types, testing.
Platform plugins — add a gateway channel
Drop an adapter into plugins/platforms/<name>/:
# plugins/platforms/myplatform/adapter.py
from gateway.platforms.base import BasePlatformAdapter
class MyPlatformAdapter(BasePlatformAdapter):
async def connect(self): ...
async def send(self, chat_id, text): ...
async def disconnect(self): ...
def check_requirements():
from agent.secret_scope import get_secret
return bool(get_secret("MYPLATFORM_TOKEN"))
def _env_enablement():
from agent.secret_scope import get_secret
tok = (get_secret("MYPLATFORM_TOKEN") or "").strip()
if not tok:
return None
return {"token": tok}
def register(ctx):
ctx.register_platform(
name="myplatform",
label="MyPlatform",
adapter_factory=lambda cfg: MyPlatformAdapter(cfg),
check_fn=check_requirements,
required_env=["MYPLATFORM_TOKEN"],
# Auto-populate PlatformConfig.extra from env so env-only setups
# show up in `hermes gateway status` without SDK instantiation.
env_enablement_fn=_env_enablement,
# Opt in to cron delivery: `deliver=myplatform` routes to this var.
cron_deliver_env_var="MYPLATFORM_HOME_CHANNEL",
emoji="💬",
platform_hint="You are chatting via MyPlatform. Keep responses concise.",
)# plugins/platforms/myplatform/plugin.yaml
name: myplatform-platform
label: MyPlatform
kind: platform
version: 1.0.0
description: MyPlatform gateway adapter
requires_env:
- name: MYPLATFORM_TOKEN
description: "Bot token from the MyPlatform console"
password: true
optional_env:
- name: MYPLATFORM_HOME_CHANNEL
description: "Default channel for cron delivery"
password: falseFull guide: Adding Platform Adapters — complete BasePlatformAdapter contract, message routing, auth gating, setup wizard integration. Look at plugins/platforms/irc/ for a stdlib-only working example.
Memory provider plugins — add a cross-session knowledge backend
Drop an implementation of MemoryProvider into plugins/memory/<name>/:
# plugins/memory/my-memory/__init__.py
from agent.memory_provider import MemoryProvider
class MyMemoryProvider(MemoryProvider):
@property
def name(self) -> str:
return "my-memory"
def is_available(self) -> bool:
from agent.secret_scope import get_secret
return bool(get_secret("MY_MEMORY_API_KEY"))
def initialize(self, session_id: str, **kwargs) -> None:
self._session_id = session_id
def sync_turn(self, user_content, assistant_content, *,
session_id="", messages=None) -> None:
...
def prefetch(self, query, *, session_id="") -> str:
...
def get_tool_schemas(self) -> list[dict]:
return [] # required @abstractmethod — see full guide
def register(ctx):
ctx.register_memory_provider(MyMemoryProvider())Memory providers are single-select — only one is active at a time, chosen via memory.provider in config.yaml.
If a provider also loads as a general plugin, general discovery owns its lifecycle hooks. The memory loader supplies hooks only as a fallback until that same plugin source loads successfully through general discovery. Repeated provider loads replace the fallback hook group; distinct callbacks within the group are preserved. This does not deduplicate hooks from different plugin sources or change provider activation.
Full guide: Memory Provider Plugins — full MemoryProvider ABC, threading contract, profile isolation, CLI command registration via cli.py.
Context engine plugins — replace the context compressor
# plugins/context_engine/my-engine/__init__.py
from agent.context_engine import ContextEngine
class MyContextEngine(ContextEngine):
@property
def name(self) -> str:
return "my-engine"
def update_from_response(self, usage) -> None: ...
def should_compress(self, prompt_tokens: int = None) -> bool: ...
def compress(self, messages, current_tokens=None, focus_topic=None,
force=False, memory_context="") -> list: ...
def register(ctx):
ctx.register_context_engine(MyContextEngine())Context engines are single-select — chosen via context.engine in config.yaml.
Full guide: Context Engine Plugins.
Image-generation backends
Drop a provider into plugins/image_gen/<name>/:
# plugins/image_gen/my-imggen/__init__.py
from agent.image_gen_provider import ImageGenProvider
class MyImageGenProvider(ImageGenProvider):
@property
def name(self) -> str:
return "my-imggen"
def is_available(self) -> bool: ...
def generate(self, prompt: str, aspect_ratio="landscape", **kwargs) -> dict:
# returns success_response(...) / error_response(...)
...
def register(ctx):
ctx.register_image_gen_provider(MyImageGenProvider())# plugins/image_gen/my-imggen/plugin.yaml
name: my-imggen
kind: backend
version: 1.0.0
description: Custom image generation backendFull guide: Image Generation Provider Plugins — full ImageGenProvider ABC, list_models() / get_setup_schema() metadata, success_response()/error_response() helpers, base64 vs URL output, user overrides, pip distribution.
Reference examples: plugins/image_gen/openai/ (DALL-E / GPT-Image via OpenAI SDK), plugins/image_gen/openai-codex/, plugins/image_gen/xai/ (Grok image gen).
Computer-use backend plugins
The computer_use tool talks to exactly one driver through the ComputerUseBackend ABC
(tools/computer_use/backend.py). computer_use.backend in config.yaml picks it; the default is the
built-in cua-driver, which ships as an ordinary provider at plugins/computer_use/cua/. Another driver
is a provider plugin in ~/.hermes/plugins/<name>/ whose register(ctx) callsctx.register_computer_use_provider(). The directory name is the value you put in computer_use.backend.
Computer-use providers are single-select, like memory providers and context engines:
/* Detailed source-code truncated for AI context efficiency. */# ~/.hermes/plugins/my-driver/plugin.yaml
name: my-driver
version: 1.0.0
description: Alternative computer-use driver # shown in the pickerSelect it in hermes tools → Computer Use (installed providers are listed under cua-driver, in the
CLI and in the Desktop toolset panel), or set it directly. No plugins.enabled entry is needed:
# config.yaml
computer_use:
backend: my-driver # default: cuaRules:
- One provider is active at a time. Only the selected one is imported and instantiated. Installed
providers that aren't selected stay on disk and are only listed (fromplugin.yaml) as options. A
provider never activates itself. - Selection is per profile and read when a session starts its driver. If the configured name
doesn't resolve, the call fails with an error naming it and the installed providers. Hermes never
falls back to another driver. - The model-facing schema is the same for every provider, so the prompt cache survives a driver
swap. Report gaps per action withActionResult(ok=False, code="unsupported_action", ...). - Approval gating, the Bot Desktop lease, screenshot dedup, element caps and vision routing all run
in the tool, above the backend. The backend only drives the screen. hermes computer-use status/doctor/permissionsrun cua-driver checks only whencuais
selected. For any other provider they report itsis_available()and run itsdoctor()if it has one.- Providers return live driver sessions, so they load in-process only. Under
plugins.isolation: host, a user provider is refused and the call reports that it could not be loaded.
Non-Python extension surfaces
Hermes also accepts extensions that aren't Python plugins at all. These are shown in the Pluggable interfaces table; the sections below sketch each authoring style briefly.
MCP servers — register external tools
Model Context Protocol (MCP) servers register their own tools into Hermes without any Python plugin. Declare them in ~/.hermes/config.yaml:
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/home/user/projects"]
timeout: 120
linear:
url: "https://mcp.linear.app/sse"
auth:
type: "oauth"Hermes connects to each server at startup, lists its tools, and registers them alongside built-ins. The LLM sees them exactly like any other tool. Full guide: MCP.
Gateway event hooks — fire on lifecycle events
Drop a manifest + handler into ~/.hermes/hooks/<name>/. Unlike plugins there is no plugins.enabled step: the gateway imports every valid hook directory at startup, so placing the files is the opt-in (trust model):
# ~/.hermes/hooks/long-task-alert/HOOK.yaml
name: long-task-alert
description: Send a push notification when a long task finishes
events:
- agent:end# ~/.hermes/hooks/long-task-alert/handler.py
async def handle(event_type: str, context: dict) -> None:
if context.get("duration_seconds", 0) > 120:
# send notification …
passEvents include gateway:startup, session:start, session:end, session:reset, agent:start, agent:step, agent:end, and wildcard command:*. Errors in hooks are caught and logged — they never block the main pipeline.
Full guide: Gateway Event Hooks.
Shell hooks — run a shell command on tool calls
If you just want to run a script when a tool fires (notifications, audit logs, desktop alerts, auto-formatters), use shell hooks in config.yaml — no Python required:
hooks:
- event: post_tool_call
command: "notify-send 'Tool ran: {tool_name}'"
when:
tools: [terminal, patch, write_file]Supports all the same events as Python plugin hooks (pre_tool_call, post_tool_call, pre_llm_call, post_llm_call, on_session_start, on_session_end, pre_gateway_dispatch) plus structured JSON output for pre_tool_call blocking decisions.
Full guide: Shell Hooks.
Skill sources — add a custom skill registry
If you maintain a GitHub repo of skills (or want to pull from a community index beyond the built-in sources), add it as a tap:
hermes skills tap add myorg/skills-repo
hermes skills search my-workflow --source myorg/skills-repo
hermes skills install myorg/skills-repo/my-workflowPublishing your own tap is just a GitHub repo with skills/<skill-name>/SKILL.md directories — no server or registry signup needed.
Full guides: Skills Hub · Publishing a custom tap (repo layout, minimal example, non-default paths, trust levels).
TTS / STT via command templates
Any CLI that reads/writes audio or text can be plugged in through config.yaml — no Python code:
tts:
provider: voxcpm
providers:
voxcpm:
type: command
command: "voxcpm --ref ~/voice.wav --text-file {input_path} --out {output_path}"
output_format: mp3
voice_compatible: trueFor STT, point HERMES_LOCAL_STT_COMMAND at an argv-tokenized template. It runs without implicit shell interpretation; wrap it in sh -c, cmd /c, or PowerShell explicitly if the trusted local command requires shell syntax. Supported placeholders: {input_path}, {output_path}, {format}, {voice}, {model}, {speed} (TTS); {input_path}, {output_dir}, {language}, {model} (STT). Any path-interacting CLI is automatically a plugin.
Full guides: TTS custom command providers · STT.
Distribute via pip
For sharing plugins publicly, add an entry point to your Python package:
# pyproject.toml
[project.entry-points."hermes_agent.plugins"]
my-plugin = "my_plugin_package"Entry-point discovery remains supported when the distribution is present in the
environment supplied by the installation owner (for example, a Nix derivation).
It is discovery, not permission to inject packages into a PM-selected generation.
For managed installs, distribute a directory plugin with pyproject.toml or
manifest Python requirements and use hermes plugins install / enable so PM
can admit it transactionally. Restart Hermes after a new environment is selected.hermes pm install accepts managed tool names, not arbitrary PyPI packages.
Distribute for NixOS
:::warning Nix is no longer explicitly supported
Nix/NixOS is no longer an explicitly supported install path (best-effort only) — see Nix Setup. This section is kept for users already deploying on NixOS.
:::
NixOS users can install your plugin declaratively if you provide a pyproject.toml with entry points:
Entry-point plugins (recommended for distribution):
# User's configuration.nix
services.hermes-agent.extraPythonPackages = [
(config.services.hermes-agent.package.python.pkgs.buildPythonPackage {
pname = "my-plugin";
version = "1.0.0";
src = pkgs.fetchFromGitHub {
owner = "you";
repo = "hermes-my-plugin";
rev = "v1.0.0";
hash = "sha256-..."; # nix-prefetch-url --unpack
};
format = "pyproject";
build-system = [ config.services.hermes-agent.package.python.pkgs.setuptools ];
})
];Directory plugins (no pyproject.toml needed):
services.hermes-agent.extraPlugins = [
(pkgs.fetchFromGitHub {
owner = "you";
repo = "hermes-my-plugin";
rev = "v1.0.0";
hash = "sha256-...";
})
];See the Nix Setup guide for complete documentation including overlay usage and collision checking.
Common mistakes
Handler doesn't return JSON string:
# Wrong — returns a dict
def handler(args, **kwargs):
return {"result": 42}
# Right — returns a JSON string
def handler(args, **kwargs):
return json.dumps({"result": 42})Missing **kwargs in handler signature:
# Works — the dispatcher only forwards the context keywords a signature names
def handler(args):
...
# Better — receives every injected context field (task_id, session_id, parent_agent, ...)
def handler(args, **kwargs):
...Handler raises exceptions:
# Wrong — exception propagates, tool call fails
def handler(args, **kwargs):
result = 1 / int(args["value"]) # ZeroDivisionError!
return json.dumps({"result": result})
# Right — catch and return error JSON
def handler(args, **kwargs):
try:
result = 1 / int(args.get("value", 0))
return json.dumps({"result": result})
except Exception as e:
return json.dumps({"error": str(e)})Schema description too vague:
# Bad — model doesn't know when to use it
"description": "Does stuff"
# Good — model knows exactly when and how
"description": "Evaluate a mathematical expression. Use for arithmetic, trig, logarithms. Supports: +, -, *, /, **, sqrt, sin, cos, log, pi, e."User Guide Egress Index (`website/docs/user-guide/egress/index.md`)
title: Egress proxy sidebar_position: 1
Egress proxy
Optional outbound credential-injection firewall for remote terminal sandboxes. The sandbox only ever holds opaque proxy tokens; real API keys never leave the host.
- iron-proxy — single-binary TLS-intercepting proxy from ironsh/iron-proxy, lazy-installed and managed by
hermes egress.
User Guide Features Overview (`website/docs/user-guide/features/overview.md`)
title: "Features Overview" sidebar_label: "Overview" sidebar_position: 1
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.
:::
Core
- Tools & Toolsets — 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 — 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 open standard.
- Persistent Memory — Bounded, curated memory that persists across sessions. Hermes remembers your preferences, projects, environment, and things it has learned via
MEMORY.mdandUSER.md. - Context Files — 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 — 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 — Hermes automatically snapshots your working directory before making file changes, giving you a safety net to roll back with
/rollbackif something goes wrong.
Automation
- Scheduled Tasks (Cron) — 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 — The
delegate_tasktool 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 — The
execute_codetool 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 — Run custom code at key lifecycle points. Gateway hooks handle logging, alerts, and webhooks; plugin hooks handle tool interception, metrics, and guardrails.
- Batch Processing — 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 — 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 — 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 — 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 — 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 — 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 — 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 — 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 — 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 — Automatic failover to backup LLM providers when your primary model encounters errors, including independent fallback for auxiliary tasks like vision and compression.
- Credential Pools — Distribute API calls across multiple keys for the same provider. Automatic rotation on rate limits or failures.
- 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 — Plug in external memory backends (RetainDB, ByteRover, plus Honcho, Hindsight, Supermemory, Mem0, OpenViking and Holographic from the plugin catalog) for cross-session user modeling and personalization beyond the built-in memory system.
- API Server — 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) — 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 — 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 — Fully customizable agent personality.
SOUL.mdis the primary identity file — the first thing in the system prompt — and you can swap in built-in or custom/personalitypresets per session. - Skins & Themes — Customize the CLI's visual presentation: banner colors, spinner faces and verbs, response-box labels, branding text, and the tool activity prefix.
- Plugins — 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 pluginsinteractive UI.
User Guide Messaging Index (`website/docs/user-guide/messaging/index.md`)
sidebar_position: 1 title: "Messaging Gateway"
Messaging Gateway
Chat with Hermes from Telegram, Discord, Slack, WhatsApp, Signal, SMS, Email, Home Assistant, Mattermost, Matrix, DingTalk, Feishu/Lark, WeCom, Weixin, BlueBubbles (iMessage), QQ, Yuanbao, Microsoft Teams, LINE, ntfy, or your browser. The gateway is a single background process that connects to all your configured platforms, handles sessions, runs cron jobs, and delivers voice messages.
For the full voice feature set — including CLI microphone mode, spoken replies in messaging, and Discord voice-channel conversations — see Voice Mode and Use Voice Mode with Hermes.
:::tip
Bots need both a model provider and tool providers (TTS, web). A Nous Portal subscription bundles all of them.
:::
Messaging status in Desktop and the dashboard
Messaging status belongs to the selected profile on the selected machine. Credentials
saved by hermes gateway setup can enable a credential-based platform without aplatforms entry in config.yaml; an explicit platforms.<name>.enabled: false
still disables it. A different profile never inherits the server process's credentials.
Platforms without required credential fields are not enabled merely because that list
is empty.
Naming the server's own profile explicitly (for example profile=default on a
default-profile server) gives the same status as an unscoped request. Saved means
credentials are stored, not that the messaging gateway is running or the platform is
connected. An enabled platform can correctly show Messaging gateway stopped.
Platform Comparison
| Platform | Voice | Images | Files | Threads | Reactions | Typing | Streaming |
|---|---|---|---|---|---|---|---|
| Telegram | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ |
| Discord | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Slack | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Google Chat | — | ✅ | ✅ | ✅ | — | ✅ | — |
| — | ✅ | ✅ | — | — | ✅ | ✅ | |
| WhatsApp Cloud API | ✅ | ✅ | ✅ | — | — | ✅ | — |
| Signal | — | ✅ | ✅ | — | — | ✅ | — |
| SMS | — | — | — | — | — | — | — |
| — | ✅ | ✅ | ✅ | — | — | — | |
| Home Assistant (plugin) | — | — | — | — | — | — | — |
| Mattermost | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ |
| Matrix | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| DingTalk | — | ✅ | ✅ | — | ✅ | — | ✅ |
| Feishu/Lark | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| WeCom | ✅ | ✅ | ✅ | — | — | — | — |
| WeCom Callback | — | — | — | — | — | — | — |
| Weixin | ✅ | ✅ | ✅ | — | — | ✅ | — |
| BlueBubbles | — | ✅ | ✅ | — | ✅ | ✅ | — |
| Photon (iMessage) | ✅ | ✅ | ✅ | — | ✅ | ✅ | — |
| ✅ | ✅ | ✅ | — | — | ✅ | — | |
| Yuanbao | ✅ | ✅ | ✅ | — | — | ✅ | ✅ |
| Microsoft Teams | — | ✅ | — | ✅ | — | ✅ | — |
| LINE | — | ✅ | ✅ | — | — | ✅ | — |
| ntfy | — | — | — | — | — | — | — |
| Raft | — | — | — | — | — | — | — |
| IRC | — | — | — | — | — | — | — |
| Buzz | — | ✅ | — | ✅ | — | — | — |
| SimpleX | ✅ | ✅ | ✅ | — | — | ✅ | — |
Voice = TTS audio replies and/or voice message transcription. Images = send/receive images. Files = send/receive file attachments. Threads = threaded conversations. Reactions = emoji reactions on messages. Typing = typing indicator while processing. Streaming = progressive message updates via editing.
:::note Hermes Relay
Hermes Relay (experimental) is not a chat platform itself — it is a connector system that fronts platforms like Discord, Telegram, Slack, and WhatsApp through an external connector that owns the platform credentials. Capabilities (media, native approval/clarify prompts, reactions, threads, typing, streaming) are negotiated per connector at handshake rather than fixed in the table above.
:::
Architecture
flowchart TB
subgraph Gateway["Hermes Gateway"]
subgraph Adapters["Platform adapters"]
tg[Telegram]
dc[Discord]
wa[WhatsApp]
sl[Slack]
gc[Google Chat]
sig[Signal]
sms[SMS]
em[Email]
ha[Home Assistant]
mm[Mattermost]
mx[Matrix]
dt[DingTalk]
fs[Feishu/Lark]
wc[WeCom]
wcb[WeCom Callback]
wx[Weixin]
bb[BlueBubbles]
qq[QQ]
yb[Yuanbao]
ms[Microsoft Teams]
api["API Server<br/>(OpenAI-compatible)"]
wh[Webhooks]
end
store["Session store<br/>per chat"]
agent["AIAgent<br/>run_agent.py"]
cron["Cron scheduler<br/>ticks every 60s"]
end
tg --> store
dc --> store
wa --> store
sl --> store
gc --> store
sig --> store
sms --> store
em --> store
ha --> store
mm --> store
mx --> store
dt --> store
fs --> store
wc --> store
wcb --> store
wx --> store
bb --> store
qq --> store
yb --> store
ms --> store
api --> store
wh --> store
store --> agent
cron --> storeEach platform adapter receives messages, routes them through a per-chat session store, and dispatches them to the AIAgent for processing. The gateway also runs the cron scheduler, ticking every 60 seconds to execute any due jobs.
Intentional Silence Tokens
For group chats, hooks, and automation flows, Hermes supports explicit silence tokens. If the agent's final response is exactly one supported token, the gateway suppresses outbound delivery and sends nothing to the chat.
Supported tokens:
[SILENT]SILENTNO_REPLYNO REPLY[静默]/静默and[沉默]/沉默— the Chinese renderings a model produces when it translates the sentinel instead of emitting it literally
Whitespace and case are normalized, but the whole final response must be the token. A sentence like "Use [SILENT] when nothing changed" is delivered normally.
Silence is a delivery decision only. Hermes keeps the assistant silence turn in the session transcript, so the conversation still alternates normally:
user: side-channel chatter
assistant: [SILENT] # stored, not delivered
user: next messageFailed turns still surface as errors; Hermes does not hide failures just because the text resembles a silence token.
On a message from a person, a bare silence token is replaced by a short notice, because a message that needed a reply must not vanish. Internal wakes such as background-process notifications may stay silent, and so may a message the platform adapter reports as not addressed to the bot. Slack reports this for messages that open by @mentioning someone else and for unmentioned top-level messages that start a new thread in a free-response channel; other platforms always get the notice.
Quick Setup
The easiest way to configure messaging platforms is the interactive wizard:
hermes gateway setup # Interactive setup for all messaging platformsThis walks you through configuring each platform with arrow-key selection, shows which platforms are already configured, and offers to start/restart the gateway when done.
Gateway Commands
hermes gateway # Run in foreground
hermes gateway setup # Configure messaging platforms interactively
hermes gateway install # Install as a user service (Linux) / launchd service (macOS)
sudo hermes gateway install --system # Linux only: install a boot-time system service
hermes gateway start # Start the default service
hermes gateway stop # Stop the default service
hermes gateway status # Check default service status
hermes gateway status --system # Linux only: inspect the system service explicitlyStack dump on demand (`SIGUSR2`)
On Linux and macOS, kill -USR2 <gateway pid> appends a dump of every thread's
stack to ~/.hermes/logs/gateway_faulthandler.log and the gateway keeps
running — use it to see what a stalled or misbehaving gateway is doing without
restarting it.
Built-in event-loop liveness watchdog
On every platform the gateway runs an out-of-loop watchdog thread that probes
the asyncio loop (gateway.loop_watchdog_probe_interval_s, default 30 s). When
the loop stops dispatching for gateway.loop_watchdog_max_strikes consecutive
probes (default 3), housekeeping, the cron scheduler and the embedded kanban
dispatcher have all frozen with it, so the watchdog dumps every thread's stack
to the log, stamps gateway_state.json with gateway_state: degraded andexit_reason: loop_liveness_watchdog, and exits with code 75 so the service
supervisor restarts the process. hermes gateway status renders that record as⚠ Gateway exited degraded: event loop stopped dispatching … until a new
gateway process overwrites it, and the dashboard's gateway badge shows
Degraded with the same reason. Set gateway.loop_watchdog: false inconfig.yaml to disable the watchdog.
Housekeeping also re-stamps gateway_state.json's updated_at every tick
(60 s), so it doubles as a heartbeat: when the process is still alive but that
stamp is more than 120 s old, hermes gateway status prints⚠ Gateway heartbeat stale: housekeeping has not refreshed gateway_state.json for N s … and the dashboard badge reads Heartbeat stale — the "looks
running but nothing is scheduled" case. Restart the gateway.
Optional Linux event-loop watchdog
A systemd-managed gateway can opt into process recovery when Python's asyncio
event loop stops receiving scheduling time. This covers whole-process stalls
that also prevent platform-specific liveness tasks from running:
gateway:
systemd_watchdog_seconds: 120Regenerate the service unit after changing this setting:
hermes gateway install --forceA positive value makes the generated unit use Type=notify,NotifyAccess=main, and the matching WatchdogSec. Hermes sends heartbeats
only while its event loop is making timely progress; systemd restarts the
process when they stop. The default 0 keeps the existing Type=simple
behavior. This setting is Linux/systemd-only and does not treat an ordinary
platform network disconnect as an event-loop failure.
Chat Commands (Inside Messaging)
| Command | Description |
|---|---|
/new or /reset |
Start a fresh conversation |
/model [provider:model] |
Show or change the model (supports provider:model syntax) |
/personality [name] |
Set a personality (none to reset) |
/retry |
Retry the last message |
/undo |
Remove the last exchange |
/status |
Show session info |
/whoami |
Show your slash command access on this scope (admin / user / unrestricted) |
/stop |
Stop the running agent |
/approve |
Approve a pending dangerous command |
/deny |
Reject a pending dangerous command |
/sethome |
Set this chat as the home channel |
/compress |
Manually compress conversation context |
/title [name] |
Set or show the session title |
/resume [name] |
Resume a previously named session |
/sessions [all] [search <query>] |
List previous sessions; search <query> filters by title or id |
/usage |
Show token usage for this session (/usage reset [--force] redeems a banked Codex limit reset) |
/insights [days] |
Show usage insights and analytics |
/reasoning [level|show|hide] |
Change reasoning effort or toggle reasoning display |
/voice [on|off|tts|join|leave|status] |
Control messaging voice replies and Discord voice-channel behavior |
/rollback [number] |
List or restore filesystem checkpoints |
/bg |
Run a prompt in a separate background session |
/btw <question> |
Ask a side question about the current conversation without interrupting it |
/reload-mcp |
Reload MCP servers from config |
/update |
Update Hermes Agent to the latest version |
/help |
Show available commands |
/<skill-name> |
Invoke any installed skill |
Session Management
Session Persistence
Sessions persist across messages until they reset. The agent remembers your conversation context.
Finding Past Sessions (`/sessions`)
/sessions lists your previous sessions for the current chat — including the one you're in now, marked (current) — and /sessions <name> resumes one (shorthand for /resume). When the list grows long, /sessions search <query> (alias find) filters by title or session-id match, ordered by most recently active. Cross-origin listing with /sessions all is admin-only — regular users get a notice explaining the list stayed chat-scoped, and only ever see sessions from their own chat origin.
Persistent `/model` Overrides
A /model switch in a gateway chat applies to that session and now survives gateway restarts: the model/provider choice is persisted to the session store and rehydrated on first use after a restart (credentials are re-resolved at load time and never written to disk). /new (or /reset) clears the override, and /model <name> --global writes it through to config.yaml instead. /model <name> --once applies for a single turn only.
Delivery Reliability
Final agent responses are recorded in a durable delivery ledger
(state.db) around each platform send. If the gateway crashes or restarts
between producing a response and the platform confirming receipt, the next
boot redelivers the stored response instead of losing it — or re-running the
whole turn. The ledger lives in the home the gateway was started from; a
multiplexed gateway keeps every served profile's replies there too.
Semantics are honest at-least-once:
- A response whose send never started is redelivered as-is.
- A response that was mid-send when the gateway died (the platform may or
may not have received it), including a redelivery an earlier boot was
still sending, is redelivered with a visible
"♻️ Recovered reply — … may be a duplicate" prefix. Ambiguity is labeled,
never silently resent. - A final send refused by flood control (such as Telegram rate limits) is retried automatically
after the recorded penalty expires, without requiring a reconnect or restart.
A restart during the penalty adopts the stored reply without spending a retry
attempt or re-running the agent. Retries retain the original bot profile, chat
and thread. A rate-limit recovery prefix warns that earlier chunks may already
have arrived; the ledger cannot infer partial delivery from message length. - Any other rejected final send (a platform 5xx, an unclassified error) is retried the same
way after a growing backoff (30 s, then 2 min); the last budgeted attempt is left for the
next gateway start, so an outage that outlasts the timer never strands the reply. A
permanently unreachable chat (blocked bot, deleted group) is not retried. - Redelivery is bounded: 3 attempts, 24-hour freshness, then the row is
abandoned. Delivered rows are pruned after 7 days.
Disable with gateway.delivery_ledger: false in config.yaml (restores the
old behavior: in-flight responses are lost on crash).
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.
Core ignores legacy session_reset settings, reset-policy overrides and reset-timer
environment variables. If your config still sets session_reset.mode to idle, daily
or both, gateway startup and hermes doctor warn about it. To keep time-based resets,
install the catalog plugin that reads the same block unchanged:hermes plugins install hermes-session-reset-policy. 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.
Per-Channel Model & System Prompt Overrides
Different channels can run different models and personas from a single gateway — e.g. a cheap fast model in #daily and a frontier model with a specialist prompt in #dev. Configure channel_overrides under the platform in ~/.hermes/config.yaml:
platforms:
discord:
enabled: true
channel_overrides:
"123456789012345678": # channel/thread id
model: anthropic/claude-sonnet-4.6
provider: anthropic
system_prompt: "You are the #dev channel code-review specialist."
"987654321098765432":
model: openai/gpt-5-miniDetails:
- All three keys are optional — set only
model, onlysystem_prompt, or any combination. Unset fields fall back to the global defaults. - Lookup order is exact channel/thread id first, then the parent channel/forum id — so Discord threads inherit their parent channel's override automatically.
- Resolution priority for the model is: session
/modeloverride →channel_overrides→ global config. A user running/modelin a chat still wins over the channel default. - The
system_promptoverride replaces the global gateway prompt for that channel (it is ephemeral — injected per turn, not stored in history).
Security
By default, the gateway denies all users who are not in an allowlist or paired via DM. This is the safe default for a bot with terminal access.
# Restrict to specific users (recommended):
TELEGRAM_ALLOWED_USERS=123456789,987654321
DISCORD_ALLOWED_USERS=123456789012345678
SIGNAL_ALLOWED_USERS=+155****4567,+155****6543
SMS_ALLOWED_USERS=+155****4567,+155****6543
[email protected],[email protected]
MATTERMOST_ALLOWED_USERS=3uo8dkh1p7g1mfk49ear5fzs5c
MATRIX_ALLOWED_USERS=@alice:matrix.org
DINGTALK_ALLOWED_USERS=user-id-1
FEISHU_ALLOWED_USERS=ou_xxxxxxxx,ou_yyyyyyyy
WECOM_ALLOWED_USERS=user-id-1,user-id-2
WECOM_CALLBACK_ALLOWED_USERS=user-id-1,user-id-2
TEAMS_ALLOWED_USERS=aad-object-id-1,aad-object-id-2
# Or allow
GATEWAY_ALLOWED_USERS=123456789,987654321
# Or explicitly allow all users (NOT recommended for bots with terminal access):
GATEWAY_ALLOW_ALL_USERS=trueDM Pairing (Alternative to Allowlists)
Instead of manually configuring user IDs, unknown users receive a one-time pairing code when they DM the bot. Email is the exception: unknown email senders are ignored unless email pairing is explicitly enabled.
# The user sees: "Pairing code: XKGH5N7P"
# You approve them with:
hermes pairing approve telegram XKGH5N7P
# Other pairing commands:
hermes pairing list # View pending + approved users
hermes pairing revoke telegram 123456789 # Remove accessPairing codes expire after 1 hour, are rate-limited, and use cryptographic randomness.
Admins vs Regular Users
Allowlists answer "can this person reach the bot at all?" The admin / user split answers "now that they're in, what are they allowed to do?"
Every allowed user falls into one of two tiers per scope (DM vs group/channel):
- Admin — full access. Can run every registered slash command (built-in + plugin) and use every gated capability.
- Regular user — restricted access. Can chat with the agent normally, but can only run the slash commands you explicitly enable. The always-allowed floor is
/helpand/whoami.
The tiers are configured per platform and per scope. DM admin status does not imply group/channel admin status — each scope has its own admin list.
What the tiers gate today: slash commands. The split runs through the live command registry, so it covers built-ins and plugin-registered commands without per-feature wiring. Plain chat is not affected — non-admins can still talk to the agent.
What may be gated in the future: more capability surfaces (tool access, model switching, expensive operations) will hang off the same admin / user distinction as we add them. Configuring the split now means those future restrictions land cleanly without you having to re-model who's an admin.
Configuration
gateway:
platforms:
discord:
extra:
allow_from: ["111", "222", "333"]
allow_admin_from: ["111"] # admins → all slash commands
user_allowed_commands: [status, model] # what non-admins may run
# Optional: separate group/channel scope
group_allow_admin_from: ["111"]
group_user_allowed_commands: [status]Backward compat: if allow_admin_from is not set for a scope, the tier split is disabled for that scope and every allowed user has full access. Existing installs keep working with no changes — opt in when you want the distinction.
Inspecting your access
Use /whoami from any platform to see the active scope, your tier (admin / user / unrestricted), and which slash commands you can run. When an admin list is configured, /help and /commands show a non-admin only the commands they can actually run (/help, /whoami, plus user_allowed_commands); admins see the full catalog. See the Telegram and Discord pages for platform-specific examples.
Redirecting the Agent
Send a message while the agent is working to correct the active turn:
- Model generation restarts with context — reasoning already shown and visible partial text are retained as an ordinary assistant checkpoint
- Completed work stays available — prior tool calls and results remain in the turn
- Running tools finish safely — the correction is applied at the next tool-result boundary instead of killing the tool
/stopremains a hard stop — use it to cancel the active turn and foreground work
Queue vs interrupt vs steer (busy-input mode)
By default, messaging a busy agent redirects its active turn (a running foreground terminal command is moved to the background rather than killed, so your message is read immediately). Two other modes are available:
queue— follow-up messages wait and run as the next turn after the current task finishes. Each follow-up (text, voice note, video, document) gets its own turn in arrival order; only a rapid photo burst is merged into one album turn.steer— follow-up messages are injected into the current run via/steer, arriving at the agent after the next tool call. No interrupt, no new turn. Falls back toqueuebehavior if the agent hasn't started yet.
Gateway steers (including explicit /steer) and active-turn redirects carry the requesting event's available platform, chat, thread, sender, message, profile, and scope identifiers as per-message JSON context. With privacy.redact_pii: true, identifiers in this model-visible context are hashed on supported platforms, including alternate and parent identifiers; the original event identifiers remain internal for routing. Otherwise identifiers are preserved exactly. Neither mode changes the session's system prompt or chooses a fallback reply destination. The context is routing data, not authorization or a guarantee of automatic delivery.
display:
busy_input_mode: steer # or queue, or interrupt (default)
busy_ack_enabled: true # set to false to suppress the ⚡/⏳/⏩ chat reply entirely
busy_text_debounce_seconds: 0.35 # quiet window before merged busy text is delivered
busy_text_hard_cap_seconds: 1.0 # never hold merged busy text longer than thisAll four keys are read from each profile's own config.yaml, so multiplexed profiles keep independent busy policies; there is no process-environment override.
The first time you message a busy agent on any platform, Hermes appends a one-line reminder to the busy-ack explaining the knob ("💡 First-time tip — …"). The reminder fires once per install — a flag under onboarding.seen.busy_input_prompt latches it. Delete that key to see the tip again.
If you find the busy acknowledgment noisy, set display.busy_ack_enabled: false. Input handling is unchanged; only the confirmation message is hidden.
Clarify Questions (Multi-Select)
When the agent uses the clarify tool to ask you a question, the gateway renders the choices as a numbered prompt (or native buttons on platforms that support them). Clarify supports multi-select questions too — the agent can let you pick several options at once:
- Messaging platforms — the prompt says "Multiple selections allowed"; reply with the numbers separated by commas or spaces (e.g.
1, 3), the option text, or your own free-form answer. - Classic CLI / TUI — multi-select renders as checkboxes: Space toggles an option, Enter submits the selection.
Single-select prompts behave as before: pick one option by number, button, or text, or type your own answer via the "Other" path.
Tool Progress Notifications
Control how much tool activity is displayed in ~/.hermes/config.yaml:
display:
tool_progress: all # off | new | all | verbose | log
tool_progress_command: false # set to true to enable /verbose in messaging
# How progress is grouped on platforms that support message editing:
# accumulate (default) — edit one bubble in place as tools run
# separate — send one message per tool (pre-v0.9 style; noisier)
# Only applies where tool_progress is already enabled.
tool_progress_grouping: accumulate # accumulate | separate`log` mode — audit file instead of chat messages
Setting display.tool_progress: log sends no progress bubbles to chat. Instead, each tool call is appended as a line to ~/.hermes/logs/tool_calls.log — a rotating audit file (5 MB × 3 backups) run through the same secret-redacting formatter as regular logs, so credentials never land on disk. Use it when you want a full tool-call trail without any chat noise.
Configurable status phrases
Long-running gateway status lines ("still working…"-style heartbeats) draw from a phrase catalog. Built-in defaults ship in gateway/assets/status_phrases.yaml; you can add your own with profile-portable files under HERMES_HOME:
~/.hermes/status_phrases.yamlor any*.yamlin~/.hermes/status_phrases/(conventional paths, auto-loaded), or- point config at a relative path:
display:
status_phrases:
path: status_phrases/whatsapp.yaml # relative to HERMES_HOME
mode: append # append (default) or replacePhrase files map a surface (status, generic) to a list of strings (max 80 phrases per surface, 160 chars each). Absolute paths and .. escapes are ignored so config stays profile-portable. Only your configured phrase strings are used — raw tool arguments, commands, and reasoning text are never interpolated into a status phrase.
Message timestamps in model context
Off by default. When enabled, Hermes prepends a human-readable timestamp
(e.g. [Tue 2026-04-28 13:40:53 CEST]) onto each user message in the
model's context so the agent knows when messages were sent — useful for
temporal reasoning ("you asked this morning…", noticing a long gap). It is
not added to assistant messages or the system prompt.
gateway:
message_timestamps:
enabled: false # set true to show send-times to the modelPersisted transcripts always stay clean — the timestamp is stored as message
metadata regardless of this toggle, so enabling it later also surfaces
send-times for past messages, and replay never accumulates duplicate prefixes.
When enabled, the bot sends status messages as it works:
💻 `ls -la`...
🔍 web_search...
📄 web_extract...
🐍 execute_code...Background Sessions
Run a prompt in a separate background session so the agent works on it independently while your main chat stays responsive:
/bg Check all servers in the cluster and report any that are downHermes confirms immediately:
🔄 Background task started: "Check all servers in the cluster..."
Task ID: bg_143022_a1b2c3How It Works
Each /bg prompt spawns a separate agent instance that runs asynchronously:
- Isolated session — the background agent has its own session with its own conversation history. It has no knowledge of your current chat context and receives only the prompt you provide.
- Same configuration — inherits your model, provider, toolsets, reasoning settings, and provider routing from the current gateway setup.
- Non-blocking — your main chat stays fully interactive. Send messages, run other commands, or start more background tasks while it works.
- Result delivery — when the task finishes, the result is sent back to the same chat or channel where you issued the command, prefixed with "✅ Background task complete". If it fails, you'll see "❌ Background task failed" with the error.
Background Process Notifications
When the agent running a background session uses terminal(background=true) to start long-running processes (servers, builds, etc.), the gateway can push status updates to your chat. Control this with display.background_process_notifications in ~/.hermes/config.yaml:
display:
background_process_notifications: concise # concise | all | result | error | off| Mode | What you receive |
|---|---|
concise |
One-line status message on completion; failures append a short output tail (default) |
all |
Running-output updates and the final status message with the output tail |
result |
Only the final status message with the output tail (regardless of exit code) |
error |
Only the final status message with the output tail when the exit code is non-zero |
off |
No process watcher messages at all. Also honored by the CLI, TUI and Desktop: background-process completions and heartbeats no longer wake the agent (subagent results still do) |
You can also set this via environment variable:
HERMES_BACKGROUND_NOTIFICATIONS=resultWith terminal(background=true, notify_on_complete=true) the finished process starts a new agent turn and the agent reports the result itself, so no separate status line is sent. The exception is a process that finishes while the turn that launched it is still running: the completion is queued as the agent's next turn and you get the one-line concise status right away (unless the mode is off, or error with a zero exit code), instead of silence until that turn ends.
Use Cases
- Server monitoring — "/bg Check the health of all services and alert me if anything is down"
- Long builds — "/bg Build and deploy the staging environment" while you continue chatting
- Research tasks — "/bg Research competitor pricing and summarize in a table"
- File operations — "/bg Organize the photos in ~/Downloads by date into folders"
:::tip
Background tasks on messaging platforms are fire-and-forget — you don't need to wait or check on them. Results arrive in the same chat automatically when the task finishes.
:::
Service Management
Linux (systemd)
hermes gateway install # Install as user service
hermes gateway start # Start the service
hermes gateway stop # Stop the service
hermes gateway status # Check status
journalctl --user -u hermes-gateway -f # View logs
# Enable lingering (keeps running after logout)
sudo loginctl enable-linger $USER
# Or install a boot-time system service that still runs as your user
sudo hermes gateway install --system
sudo hermes gateway start --system
sudo hermes gateway status --system
journalctl -u hermes-gateway -fUse the user service on laptops and dev boxes. Use the system service on VPS or headless hosts that should come back at boot without relying on systemd linger.
:::danger Don't add a custom ExecStopPost kill drop-in
The unit Hermes installs already shuts the gateway down cleanly with KillMode=mixed + KillSignal=SIGTERM, and uses Restart=always with RestartForceExitStatus so updates and /restart respawn correctly. Do not add a systemd drop-in such as ExecStopPost=/bin/kill -9 $MAINPID — ExecStopPost fires on every stop, including clean restarts, so it SIGKILLs the freshly spawned instance before it stabilizes and Restart=always immediately respawns it. The result is an infinite restart loop (and, on Telegram, a flood of restart messages). If you've added such a drop-in, remove it: systemctl --user edit hermes-gateway (or sudo systemctl edit hermes-gateway for a system service) and delete the ExecStopPost line, then systemctl --user daemon-reload.
:::
Direct `systemctl restart` / `stop` exits cleanly
The installed unit declares ExecStop= to record a planned-stop marker for $MAINPID before SIGTERM is delivered, so stopping or restarting the service directly is classified as intentional: the gateway drains, persists gateway_state=stopped, and exits 0 — the journal shows a clean stop/start with no Failed with result exit-code line.
systemctl --user restart hermes-gateway # or: sudo systemctl restart hermes-gatewayPrefer hermes gateway restart when in-flight agent turns matter: it asks the gateway to drain first (SIGUSR1, honoring the restart wait budget) and waits for the replacement, while a raw systemctl restart stops the current process on systemd's schedule. After updating Hermes, run hermes gateway restart once so the running service picks up the regenerated unit that contains the ExecStop= line (hermes gateway status warns while the installed unit is outdated).
The installed unit also maps systemctl reload hermes-gateway to SIGUSR1. For Hermes, reload therefore means a graceful drain, process exit, and supervisor relaunch; it is not an in-process configuration reload. Use hermes gateway restart when you want the CLI to wait for and verify the replacement process.
:::tip Headless VMs: user service + linger avoids root prompts
A system service needs root for every restart — including the automatic gateway restart at the end of hermes update. When hermes update runs as a non-root user, it tries passwordless sudo systemctl; if that's unavailable, it skips the restart and prints the manual sudo systemctl restart hermes-gateway command (it never blocks on an interactive password prompt).
For a headless VM you never log into, a user service with lingering enabled gives you the same start-at-boot behavior with zero root involvement:
hermes gateway install # user service
sudo loginctl enable-linger $USER # one-time: start at boot, survive logoutAfter that, hermes update can restart the gateway without any privileges. If you prefer to keep the system service, either run updates with sudo hermes update, or grant the service account passwordless sudo for systemctl, e.g. in sudo visudo -f /etc/sudoers.d/hermes-gateway:
hermes ALL=(root) NOPASSWD: /usr/bin/systemctl --no-ask-password reset-failed hermes-gateway*, /usr/bin/systemctl --no-ask-password start hermes-gateway*, /usr/bin/systemctl --no-ask-password restart hermes-gateway*:::
Avoid keeping both the user and system gateway units installed at once unless you really mean to. Hermes will warn if it detects both because start/stop/status behavior gets ambiguous.
An installed unit (or launchd plist) belongs to the HERMES_HOME it pins. A gateway started from a different home, such as a test or scratch home that happens to resolve to the same service name, never rewrites it: gateway run, start, restart and install --force refuse with a message naming both homes and leave the file alone. To deliberately point an existing service at the current home, run hermes gateway install --force-unit-path.
:::note Inside a container, only the system scope is offeredhermes gateway install (and the hermes gateway setup wizard) refuse to install a user service when Hermes detects it is running inside a container. A user unit lands in ~/.config/systemd/user, and when that home is bind-mounted from the host (podman/distrobox), the host's own systemd --user enables and starts the same unit — a second gateway polling the same bot token. Run the gateway as the container's main process (hermes gateway run, with a container restart policy), or in a systemd container (systemd as PID 1) install the isolated system scope: sudo hermes gateway install --system --run-as-user .
:::
:::info Multiple installations
If you run multiple Hermes installations on the same machine (with different HERMES_HOME directories), each gets its own systemd service name. The default ~/.hermes uses hermes-gateway; other installations use hermes-gateway-<hash>. The hermes gateway commands automatically target the correct service for your current HERMES_HOME.
:::
macOS (launchd)
hermes gateway install # Install as launchd agent
hermes gateway start # Start the service
hermes gateway stop # Stop the service
hermes gateway status # Check status
tail -f ~/.hermes/logs/gateway.log # View logsThe generated plist lives at ~/Library/LaunchAgents/ai.hermes.gateway.plist. It includes three environment variables:
- PATH — your full shell PATH at install time, with the venv
bin/andnode_modules/.binprepended. This ensures user-installed tools (Node.js, ffmpeg, etc.) are available to gateway subprocesses like the WhatsApp bridge. - VIRTUAL_ENV — points to the Python virtualenv so tools can resolve packages correctly.
- HERMES_HOME — scopes the gateway to your Hermes installation.
:::tip PATH changes after install
launchd plists are static — if you install new tools (e.g. a new Node.js version via nvm, or ffmpeg via Homebrew) after setting up the gateway, run hermes gateway install again to capture the updated PATH. The gateway will detect the stale plist and reload automatically.
:::
:::info Installing without starting
The plist sets RunAtLoad, so loading it starts the gateway. hermes gateway install --no-start-now, like answering No to "Start the gateway now?" in hermes gateway setup, writes the plist without loading it: the gateway starts at your next login, or when you run hermes gateway start. A gateway that launchd is already running is reloaded onto the new plist, not stopped.
:::
:::info Local Network access (LAN devices fail with "No route to host")
macOS Local Network Privacy attributes a socket to the executable launchd spawned for the job. A bare venv Python has no application identity, so a launchd-run gateway could not reach LAN hosts (Home Assistant, local model servers) — every connect failed with errno 65 No route to host while the same URL worked from Terminal, and no prompt was ever shown to grant it. The generated plist therefore runs the gateway through /usr/bin/osascript; a JXA system() call starts the gateway without an interactive event-polling loop, and macOS treats its children as osascript's own — an Apple platform binary, exempt from the check. ps shows osascript → stderr_timestamp → gateway run; stop/restart/KeepAlive behave exactly as before. A plist installed by an older Hermes is refreshed by hermes gateway install (or on the next hermes gateway start).
:::
:::tip Picking up new credentials after hermes auth add / hermes auth reset
Agents run as threads inside the one gateway process; the only child processes are tool subprocesses (terminal commands, browsers), which never hold provider credentials. A running gateway also re-reads the openai-codex login it seeded from auth.json the next time its pool selects that entry after it had gone exhausted or dead (entries added with hermes auth add openai-codex are independent accounts and are not resynced). When you want every session on the fresh login at once, restart the gateway — but prefer the drain-aware path over a bare kill:
hermes gateway restartasks the gateway (SIGUSR1) to refuse new turns, waits up toagent.restart_after_turn_timeout(default 1800 s) for in-flight turns to finish, exits, and lets launchd'sKeepAliverelaunch it; the new process readsauth.jsonfrom scratch.launchctl kickstart -k gui/$UID/ai.hermes.gatewaysends SIGTERM instead: the gateway interrupts in-flight chat turns afteragent.restart_drain_timeout(default0— immediately; the user is told and the turn resumes on their next message), gives cron runs and api_server (/v1) runsagent.cron_drain_timeout(default 30 s), kills tool subprocesses and exits, then launchd relaunches it. Nothing from the old process survives, so a session that still fails with401after the relaunch is talking to a different gateway process — checkhermes gateway status(andlaunchctl list | grep hermes) for a second PID, such as a manually startedhermes gateway run, and stop that one too.
:::
:::info Multiple installations
Like the Linux systemd service, each HERMES_HOME directory gets its own launchd label. The default ~/.hermes uses ai.hermes.gateway; other installations use ai.hermes.gateway-<suffix>.
:::
Windows (Task Scheduler)
hermes gateway install # Register the Hermes_Gateway Scheduled Task (runs at logon)
hermes gateway start # Start the gateway hidden, without a console window
hermes gateway stop # Drain and stop the service
hermes gateway status # Check status, including registration driftThe Scheduled Task runs wscript.exe on a generated .vbs launcher under %USERPROFILE%\.hermes\gateway-service\. The launcher starts python.exe -m hermes_cli.main gateway run with a hidden window and **exits immediately** — by design: wscript.exe has no console, so at logon it never receives the CTRL_CLOSE_EVENT that kills a cmd.exe-hosted gateway, and the gateway inherits one hidden console instead of every subprocess flashing its own (see hermes_cli/gateway_windows.py::_build_gateway_vbs_script).
:::warning RestartOnFailure covers the launcher, not the gateway
Because the launcher returns as soon as the gateway is spawned, Task Scheduler only ever sees the launcher's exit code. The <RestartOnFailure> policy in the registered task therefore fires only when wscript.exe itself fails to start the gateway — it does not restart a gateway that crashes or is killed later. Gateway auto-restart on Windows relies on the gateway's own in-process restart path (/restart, updates, and the hermes gateway restart command); a gateway killed from outside stays down until hermes gateway start or schtasks /Run /TN <task>.
:::
hermes gateway install writes the task from the current template; a task registered by an older build would otherwise keep its old settings (no RestartOnFailure, no logon Delay, an older launcher command line) indefinitely. hermes gateway status compares the registered task with the current template and warns when it predates it:
⚠ Scheduled Task registration predates the current template (missing: RestartOnFailure, LogonTrigger Delay; version 1.3 vs 1.4)
Repair: hermes gateway start (or: hermes gateway install)hermes gateway start and hermes update run the same comparison and re-register a drifted task from the current template automatically (like the systemd unit refresh on Linux); when schtasks refuses without elevation, re-run hermes gateway install, which can request administrator approval. The check is silent when the task cannot be queried, and it only inspects a few settings Hermes owns (task version, RestartOnFailure, the logon trigger delay and the launcher arguments), so deliberate local edits elsewhere in the task are not flagged.
Platform-Specific Toolsets
Each platform has its own toolset:
| Platform | Toolset | Capabilities |
|---|---|---|
| CLI | hermes-cli |
Full access |
| Telegram | hermes-telegram |
Full tools including terminal |
| Discord | hermes-discord |
Full tools including terminal |
hermes-whatsapp |
Full tools including terminal | |
| WhatsApp Cloud API | hermes-whatsapp |
Full tools including terminal (shares toolset with the Baileys bridge) |
| Slack | hermes-slack |
Full tools including terminal |
| Google Chat | hermes-google_chat |
Full tools including terminal |
| Signal | hermes-signal |
Full tools including terminal |
| SMS | hermes-sms |
Full tools including terminal |
hermes-email |
Full tools including terminal | |
| Home Assistant (plugin) | hermes-homeassistant |
Full tools + HA device control (ha_list_entities, ha_get_state, ha_call_service, ha_list_services) from the homeassistant catalog plugin |
| Mattermost | hermes-mattermost |
Full tools including terminal |
| Matrix | hermes-matrix |
Full tools including terminal |
| DingTalk | hermes-dingtalk |
Full tools including terminal |
| Feishu/Lark | hermes-feishu |
Full tools including terminal |
| WeCom | hermes-wecom |
Full tools including terminal |
| WeCom Callback | hermes-wecom-callback |
Full tools including terminal |
| Weixin | hermes-weixin |
Full tools including terminal |
| BlueBubbles | hermes-bluebubbles |
Full tools including terminal |
| QQBot | hermes-qqbot |
Full tools including terminal |
| Yuanbao | hermes-yuanbao |
Full tools including terminal |
| Microsoft Teams | hermes-teams |
Full tools including terminal |
| API Server | hermes-api-server |
Full tools (drops clarify, text_to_speech — programmatic access doesn't have an interactive user) |
| Webhooks | hermes-webhook |
Full tools including terminal |
| Raft | hermes-raft |
Wake-only channel; agent uses Raft CLI for message I/O |
Operating a multi-platform gateway
A gateway typically runs several adapters at once (Telegram + Discord + Slack, etc.). The sections below cover day-2 operations that span all platforms.
`/platform` command
Once the gateway is running, use the /platform slash command from any connected CLI session or chat to inspect and steer individual adapters without restarting the whole gateway:
/platform list # show all adapters and their state
/platform pause <name> # stop dispatching new messages to one adapter
/platform resume <name> # re-enable a paused adapter/platform list shows whether each adapter is running, paused (manually), or paused-by-breaker (see below). Pausing keeps the adapter loaded and its background loops alive — incoming messages are dropped on the floor, but the connection itself stays open so resume is instant.
See also the broader status summary command /platforms.
Disabling a platform whose credentials are still in `.env`
platforms.<name>.enabled: false in ~/.hermes/config.yaml is authoritative.
Credentials for that platform left in the environment (TELEGRAM_BOT_TOKEN,WEIXIN_TOKEN, HASS_TOKEN, EMAIL_*, TWILIO_ACCOUNT_SID, ...) are still
wired into the platform's config so send-only tooling keeps working, but they
no longer start the adapter:
platforms:
weixin:
enabled: false # wins over WEIXIN_TOKEN in .envEarlier releases let the mere presence of credentials re-enable twelve
platforms (Weixin, WhatsApp Cloud, Home Assistant, Email, SMS, DingTalk, Feishu,
WeCom, WeCom callback, BlueBubbles, QQ Bot, Yuanbao) regardless of that key. If
you relied on that, the gateway now logs one WARNING per affected platform at
startup so it does not just go dark:
Platform 'weixin' is explicitly disabled by platforms.weixin.enabled: false in config.yaml,
so the credentials found in the environment (WEIXIN_TOKEN, WEIXIN_ACCOUNT_ID) will NOT start
its adapter. Environment credentials no longer override an explicit disable. Remove the key
or set platforms.weixin.enabled: true to turn it back on.Omitting the enabled key entirely keeps the env-only behaviour: credentials
present → adapter starts.
Ignoring an inherited proxy (`gateway.trust_env`)
By default every platform adapter honors HTTP_PROXY / HTTPS_PROXY /NO_PROXY (and SSL_CERT_FILE) from the gateway's environment, and
auto-detects the macOS system proxy. A gateway started by a Windows Scheduled
Task or a service manager can inherit a proxy the interactive shell never
sees — a local Clash/V2Ray listener that isn't running yet — and logCannot connect to host 127.0.0.1:7890 on every poll. Turn the inherited
proxy off for all adapters at once:
gateway:
trust_env: falseExplicit per-platform proxy variables (DISCORD_PROXY, TELEGRAM_PROXY,MATRIX_PROXY, ...) are still honored. Restart the gateway after changing it.
Automatic circuit breaker
Each adapter is wrapped in a circuit breaker. Repeated retryable failures (network blips, rate-limit replies, 5xx upstream responses, websocket disconnects) cause the breaker to trip — the adapter is auto-paused, an operator notification is sent to the home channel of another live platform when one is configured, and a structured log line is emitted.
The breaker does not auto-resume — it stays open until you run /platform resume <name> manually. This is intentional: if a platform is in a sustained outage, you don't want the gateway thrashing reconnects.
Where to look when a platform is paused
When an adapter is paused, check:
- Gateway log (
~/.hermes/logs/gateway.logor the systemd / launchd unit log). Search for the platform name andcircuit breaker,paused, ordisabled. The trip event includes the failure count and the last error. /platform listoutput — shows the current state and last reason.- The provider's status page (Telegram bot API status, Discord status, etc.). The breaker tripped because the platform was unhealthy; don't try to resume until it's back.
Once upstream is healthy, /platform resume <name> clears the breaker and re-arms the adapter.
Restart notifications
When the gateway restarts (or is shut down with in-flight sessions), it can send a one-shot "the agent is back" / "the agent was interrupted" message to each platform's home channel. This is controlled per-platform by the gateway_restart_notification flag in config.yaml, which defaults to true:
gateway:
platforms:
telegram:
home_chat_id: "123456789"
gateway_restart_notification: false # opt out for this platform
discord:
home_chat_id: "987654321"
# gateway_restart_notification omitted → defaults to trueDisable it on noisy or low-priority platforms while leaving it on for your primary chat. The notification is sent once per restart, regardless of how many sessions were in flight.
Typing indicators
While the agent is processing a message, the gateway shows a live typing status on platforms that support it — a "typing…" bubble on Telegram/Discord/Signal, or the "is thinking…" assistant status on Slack. This is controlled per-platform by the typing_indicator flag in config.yaml, which defaults to true:
gateway:
platforms:
slack:
typing_indicator: false # don't show "is thinking…" on Slack
telegram:
# typing_indicator omitted → defaults to trueSet typing_indicator: false on any platform where the indicator is unwanted. Some users find Slack's "is thinking…" status noisy (it also briefly disables the compose box while shown, since it uses Slack's Assistant API). Disabling it only suppresses the indicator — message delivery and everything else is unchanged. The flag is generic, so the same key works for every platform.
Session resume across gateway restarts
When the gateway shuts down with an in-flight tool call or generation, the affected sessions are flagged as restart_interrupted. On the next startup, the gateway schedules an auto-resume for each one — the user gets a short heads-up in the chat ("Send any message after restart and I'll try to resume where you left off.") and the session picks up from the last committed turn when they reply.
Only turns that were actually in flight are resumed, and each resumes once. A chat whose turn had already finished is never answered again just because it was active shortly before a crash. If the gateway was killed after the agent finished a reply but before it was sent, the stored reply is delivered (with a "Recovered reply" notice) instead of being regenerated.
This behaviour is on by default and is logged at gateway start:
Scheduled auto-resume for N restart-interrupted session(s)No configuration is required. If you don't want the heads-up, set gateway_restart_notification: false on the platform.
Mobile-friendly progress defaults
Telegram is usually a mobile inbox, so the defaults are tuned for that surface:
tool_progressdefaults tooff— no per-tool breadcrumb stream filling up the chat.busy_ack_detaildefaults tooff— busy-state acknowledgments and long-running heartbeats stay terse (noiteration 21/60debug detail).interim_assistant_messagesstays on — real mid-turn assistant commentary (the model literally telling you what it's about to do) is signal, not noise.long_running_notificationsstays on — a single edit-in-place "⏳ Working — N min" bubble updates every few minutes so you have a heartbeat instead of staring attyping…for half an hour.
These per-platform defaults apply only while the same key is unset directly under display:. A global display.tool_progress, display.show_reasoning, display.busy_ack_detail, display.interim_assistant_messages or display.long_running_notifications applies to every platform and replaces its default. A config.yaml copied from an older cli-config.yaml.example sets all five globally, and an older first-time hermes setup wrote tool_progress: all; delete those lines to get the per-platform defaults back.
Opt out of either of the kept-on defaults or opt back into verbose progress per platform:
display:
platforms:
telegram:
# Re-enable the tool-progress stream
tool_progress: new
# Show "iteration N/M, running: tool" in heartbeats and busy acks
busy_ack_detail: true
# Or quiet them entirely
interim_assistant_messages: false
long_running_notifications: falseWarning and error notifications (opt-in suppression)
Automatic warning and error notifications are shown by default. To suppress
these notifications, enable suppress_warning_notifications globally or for
an individual surface:
display:
suppress_warning_notifications: true
platforms:
telegram:
suppress_warning_notifications: falseThis example suppresses notifications globally while keeping them visible on
Telegram. Omit the setting or use false to preserve normal delivery. Platform
overrides take precedence; null inherits. Invalid values do not enable
suppression.
The setting controls automatic engine warnings, retry/fallback diagnostics,
watchdog and database notices, cron failure notifications, Kanban failure
notifications, background/delegation diagnostics, and adapter-generated error
notices. It applies to messaging platforms, CLI/TUI presentation and API
notification presentation. Classification belongs to the producer: warning-like
text in a user request or an ordinary result is not filtered by its wording.
Suppression changes presentation, not execution. Existing logs, stored diagnostic
content, retry decisions, failure state, scheduler bookkeeping and notification
cursors remain available. A diagnostic-only internal wake (a subagent or credit
failure, a Kanban crash notice) still runs its agent turn — so the agent can act on
the failure and the session history stays consistent — and that turn is billed as
usual; only its unsolicited text, media and streaming presentation are muted. Structured
approval and clarification controls, direct command/API outcomes and requested
results are not converted into success or discarded. API failure flags, status
codes and usage remain truthful even when diagnostic text is hidden.
Cron failure_deliver still selects the destination; the destination's warning
policy determines whether an automatic failure notice is presented there.
Suppressed deliveries are settled without claiming a successful send. Already
admitted deliveries retain their delivery identity and outcome.
Policy is resolved for the owning profile and logical destination. Agent turns
use their turn policy; independent notifications and deferred deliveries evaluate
policy at their own delivery boundary. Already delivered messages are not removed.
Suppression does not fix an underlying failure or add another logging destination.
Progress bubble cleanup (opt-in)
Tool-progress messages, the "still working…" heartbeat, and status-callback bubbles can also be auto-deleted after the final response lands. Enable per-platform via display.platforms..cleanup_progress:
display:
platforms:
telegram:
cleanup_progress: true
discord:
cleanup_progress: trueDefaults to false. Only platforms whose adapter implements delete_message honor the setting (currently Telegram and Discord). Failed runs skip cleanup so the bubbles remain as breadcrumbs.
Next Steps
- Telegram Setup
- Discord Setup
- Slack Setup
- Google Chat Setup
- WhatsApp Setup
- WhatsApp Business Cloud API Setup
- Signal Setup
- SMS Setup (Twilio)
- Email Setup
- Home Assistant Integration (plugin catalog)
- Mattermost Setup
- Matrix Setup
- DingTalk Setup
- Feishu/Lark Setup
- WeCom Setup
- WeCom Callback Setup
- Weixin Setup (WeChat)
- BlueBubbles Setup (iMessage)
- Photon Setup (iMessage)
- QQBot Setup
- Yuanbao Setup
- Microsoft Teams Setup
- Teams Meetings Pipeline
- Microsoft Graph Webhook Listener
- LINE Setup
- ntfy Setup
- SimpleX Chat Setup
- Open WebUI + API Server
- Raft Setup
- IRC Setup
- Buzz Setup
- A2A (Agent-to-Agent) Setup
- Webhooks
User Guide Secrets Index (`website/docs/user-guide/secrets/index.md`)
Secrets
Hermes can pull API keys from external secret managers at process startup instead of storing them in ~/.hermes/.env. The bootstrap token for the secret manager lives in .env; every other provider key (OpenAI, Anthropic, OpenRouter, etc.) can stay in the manager and rotate centrally.
Supported:
- Bitwarden Secrets Manager —
bwsCLI, lazy-installed, free tier works. - 1Password —
op://references via the officialopCLI; service-account or desktop session auth. - Command helper — any CLI vault (
keepassxc-cli,secret-tool,pass, custom scripts) via a user-configured helper that printsKEY=VALUElines.
Multiple sources at once
You can enable more than one secret source at the same time — for example a team Bitwarden project alongside a personal vault plugin. Sources compose per env var with a deterministic precedence ladder:
- Your
.env/ shell wins by default. A source only replaces a pre-existing value when its ownoverride_existing: trueis set (Bitwarden defaults to true so central rotation works). - Mapped sources beat bulk sources. A source where you explicitly bind env vars to references (an
env:map) outranks a source that injects a whole project of secrets implicitly, regardless of ordering. - First source wins. Within the same shape, the order of the optional
secrets.sourceslist (or registration order) decides. Later claims on an already-claimed var are skipped — with a startup warning, never silently.
override_existing never lets one source overwrite a var another source already claimed, and no source can ever overwrite another source's bootstrap token (e.g. BWS_ACCESS_TOKEN).
secrets:
sources: [bitwarden] # optional explicit ordering
bitwarden:
enabled: true
project_id: "..."Every credential injected by a source is labelled with its origin — setup flows and hermes model show (from Bitwarden) next to detected keys so you always know where a value came from.
Profiles and shared vaults
Two orchestrator-level knobs make one shared vault safe across profiles:
secrets.preserve_existing— a list of env var names whose existing.env/ shell value always wins, even against a source withoverride_existing: true. Use it for per-profile platform secrets (e.g.FEISHU_APP_SECRET) that intentionally differ across profiles while everything else rotates centrally:yamlsecrets: preserve_existing: [FEISHU_APP_SECRET, TELEGRAM_BOT_TOKEN]Profile aliasing (on by default,
secrets.profile_alias: falseto disable) — when Hermes runs under a named profile, a vault secret namedFOO_(credential-shaped suffixes only:*_API_KEY,*_TOKEN,*_SECRET,*_KEY,*_PASSWORD) also hydrates the canonicalFOO. StoreTELEGRAM_BOT_TOKEN_MILLAin the shared project and themillaprofile's adapters — which read the fixed nameTELEGRAM_BOT_TOKEN— get the right value automatically. A var the vault supplies directly under its canonical name always beats an alias.
Both apply to every source — bundled and plugin — because they live in the orchestrator, not the backends.
Secrets in child processes
Terminal commands, execute_code sandboxes and no_agent cron scripts run with a sanitized environment: Hermes-managed credentials are stripped, and only variables you declare in terminal.env_passthrough (or a loaded skill's required_environment_variables) are forwarded. A declared variable is forwarded with the owning profile's value — from that profile's .env or its secret sources — even when the profile is served by a multi-profile gateway or the Desktop/dashboard backend and its secrets never entered the process environment. A profile's declared value never reaches another profile's children, and the launch profile's .env credentials are dropped from children that run for a served profile. Provider credentials cannot be declared; see Security → Credential scoping.
Adding your own backend
Third-party secret managers ship as standalone plugins, not core PRs. A backend subclasses agent.secret_sources.base.SecretSource (one required method: fetch(cfg, home_path) -> FetchResult) and registers via ctx.register_secret_source(MySource()) in the plugin's register(ctx). The orchestrator owns precedence, conflict handling, timeouts, and provenance — your source only fetches. Full guide with the contract rules, subprocess-safety helper, and conformance kit: Building a Secret Source Plugin.
The bundled set is deliberately closed (same policy as memory providers): Bitwarden and 1Password ship in-tree. Everything else — Infisical, Proton Pass, HashiCorp Vault, AWS Secrets Manager, OS keystores — belongs in plugin repos; share them in the Nous Research Discord (#plugins-skills-and-skins).
User Stories (`website/docs/user-stories.mdx`)
title: User Stories & Use Cases hide_title: true hide_table_of_contents: true
import UserStoriesCollage from '@site/src/components/UserStoriesCollage';
Apps Desktop Docs Canonical Gateway Protocol (`apps/desktop/docs/canonical-gateway-protocol.md`)
Canonical local gateway protocol
Native local connections use the ordinary gateway owner. Closing Desktop,
changing profile or evicting a viewer never transfers that owner into Electron's
child-process pool. Local ensure retains the update gate and rechecks profile
retirement after asynchronous waits. Remote SSH, URL and OAuth routing retain
their existing authentication paths; failure never creates a local replacement.
Authentication
gateway ensure --json returns a credential-free endpoint. Native main requests
private session-ticket grants with exactly profile_id, instance_id andpurpose. WebSocket grants use interactive; JSON and file HTTP requests usenative-http. Each HTTP retry mints a fresh grant and sends it only inX-Hermes-Gateway-Ticket, scoped to the endpoint's exact origin. HTTP credentials
are not stored in the renderer or attached to URLs/public connection descriptors.
Streaming /api/fs/download and its 404-only /api/fs/read-data-url fallback
use the same captured backend descriptor and session/profile selectors. Each
connection retry and fallback request obtains a new grant. Downloads do not
follow redirects or repeat the save dialog/body write after a stream failure.
The native grant remains primary-profile-only; rejected profile selectors do
not fall back to a remote credential.
Local previews and seekable hermes-media://stream playback read the native
filesystem, not gateway HTTP. Remote media remains on its existing token/OAuth
transport (/api/files/stream); remote data-URL previews use the JSON IPC API.
Native HTTP grants are never attached to arbitrary image URLs, web previews,
provider voice endpoints, or remote media requests.
Session and input identities
src/api/canonical-protocol.ts adapts only native canonical sockets. Creation
uses source gui, not cli. The foreground draft retains its request_id after
an uncertain create ACK; New chat starts a new intent. Unsupported explicit
launch settings are rejected visibly rather than silently dropped. Renderer
routing profile, terminal column count and disabled fast mode are not execution
policy overrides. This does not provide a crash-durable create-intent journal.
The canonical admission_id is distinct from the submitted input ID. Receipt
projection retains both and verifies ref.session_id against the requested
session before acknowledging the prepared input. The existing native private
atomic-file journal is still written before transport admission and retired only
after a matching accepted receipt. An unknown receipt retains it for explicit
retry. Renderer-only localStorage recovery is not native crash durability.
Image history and recovery
Canonical native image history carries image-routing hints followed by flattened[screenshot] parts. Desktop projects that complete suffix into attachment
thumbnails without rewriting stored messages or image bytes. Caption prose and
assistant text remain text; legacy @image: history keeps its existing rendering.
The prepared-image recovery affordance offers only ordinary unqueued drafts in
their original connection/profile/session. Slash invocations, legacy ambiguous
attempts and other recovery identities stay in the journal unchanged; they are
not offered as ordinary image drafts or automatically resubmitted by this view.
Controls and metadata
Live and replayed prompt projections retain prompt_id and execution generation,
including named event listeners. Stop uses the captured session generation;
approval and clarify responses resolve the exact projected prompt, never the
latest arbitrary prompt. Resume restores pending approval/clarify projections.
Canonical session.title and session.archive RPC requests becomesession.mutate with a retained request ID and expected revision. An ambiguous
failure retains that CAS payload; an explicit revision conflict requires a fresh
snapshot before retry. Existing REST metadata writers are not migrated by this
RPC adapter and do not yet provide universal revision fencing.
Cutover limits
The canonical authority currently exposes a narrower RPC set than the full
Desktop application. Unsupported model/tool/slash/session-management RPCs,
cross-window pending-input text projection,
Windows validated named-pipe bootstrap, and WSL topology require further
integration. Successful authenticated transport is not proof of a usable mounted
chat or native journal retirement. End-to-end acceptance must compose the HTTP
grant server and drive real Electron controls with disposable userData.
Apps Desktop Docs Canonical Groups (`apps/desktop/docs/canonical-groups.md`)
Gateway-owned groups in Desktop
Bot Mode lists gateway rooms separately from legacy renderer rooms. Refresh gateway groups reloads the selected connection/profile's canonical room list. Opening a room captures that exact authority; changing the foreground profile does not retarget its controls.
With groups.capabilities.driver enabled, the existing creation dialog creates a canonical group. Only same-authority local-profile members are supported; Desktop connection descriptors are not peer grants. Existing legacy rooms offer Start gateway group instead of renderer-owned execution. This starts a new room and deliberately does not replay legacy history.
The gateway owns the log and work. Desktop reads groups.state and groups.log, sends through groups.send, and stops through groups.stop. Pending actions come only from driver_status.pending_actions. Discard requires explicit confirmation that prior side effects are not undone; Retry and approvals retain the exact member/task/generation/request identity. Errors remain visible and no failed canonical mutation falls back to hidden-session submission.
This initial canonical workspace is text-only. Rename, disband, membership editing and attachments are not offered here. Room discovery is durable on the gateway rather than replicated through Desktop ui_meta. Ambiguous send retries preserve their event identity while the workspace remains mounted; native crash-safe group-send journaling is not implemented.
Developer Guide Acp Internals (`website/docs/developer-guide/acp-internals.md`)
sidebar_position: 2 title: "ACP Internals"
ACP Internals
The ACP adapter wraps Hermes' synchronous AIAgent in an async JSON-RPC stdio server.
Key implementation files:
acp_adapter/entry.pyacp_adapter/server.pyacp_adapter/session.pyacp_adapter/events.pyacp_adapter/permissions.pyacp_adapter/tools.pyacp_adapter/auth.py
Boot flow
hermes acp / hermes-acp / python -m acp_adapter
-> acp_adapter.entry.main()
-> parse --version / --check / --setup before server startup
-> load ~/.hermes/.env
-> configure stderr logging
-> construct HermesACPAgent
-> acp.run_agent(agent, use_unstable_protocol=True)Stdout is reserved for ACP JSON-RPC transport. Human-readable logs go to stderr.
Gateway terminal projection
acp_adapter/gateway_server.py waits for the completion of the exact submitted
admission. A cancelled admission returns stopReason: cancelled; a failed
admission raises an ACP request error rather than reporting end_turn. Ordinary
completion still returns end_turn. Cancellation while waiting for the model
suppresses the local Operation interrupted: waiting for model response status
message; actual partial assistant text remains visible. Terminal outcomes come
from the gateway's structured execution result, not from assistant prose or a
session-wide cancellation flag.
Major components
`HermesACPAgent`
acp_adapter/server.py implements the ACP agent protocol.
Responsibilities:
- initialize / authenticate
- new/load/resume/fork/list/cancel session methods
- prompt execution
- session model switching
- wiring sync AIAgent callbacks into ACP async notifications
`SessionManager`
acp_adapter/session.py tracks live ACP sessions.
Each session stores:
session_idagentcwdmodelhistorycancel_event
The manager is thread-safe and supports:
- create
- get
- remove
- fork
- list
- cleanup
- cwd updates
Event bridge
acp_adapter/events.py converts AIAgent callbacks into ACP session_update events.
Bridged callbacks:
tool_progress_callbackthinking_callback(currently set toNonein the ACP bridge — reasoning is forwarded throughstep_callbackinstead)step_callback
Every ACP tool call reaches a terminal status: tool.completed closes the call with its own
result (completed / failed), the step_callback prev_tools pass is only a fallback for
runtimes that never project a completion, and anything still open when the turn ends —
a denied or blocked call, an interrupted one — is marked failed before the response is
returned.
Because AIAgent runs in a worker thread while ACP I/O lives on the main event loop, the bridge uses:
asyncio.run_coroutine_threadsafe(...)Permission bridge
acp_adapter/permissions.py adapts dangerous terminal approval prompts into ACP permission requests.
Mapping:
allow_once-> Hermesonceallow_always-> Hermesalways- reject options -> Hermes
deny
Timeouts and bridge failures deny by default.
Tool rendering helpers
acp_adapter/tools.py maps Hermes tools to ACP tool kinds and builds editor-facing content.
Examples:
patch/write_file-> file diffsterminal-> shell command textread_file/search_files-> text previews- large results -> truncated text blocks for UI safety
Session lifecycle
new_session(cwd)
-> create SessionState
-> create AIAgent(platform="acp", enabled_toolsets=<_get_platform_tools(config, "acp"), as on the
gateway: platform_toolsets.acp (default hermes-acp) plus the resolver's extras
such as plugin toolsets, with its admitted MCP servers keyed mcp-<server>>,
disabled_toolsets=<agent.disabled_toolsets>)
-> bind task_id/session_id to cwd override
prompt(..., session_id)
-> extract text from ACP content blocks
-> reset cancel event
-> install callbacks + approval bridge
-> run AIAgent in ThreadPoolExecutor
-> update session history
-> emit final agent message chunkA turn that ends in a terminal failure (provider refusal, non-retryable error, exhausted
retries, interrupt before any reply) is closed by the core loop with a Hermes-authored
assistant row ("Your request was not processed…" / "This turn did not complete…") so the
durable transcript never ends on an open user row. Without it the next prompt would be
merged into the failed request and replayed. Context-overflow failures are exempt: their
repair is session rotation, not another row.
Cancelation
cancel(session_id):
- sets the session cancel event
- calls
agent.interrupt()when available - causes the prompt response to return
stop_reason="cancelled"
Forking
fork_session() deep-copies message history into a new live session, preserving conversation state while giving the fork its own session ID and cwd.
Provider/auth behavior
ACP does not implement its own auth store.
Instead it reuses Hermes' runtime resolver:
acp_adapter/auth.pyhermes_cli/runtime_provider.py
So ACP advertises and uses the currently configured Hermes provider/credentials. It also always advertises a terminal setup auth method (hermes-setup, args --setup) so first-run ACP clients can open Hermes' interactive model/provider configuration before starting a normal ACP session.
Working directory binding
ACP sessions carry an editor cwd.
The session manager binds that cwd to the ACP session ID via task-scoped terminal/file overrides, so file and terminal tools operate relative to the editor workspace.
Duplicate same-name tool calls
The event bridge tracks tool IDs FIFO per tool name, not just one ID per name. This is important for:
- parallel same-name calls
- repeated same-name calls in one step
Without FIFO queues, completion events would attach to the wrong tool invocation.
Approval callback restoration
ACP temporarily installs an approval callback on the terminal tool during prompt execution, then restores the previous callback afterward. This avoids leaving ACP session-specific approval handlers installed globally forever.
Current limitations
- ACP sessions are persisted to the shared
~/.hermes/state.db(SessionDB) and transparently restored across process restarts; they appear insession_search - non-text prompt blocks are currently ignored for request text extraction
- editor-specific UX varies by ACP client implementation
Related files
tests/acp_adapter/— ACP test suitetoolsets.py—hermes-acptoolset definitionhermes_cli/main.py—hermes acpCLI subcommandpyproject.toml—[acp]optional dependency +hermes-acpscript
Developer Guide Adding Platform Adapters (`website/docs/developer-guide/adding-platform-adapters.md`)
sidebar_position: 9
Adding a Platform Adapter
This guide covers adding a new messaging platform to the Hermes gateway. A platform adapter connects Hermes to an external messaging service (Telegram, Discord, WeCom, etc.) so users can interact with the agent through that service.
:::tip
There are two ways to add a platform:
- Plugin (recommended for community/third-party): Drop a plugin directory into
~/.hermes/plugins/— zero core code changes needed. See Plugin Path below. - Built-in: Modify 20+ files across code, config, and docs. Use the Built-in Checklist below.
:::
Architecture Overview
User ↔ Messaging Platform ↔ Platform Adapter ↔ Gateway Runner ↔ AIAgentEvery adapter extends BasePlatformAdapter from gateway/platforms/base.py and implements:
connect()— Establish connection (WebSocket, long-poll, HTTP server, etc.) (abstract)disconnect()— Clean shutdown (abstract)send()— Send a text message to a chat (abstract)send_typing()— Show typing indicator (optional override)get_chat_info()— Return chat metadata (optional override)
Inbound messages are received by the adapter and forwarded via self.handle_message(event), which the base class routes to the gateway runner.
Plugin Path (Recommended)
The plugin system lets you add a platform adapter without modifying any core Hermes code. Your plugin is a directory with two files:
~/.hermes/plugins/my-platform/
plugin.yaml # Plugin metadata
adapter.py # Adapter class + register() entry pointplugin.yaml
Plugin metadata. The requires_env and optional_env blocks auto-populate hermes config UI entries (see Surfacing Env Vars below).
name: my-platform
label: My Platform
kind: platform
version: 1.0.0
description: My custom messaging platform adapter
author: Your Name
requires_env:
- MY_PLATFORM_TOKEN # bare string works
- name: MY_PLATFORM_CHANNEL # or rich dict for better UX
description: "Channel to join"
prompt: "Channel"
password: false
optional_env:
- name: MY_PLATFORM_HOME_CHANNEL
description: "Default channel for cron delivery"
password: falseOutbound client tools: `provides_tools`
kind: platform plugins are deferred: the adapter module (and its SDK
imports) only load when a gateway, cron, or send_message path first asks the
platform registry for the platform. If your plugin also ships outbound client
tools the agent should be able to call from any session (the bundled a2a
plugin's a2a_call / a2a_discover etc.), put them in a dedicated tools.py
with a register_tools(ctx) function and declare them in the manifest:
provides_tools:
- my_platform_call
- my_platform_listWith provides_tools declared, Hermes imports only tools.py during plugin
discovery and registers the client tools in every process — CLI and TUI
included — while the adapter stays deferred. Keep the package __init__.py
import-light and pull the adapter in from inside register() so the eager
import stays cheap. Without the field, nothing changes: the whole plugin stays
deferred.
Users enable the toolset per platform like any other, e.g.hermes tools enable my_platform --platform cli, or by listing the toolset
key under platform_toolsets in config.yaml. Plugin platform names are
also valid --platform targets, so an inbound session on your platform can
be granted its own outbound tools.
adapter.py
/* Detailed source-code truncated for AI context efficiency. */Configuration
Users configure the platform in config.yaml:
gateway:
platforms:
my_platform:
enabled: true
extra:
token: "..."
channel: "#general"Or via environment variables (which the adapter reads in __init__).
What the Plugin System Handles Automatically
When you call ctx.register_platform(), the following integration points are handled for you — no core code changes needed:
| Integration point | How it works |
|---|---|
| Gateway adapter creation | Registry checked before the built-in _BUILTIN_ADAPTERS table |
| Config parsing | Platform._missing_() accepts any platform name |
| Connected platform validation | Registry validate_config() called |
| User authorization | allowed_users_env / allow_all_env checked; group_allowed_chats_env / allowlist_suppresses_pairing (see below) |
| Env-only auto-enable | env_enablement_fn seeds PlatformConfig.extra + home_channel |
| YAML config bridge | apply_yaml_config_fn translates config.yaml keys into env vars / extras |
| Cron delivery | cron_deliver_env_var makes deliver=<name> work |
hermes config UI entries |
requires_env / optional_env in plugin.yaml auto-populate |
send engine (tools/send_message_tool.py) |
Routes through live gateway adapter |
| Webhook cross-platform delivery | Registry checked for known platforms |
/update command access |
allow_update_command flag |
| Channel directory | Plugin platforms included in enumeration |
| System prompt hints | platform_hint injected into LLM context |
| Message chunking | max_message_length for smart splitting |
| PII redaction | pii_safe flag |
hermes status |
Lists plugin platforms alongside built-ins, one row each, using the gateway's own configured check |
hermes gateway setup |
Plugin platforms appear in setup menu |
hermes tools / hermes skills |
Plugin platforms in per-platform config |
| Token lock (multi-profile) | Use acquire_scoped_lock() in your connect() |
| Orphaned config warning | Descriptive log when plugin is missing |
| Service-event authorization | trusted_inbound=True skips user allowlists/pairing (see below) |
| Display defaults | display_tier picks the built-in tool-progress/streaming tier |
| Profile clone credential strip | shared_env_prefixes keeps tool-shared keys when the adapter is not in use |
Service adapters: `trusted_inbound`, `display_tier`, `shared_env_prefixes`
Three optional ctx.register_platform(...) / PlatformEntry fields exist for adapters that bridge a
service rather than a chat network. The Home Assistant plugin
is the reference consumer.
| Field | Type / default | Effect |
|---|---|---|
trusted_inbound |
bool = False |
Every inbound event comes from the service the adapter authenticated to with its own credential; there is no human sender. The gateway's user allowlists and DM pairing do not apply to this platform. Home Assistant's event bus is the consumer. Never set it for a chat platform — it would let anyone who can message the bot drive the agent. |
display_tier |
str = '' |
One of 'high', 'medium', 'low', 'minimal': the built-in per-platform display defaults tier (tool progress, streaming, interim messages) used when the user has no display.platforms.<name> override. Empty keeps the generic plugin default. Home Assistant uses 'minimal'. |
shared_env_prefixes |
tuple[str, ...] = () |
Env prefixes the platform shares with a non-channel capability, such as the same plugin's tools (Home Assistant: ('HASS_',)). Profile clones strip keys under these prefixes only when the source profile actually runs the adapter, so a profile that only uses the tools keeps its credentials. |
import dataclasses
def register(ctx):
kwargs = dict(
name="my_service",
label="My Service",
adapter_factory=MyServiceAdapter,
check_fn=check_requirements,
)
# Feature-detect: older cores reject unknown register_platform kwargs.
from gateway.platform_registry import PlatformEntry
known = {f.name for f in dataclasses.fields(PlatformEntry)}
for key, value in (
("trusted_inbound", True),
("display_tier", "minimal"),
("shared_env_prefixes", ("MY_SERVICE_",)),
):
if key in known:
kwargs[key] = value
ctx.register_platform(**kwargs)Feature-detect these fields with dataclasses.fields(PlatformEntry) as above so the plugin stays loadable on
older Hermes cores that predate them.
Authorization fields for chat platforms
Two more optional PlatformEntry fields carry gateway authorization behaviour that core used to
hard-code for its bundled platforms. Declare them and a plugin platform behaves the same way:
| Field | Type / default | Effect |
|---|---|---|
group_allowed_chats_env |
str = '' |
Env var holding comma-separated group/forum chat IDs. Any message from a listed chat is authorized, even without a user_id (anonymous admins, channel posts). Telegram declares TELEGRAM_GROUP_ALLOWED_CHATS and QQ Bot declares QQ_GROUP_ALLOWED_USERS. |
allowlist_suppresses_pairing |
bool = False |
When the platform's allowed_users_env (or group_allowed_chats_env) is set, unknown DM senders are ignored rather than sent a pairing code: the owner already restricted access. Most bundled chat platforms set it. |
kwargs = dict(name="my_chat", label="My Chat", adapter_factory=MyChatAdapter, check_fn=check_requirements,
allowed_users_env="MY_CHAT_ALLOWED_USERS", allow_all_env="MY_CHAT_ALLOW_ALL_USERS")
known = {f.name for f in dataclasses.fields(PlatformEntry)}
for key, value in (("group_allowed_chats_env", "MY_CHAT_GROUP_ALLOWED_CHATS"),
("allowlist_suppresses_pairing", True)):
if key in known: # older cores reject unknown kwargs
kwargs[key] = value
ctx.register_platform(**kwargs)Public helpers for platform plugins
A platform adapter needs more of core than a tool plugin does. These names are public: hermes plugins validate raises no private-import warning for them, and they keep their names and signatures across
releases. Anything else under gateway.*, tools.* or hermes_cli.* is internal, and the validator
lists each import of it.
| Need | Import |
|---|---|
| Adapter base class, send results, media cache helpers | gateway.platforms.base (public names) |
| SSRF-safe HTTP client: connect-time IP validation, per-redirect re-check, shared keepalive limits | from gateway.platforms.helpers import platform_http_client |
| The pieces of that client, to build your own | gateway.platforms.base.ssrf_redirect_guard (httpx response hook), tools.url_safety.create_ssrf_safe_async_client, tools.url_safety.is_safe_url, gateway.platforms._http_client_limits.platform_httpx_limits |
Own DM/group policy (dm_policy / group_policy / allowlists) |
from gateway.platforms.access_policy_mixin import OwnAccessPolicyMixin |
| Resolve an approval from a native button | from tools.approval import resolve_gateway_approval |
| Native clarify buttons | tools.clarify_gateway: get_pending(clarify_id) (the pending entry or None; read session_key, choices, multi_select), resolve_gateway_clarify(clarify_id, text), mark_awaiting_text(clarify_id), SKIPPED |
setup_fn wizard steps |
hermes_cli.gateway_setup_wizard: print_setup_header(title), confirm_reconfigure(label, *env_vars), offer_home_channel(home_var, user_id, what), save_env_values(**values), prompt_csv(text, default); prompts from hermes_cli.setup (prompt, prompt_choice, prompt_yes_no); output from hermes_cli.cli_output (print_info, print_success, print_warning, print_error); .env access through hermes_cli.config (save_env_value, get_env_value) |
from gateway.platforms.helpers import platform_http_client
async def connect(self) -> bool:
# timeout=30.0 and follow_redirects=True by default; any other httpx.AsyncClient kwarg passes through.
self._http = platform_http_client(headers={"User-Agent": "my-chat-bot/1.0"})
...get_pending() returns None once the clarify was answered, timed out or cleared, so compare the
result by identity with the entry your card was rendered for before acting on a click.
Standalone send-path extensions
A standalone platform can participate in host-driven outbound delivery through
direct hermes send --to ... and cron deliver=platform:... by declaring send
behavior on the same PlatformEntry created by ctx.register_platform().send_message is intentionally not an agent-callable model tool; plugins must
not register an equivalent model surface that lets the agent initiate outbound
messages on its own.
async def _send_request(args, chat_id, platform_name, pconfig):
# `args` contains the host-driven send request fields.
message_id = await client.send(
address=chat_id,
body=args["message"],
subject=args.get("subject"),
)
return {"success": True, "platform": platform_name,
"chat_id": chat_id, "message_id": message_id}
def _parse_address(raw):
normalized = raw.strip().lower()
if normalized.startswith("@") and "@" in normalized[1:]:
return normalized, None # (chat_id, optional thread_id)
return None # continue to channel-directory resolution
def _validate_address(address):
# True accepts; False rejects; a string rejects with that diagnostic.
return True if address.endswith("@example.com") else "unsupported domain"
def register(ctx):
ctx.register_platform(
name="fmsg",
label="Fixture Message",
adapter_factory=lambda cfg: FmsgAdapter(cfg),
check_fn=check_requirements,
parse_target_ref_fn=_parse_address,
validate_target_ref_fn=_validate_address,
# May be a regular function or async def. Hermes awaits any awaitable
# result, including callable objects and functools.partial wrappers.
send_message_handler=_send_request,
# Prefer this lower-level hook when cron must send from a process
# without the live gateway.
standalone_sender_fn=_standalone_send,
)Target resolution is shared across all three outbound surfaces. Parser output
is normalized first and channel-directory IDs are trusted. A plugin parser must
explicitly accept native target syntax; unresolved strings are never passed
through opaquely. Unknown platforms and validator failures return a diagnostic
instead of silently attempting delivery. Plugin force-reload/profile
transitions unregister owned entries, so parsers and handlers cannot leak into
the next profile.
Env-Driven Auto-Configuration
Most users set up a platform by dropping env vars into ~/.hermes/.env rather than editing config.yaml. The env_enablement_fn hook lets your plugin pick those env vars up before the adapter is constructed, so hermes gateway status, get_connected_platforms(), and cron delivery see the correct state without instantiating the platform SDK.
Read env through gateway.platforms._shared.get_scoped_secret — never os.getenv. Under gateway.multiplex_profiles the process env holds the DEFAULT profile's values; a secondary profile's .env exists only in its secret scope, and a raw read would enable your platform for the wrong profile with the wrong credentials. seed_extra_from_env builds the seed dict from a (ENV_VAR, extra_key, conv) table through that reader.
from gateway.platforms._shared import get_scoped_secret, seed_extra_from_env
def _env_enablement() -> dict | None:
"""Seed PlatformConfig.extra from env vars.
Called by the platform registry during load_gateway_config().
Return None when the platform isn't minimally configured — the
caller then skips auto-enabling. Return a dict to seed extras.
"""
token = get_scoped_secret("MY_PLATFORM_TOKEN", "").strip()
channel = get_scoped_secret("MY_PLATFORM_CHANNEL", "").strip()
if not (token and channel):
return None
# (ENV_VAR, extra_key, conv): blank values are skipped, a conv raising
# ValueError drops the key. home_env seeds the special 'home_channel'
# dict ({chat_id, name} from MY_PLATFORM_HOME_CHANNEL[_NAME], name
# defaults to "Home"), which the core hook lifts into a HomeChannel.
return {"token": token, "channel": channel, **seed_extra_from_env(
(("MY_PLATFORM_PORT", "port", int), ("MY_PLATFORM_NICK", "nick", None)),
home_env="MY_PLATFORM_HOME_CHANNEL",
)}
def register(ctx):
ctx.register_platform(
name="my_platform",
label="My Platform",
adapter_factory=MyPlatformAdapter,
check_fn=check_requirements,
validate_config=validate_config,
env_enablement_fn=_env_enablement,
# ... other fields
)YAML→env Config Bridge
Some users prefer setting config.yaml keys (my_platform.require_mention, my_platform.allowed_channels, etc.) over env vars. The apply_yaml_config_fn hook lets your plugin own this translation instead of forcing core gateway/config.py to know your platform's YAML schema.
from gateway.platforms._shared import apply_yaml_bridge
# (yaml key, ENV_VAR, kind). "lower" bridges whenever the key is present
# (booleans become "true"/"false"); "str" skips null/blank; "csv" comma-joins
# lists; "json" dumps the value.
_YAML_BRIDGE = (
("require_mention", "MY_PLATFORM_REQUIRE_MENTION", "lower"),
("allowed_channels", "MY_PLATFORM_ALLOWED_CHANNELS", "csv"),
)
def _apply_yaml_config(yaml_cfg: dict, platform_cfg: dict) -> dict | None:
"""Translate config.yaml `my_platform:` keys into env vars / extras.
yaml_cfg — the full top-level parsed config.yaml dict
platform_cfg — the platform's own sub-dict (yaml_cfg.get("my_platform", {}))
apply_yaml_bridge writes each env var only when it is unset (env > YAML)
and NEVER while a multiplexed secondary profile's config is loading — a
write there would pin that profile's policy process-wide. It returns the
same values for PlatformConfig.extra, so read `extra` first in the adapter
and fall back to the env var (`_shared.extra_or_secret`).
"""
return apply_yaml_bridge(platform_cfg, _YAML_BRIDGE)
def register(ctx):
ctx.register_platform(
name="my_platform",
...,
apply_yaml_config_fn=_apply_yaml_config,
)The hook is invoked during load_gateway_config() after the generic shared-key loop (which handles common keys like unauthorized_dm_behavior, notice_delivery, reply_prefix, require_mention, etc.) and before _apply_env_overrides(), so your plugin only needs to bridge platform-specific keys.
Exceptions raised by the hook are swallowed and logged at debug level — a misbehaving plugin never aborts gateway config load.
Cron Delivery
To let deliver=my_platform cron jobs route to a configured home channel, set cron_deliver_env_var to the env var name that holds the default chat/room/channel ID:
ctx.register_platform(
name="my_platform",
...
cron_deliver_env_var="MY_PLATFORM_HOME_CHANNEL",
)The scheduler reads this env var when resolving the home target for deliver=my_platform jobs, and also treats the platform as a valid cron target in _KNOWN_DELIVERY_PLATFORMS-style checks. If your env_enablement_fn seeds a home_channel dict (see above), that takes precedence — cron_deliver_env_var is the fallback for cron jobs that run before env seeding.
Out-of-process cron delivery
cron_deliver_env_var makes your platform a recognized deliver= target. To make the actual send succeed when the cron job runs in a separate process from the gateway (i.e., hermes cron run separate from hermes gateway), register a standalone_sender_fn:
async def _standalone_send(
pconfig,
chat_id,
message,
*,
thread_id=None,
media_files=None,
force_document=False,
):
"""Open an ephemeral connection / acquire a fresh token, send, and close."""
# ... open connection, send message, return result ...
return {"success": True, "message_id": "..."}
# or {"error": "..."}
ctx.register_platform(
name="my_platform",
...
cron_deliver_env_var="MY_PLATFORM_HOME_CHANNEL",
standalone_sender_fn=_standalone_send,
)Why this hook is necessary: built-in platforms (Telegram, Discord, Slack, etc.) ship direct REST helpers in tools/send_message_tool.py so cron can deliver without holding the gateway in the same process. Plugin platforms historically depended on _gateway_runner_ref(), which returns None outside the gateway process, so without standalone_sender_fn the cron-side send fails with No live adapter for platform '<name>'.
The function receives the same pconfig and chat_id that the live adapter would, plus optional thread_id, media_files, and force_document keyword arguments. Returning {"success": True, "message_id": ...} is treated as a successful delivery; returning {"error": "..."} surfaces the message in cron's delivery_errors. Exceptions raised inside the function are caught by the dispatcher and reported as Plugin standalone send failed: <reason>. Reference implementations live in plugins/platforms/{irc,teams,google_chat}/adapter.py.
Surfacing Env Vars in `hermes config`
hermes_cli/config.py scans plugins/platforms/*/plugin.yaml at import time and auto-populates OPTIONAL_ENV_VARS from requires_env and (optional) optional_env blocks. Use the rich-dict form to contribute proper descriptions, prompts, password flags, and URLs — the CLI setup UI picks them up for free.
# plugins/platforms/my_platform/plugin.yaml
name: my_platform-platform
label: My Platform
kind: platform
version: 1.0.0
description: >
My Platform gateway adapter for Hermes Agent.
author: Your Name
requires_env:
- name: MY_PLATFORM_TOKEN
description: "Bot API token from the My Platform console"
prompt: "My Platform bot token"
url: "https://my-platform.example.com/bots"
password: true
- name: MY_PLATFORM_CHANNEL
description: "Channel to join (e.g. #hermes)"
prompt: "Channel"
password: false
optional_env:
- name: MY_PLATFORM_HOME_CHANNEL
description: "Default channel for cron delivery (defaults to MY_PLATFORM_CHANNEL)"
prompt: "Home channel (or empty)"
password: false
- name: MY_PLATFORM_ALLOWED_USERS
description: "Comma-separated user IDs allowed to talk to the bot"
prompt: "Allowed users (comma-separated)"
password: falseSupported dict keys: name (required), description, prompt, url, password (bool; auto-detected from *_TOKEN / *_SECRET / *_KEY / *_PASSWORD / *_JSON suffix when omitted), category (defaults to "messaging").
Bare-string entries (- MY_PLATFORM_TOKEN) still work — they get a generic description auto-derived from the plugin's label. If a hardcoded entry for the same var already exists in OPTIONAL_ENV_VARS, it wins (back-compat); the plugin.yaml form acts as the fallback.
Platform-Specific Slow-LLM UX
Some platforms have constraints that change how a slow LLM response should be presented:
- LINE issues a single-use reply token that expires roughly 60 seconds after the inbound event. Replying with that token is free; falling back to the metered Push API is not. If the LLM hasn't finished by the deadline, the choice is "burn paid Push quota" or "do something cleverer with the reply token before it expires."
- WhatsApp marks a session inactive after 24h, after which only template messages are accepted.
- SMS has no concept of typing indicators or progressive updates — long responses just look like the bot is offline.
These are real constraints the base BasePlatformAdapter can't anticipate. The plugin surface intentionally leaves the room for an adapter to layer platform-specific UX on top of the base typing loop without expanding the kwarg list.
Pattern: subclass `_keep_typing` to layer mid-flight UX
BasePlatformAdapter._keep_typing is the typing-indicator heartbeat — it runs as a background task while the LLM is generating, and is cancelled when the response is delivered. To layer a platform-specific behavior at a threshold (e.g. send a "still thinking" bubble at 45s), override _keep_typing in your adapter, schedule your own task alongside super()._keep_typing(), and tear it down in finally:
class LineAdapter(BasePlatformAdapter):
async def _keep_typing(self, chat_id: str, *args, **kwargs) -> None:
if self.slow_response_threshold <= 0:
await super()._keep_typing(chat_id, *args, **kwargs)
return
async def _fire_at_threshold() -> None:
try:
await asyncio.sleep(self.slow_response_threshold)
except asyncio.CancelledError:
raise
# Platform-specific work here — for LINE, send a Template
# Buttons "Get answer" bubble using the cached reply token
# so the user can fetch the cached response later via a
# fresh (free) reply token from the postback callback.
await self._send_slow_response_button(chat_id)
side_task = asyncio.create_task(_fire_at_threshold())
try:
await super()._keep_typing(chat_id, *args, **kwargs)
finally:
if not side_task.done():
side_task.cancel()
try:
await side_task
except (asyncio.CancelledError, Exception):
passKey points:
- Always
await super()._keep_typing(...). The typing heartbeat is independently useful — don't replace it, layer on top of it. - Tear down the side task in
finally. When the LLM finishes (or/stopcancels the run), the gateway cancels the typing task. Your side task must observe that cancellation too, otherwise it lingers and may fire after the response was already delivered. - Pair with
interrupt_session_activityto resolve any orphan UX state when the user issues/stop. For LINE, this means transitioning the postback cache entry fromPENDINGtoERRORso the persistent "Get answer" button delivers a "Run was interrupted" message instead of looping.
Pattern: subclass `send` to route through a cache instead of sending immediately
If your slow-response UX caches the response for later retrieval (LINE's postback flow), your send override needs to recognize three modes:
- Pending postback active for this chat → cache the response under the request_id, don't send anything visible.
- System busy-ack (
⚡ Interrupting,⏳ Queued,⏩ Steered) → bypass the cache and send visibly so the user sees the gateway's response to their input. - Normal response → send via reply-token-or-push as usual.
async def send(self, chat_id: str, content: str, **kw) -> SendResult:
if _is_system_bypass(content):
return await self._send_text_chunks(chat_id, content, force_push=False)
pending_rid = self._pending_buttons.get(chat_id)
if pending_rid:
self._cache.set_ready(pending_rid, content)
return SendResult(success=True, message_id=pending_rid)
return await self._send_text_chunks(chat_id, content, force_push=False)_SYSTEM_BYPASS_PREFIXES are the gateway's own busy-acknowledgment prefixes (⚡, ⏳, ⏩, 💾). Always let those through visibly, regardless of cached UX state.
When this pattern is appropriate
Use the typing-loop override approach when:
- The platform's outbound API has a hard time-window constraint (single-use reply token, expiring sticky session, etc.) AND
- A visible mid-flight bubble is acceptable UX on that platform.
Use the simpler slow_response_threshold = 0 always-Push path when:
- The platform doesn't have a meaningful free vs. paid distinction, OR
- The user community prefers "loading… loading… DONE" silence-then-response over an interactive intermediate bubble.
LINE supports both: the threshold defaults to 45s for free postback fetch, and LINE_SLOW_RESPONSE_THRESHOLD=0 reverts to "always Push fallback."
Reference Implementation
See plugins/platforms/line/adapter.py for the full LINE postback implementation — a RequestCache state machine (PENDING → READY → DELIVERED, plus ERROR for /stop), a _keep_typing override that fires the Template Buttons bubble at threshold, a send override that routes through the cache, and an interrupt_session_activity override that resolves orphan PENDING entries.
Reference Implementations (Plugin Path)
See plugins/platforms/irc/ in the repo for a complete working example — a full async IRC adapter with zero external dependencies. plugins/platforms/teams/ covers Bot Framework / Adaptive Cards, plugins/platforms/google_chat/ covers OAuth-based REST APIs, and plugins/platforms/line/ covers webhook-driven Messaging APIs with platform-specific slow-LLM UX.
Step-by-Step Checklist (Built-in Path)
:::note
This checklist is for adding a platform directly to the Hermes core codebase — typically done by core contributors for officially supported platforms. Community/third-party platforms should use the Plugin Path above.
:::
1. Platform Enum
Add your platform to the Platform enum in gateway/config.py:
class Platform(Enum):
# ... existing platforms ...
NEWPLAT = "newplat"2. Adapter File
Create plugins/platforms/newplat/adapter.py:
from gateway.config import Platform, PlatformConfig
from gateway.platforms._shared import extra_or_secret
from gateway.platforms.base import BasePlatformAdapter, SendResult
from gateway.platforms.event import MessageEvent, MessageType
def check_newplat_requirements() -> bool:
"""Return True if dependencies are available."""
return SOME_SDK_AVAILABLE
class NewPlatAdapter(BasePlatformAdapter):
def __init__(self, config: PlatformConfig):
super().__init__(config, Platform.NEWPLAT)
# Read config from config.extra dict
extra = config.extra or {}
self._api_key = extra_or_secret(extra, "api_key", "NEWPLAT_API_KEY")
async def connect(self, *, is_reconnect: bool = False) -> bool:
# Set up connection, start polling/webhook
self._mark_connected()
return True
async def disconnect(self) -> None:
self._running = False
self._mark_disconnected()
async def send(self, chat_id, content, reply_to=None, metadata=None):
# Send message via platform API
return SendResult(success=True, message_id="...")
async def get_chat_info(self, chat_id):
return {"name": chat_id, "type": "dm"}For inbound messages, build a MessageEvent and call self.handle_message(event):
source = self.build_source(
chat_id=chat_id,
chat_name=name,
chat_type="dm", # or "group"
user_id=user_id,
user_name=user_name,
)
event = MessageEvent(
text=content,
message_type=MessageType.TEXT,
source=source,
message_id=msg_id,
)
await self.handle_message(event)3. Gateway Config (`gateway/config.py`)
Three touchpoints:
get_connected_platforms()— Add a check for your platform's required credentialsload_gateway_config()— Add token env map entry:Platform.NEWPLAT: "NEWPLAT_TOKEN"_apply_env_overrides()— Map allNEWPLAT_*env vars to config
4. Gateway Runner (`gateway/run.py` + `gateway/run_*.py` siblings)
Six touchpoints:
_BUILTIN_ADAPTERStable (gateway/run.py) — Add aPlatform.NEWPLAT: (module, class, check_fn, error_msg)entry;_instantiate_adapter()(gateway/run_adapters.py) consults the plugin registry, then this table — there is noelifchain to extend. The_create_adapter()wrapper binds every successful adapter to its gateway runner._is_user_authorized()allowed_users map —Platform.NEWPLAT: "NEWPLAT_ALLOWED_USERS"_is_user_authorized()allow_all map —Platform.NEWPLAT: "NEWPLAT_ALLOW_ALL_USERS"- Startup access-policy check (
gateway/run_startup.py) — Add"NEWPLAT"to_ALLOWLIST_ENV_PLATFORMS(derives bothNEWPLAT_ALLOWED_USERSandNEWPLAT_ALLOW_ALL_USERS) - Startup
_BUILTIN_ALLOW_ALL_VARS(gateway/run_startup.py) — derived from the same_ALLOWLIST_ENV_PLATFORMStuple; nothing extra to add _UPDATE_ALLOWED_PLATFORMSfrozenset — AddPlatform.NEWPLAT
5. Cross-Platform Delivery
gateway/platforms/webhook.py— Add"newplat"to the delivery type tuplecron/scheduler_delivery.py— Add to_KNOWN_DELIVERY_PLATFORMSfrozenset and_deliver_result()platform map
6. CLI Integration
hermes_cli/config.py— Add allNEWPLAT_*vars to_EXTRA_ENV_KEYShermes_cli/gateway.py— Add entry to_PLATFORMSlist with key, label, emoji, token_var, setup_instructions, and varshermes_cli/platforms.py— AddPlatformInfoentry with label and default_toolset (used byskills_configandtools_configTUIs)hermes_cli/setup.py— Add_setup_newplat()function (can delegate togateway.py) and add tuple to the messaging platforms listhermes_cli/status.py— Add platform detection entry:"NewPlat": ("NEWPLAT_TOKEN", "NEWPLAT_HOME_CHANNEL")hermes_cli/dump.py— Add"newplat": "NEWPLAT_TOKEN"to platform detection dict
7. Tools
tools/send_message_tool.py— Add"newplat": Platform.NEWPLATto platform maptools/cronjob_tools.py— Addnewplatto the delivery target description string
8. Toolsets
toolsets.py— Add"hermes-newplat"toolset definition with_HERMES_CORE_TOOLStoolsets.py— Add"hermes-newplat"to the"hermes-gateway"includes list
9. Optional: Platform Hints
platform_hint= on ctx.register_platform — If your platform has specific rendering limitations (no markdown, message length limits, etc.), declare a hint on its registry entry (keep the text in a light sibling such as adapter_hint.py). It is injected into the system prompt; agent/prompt_builder.py::PLATFORM_HINTS holds only core surfaces:
PLATFORM_HINT = (
"You are chatting via NewPlat. It supports markdown formatting "
"but has a 4000-character message limit."
)Not all platforms need hints — only add one if the agent's behavior should differ.
10. Tests
Create tests/gateway/test_newplat.py covering:
- Adapter construction from config
- Message event building
- Send method (mock the external API)
- Platform-specific features (encryption, routing, etc.)
11. Documentation
| File | What to add |
|---|---|
website/docs/user-guide/messaging/newplat.md |
Full platform setup page |
website/docs/user-guide/messaging/index.md |
Platform comparison table, architecture diagram, toolsets table, security section, next-steps link |
website/docs/reference/environment-variables.md |
All NEWPLAT_* env vars |
website/docs/reference/toolsets-reference.md |
hermes-newplat toolset |
website/docs/integrations/index.md |
Platform link |
website/sidebars.ts |
Sidebar entry for the docs page |
website/docs/developer-guide/architecture.md |
Adapter count + listing |
website/docs/developer-guide/gateway-internals.md |
Adapter file listing |
Parity Audit
Before marking a new platform PR as complete, run a parity audit against an established platform:
# Find every .py file mentioning the reference platform
search_files "bluebubbles" output_mode="files_only" file_glob="*.py"
# Find every .py file mentioning the new platform
search_files "newplat" output_mode="files_only" file_glob="*.py"
# Any file in the first set but not the second is a potential gapRepeat for .md and .ts files. Investigate each gap — is it a platform enumeration (needs updating) or a platform-specific reference (skip)?
Common Patterns
Long-Poll Adapters
If your adapter uses long-polling (like Telegram or Weixin), use a polling loop task:
async def connect(self):
self._poll_task = asyncio.create_task(self._poll_loop())
self._mark_connected()
async def _poll_loop(self):
while self._running:
messages = await self._fetch_updates()
for msg in messages:
await self.handle_message(self._build_event(msg))Callback/Webhook Adapters
If the platform pushes messages to your endpoint (like WeCom Callback), run an HTTP server:
async def connect(self):
self._app = web.Application()
self._app.router.add_post("/callback", self._handle_callback)
# ... start aiohttp server
self._mark_connected()
async def _handle_callback(self, request):
event = self._build_event(await request.text())
await self._message_queue.put(event)
return web.Response(text="success") # Acknowledge immediatelyFor platforms with tight response deadlines (e.g., WeCom's 5-second limit), always acknowledge immediately and deliver the agent's reply proactively via API later. Agent sessions run 3–30 minutes — inline replies within a callback response window are not feasible.
Inbound Deduplication
Platforms redeliver: websocket resumes replay recent events, webhooks retry, and an unacknowledged poll batch comes back. Drop repeats with the shared helper, keyed on the platform's message ID:
from gateway.platforms.helpers import MessageDeduplicator
self._dedup = MessageDeduplicator(ttl_seconds=600) # in __init__
if self._dedup.is_duplicate(msg_id): # in the inbound handler
returnWhen the gateway's reconnect watcher replaces a failed adapter with a new instance, it copies every MessageDeduplicator attribute's live IDs from the old instance to the new one, so a replay right after the reconnect is still dropped. A cache kept in another structure (a plain dict or set) starts empty on the new instance.
Token Locks
If the adapter holds a persistent connection with a unique credential, add a scoped lock to prevent two profiles from using the same credential:
from gateway.status import acquire_scoped_lock, release_scoped_lock
async def connect(self, *, is_reconnect: bool = False):
acquired, _existing = acquire_scoped_lock("newplat", self._token)
if not acquired:
logger.error("Token already in use by another profile")
return False
# ... connect
async def disconnect(self):
release_scoped_lock("newplat", self._token)Reference Implementations
| Adapter | Pattern | Complexity | Good reference for |
|---|---|---|---|
plugins/platforms/bluebubbles/adapter.py |
REST + webhook | Medium | Simple REST API integration |
plugins/platforms/weixin/adapter.py |
Long-poll + CDN | High | Media handling, encryption |
plugins/platforms/wecom/callback_adapter.py |
Callback/webhook | Medium | HTTP server, AES crypto, multi-app |
plugins/platforms/irc/adapter.py |
Long-poll + IRC protocol | High | Full-featured plugin adapter with scoped token lock |
Developer Guide Adding Providers (`website/docs/developer-guide/adding-providers.md`)
sidebar_position: 5 title: "Adding Providers"
Adding Providers
Hermes can already talk to any OpenAI-compatible endpoint through the custom provider path. Do not add a built-in provider unless you want first-class UX for that service:
- provider-specific auth or token refresh
- a curated model catalog
- setup /
hermes modelmenu entries - provider aliases for
provider:modelsyntax - a non-OpenAI API shape that needs an adapter
If the provider is just "another OpenAI-compatible base URL and API key", a named custom provider may be enough.
The mental model
A built-in provider has to line up across a few layers:
hermes_cli/auth.pydecides how credentials are found.hermes_cli/runtime_provider.pyturns that into runtime data:providerapi_modebase_urlapi_keysource
run_agent.pyusesapi_modeto decide how requests are built and sent.hermes_cli/models.pyandhermes_cli/main.pymake the provider show up in the CLI. (hermes_cli/setup.pydelegates tomain.pyautomatically — no changes needed there.)agent/auxiliary_client.pyandagent/model_metadata.pykeep side tasks and token budgeting working.
The important abstraction is api_mode.
- Most providers use
chat_completions. - Codex and Meta Model API (
api.meta.ai— Muse Spark) usecodex_responses(auto-sendsprompt_cache_retention: 24hfor prompt caching;api.meta.aiachieves 93–99% cache hits only on/v1/responses). - Ramp Router (
api.router.com) also usescodex_responses— Responses is Router's native wire (/v1/chat/completionsis only a minimal compatibility shim), and it validatesreasoning.effortper model, which the router profile handles by declaring each model's vocabulary from the live catalog (ProviderProfile.supported_reasoning_efforts). - Anthropic uses
anthropic_messages. - A new non-OpenAI protocol usually means adding a new adapter and a new
api_modebranch.
Tool-call wire format
Hermes stores conversation history in the OpenAI chat-completions shape internally, so the chat_completions transport's convert_messages / convert_tools (agent/transports/chat_completions.py) are near-identity, and every other transport converts from that shape into its native protocol. The canonical reference for the shape — tools definitions with JSON-schema parameters, assistant tool_calls entries with stringified function.arguments, and role: "tool" result messages keyed by tool_call_id — is the OpenAI chat completions API reference. When you write a native adapter, that page defines the input side of your conversion; your provider's docs define the output side.
Choose the implementation path first
Path A — OpenAI-compatible provider
Use this when the provider accepts standard chat-completions style requests.
Typical work:
- add auth metadata
- add model catalog / aliases
- add runtime resolution
- add CLI menu wiring
- add aux-model defaults
- add tests and user docs
You usually do not need a new adapter or a new api_mode.
Path B — Native provider
Use this when the provider does not behave like OpenAI chat completions.
Examples in-tree today:
codex_responses(OpenAI Codex, xAI Grok, Meta Muse Spark viaapi.meta.ai— the latter auto-sendsprompt_cache_retention: 24h— and Ramp Router viaapi.router.com)anthropic_messages
This path includes everything from Path A plus:
- a provider adapter in
agent/ run_agent.pybranches for request building, dispatch, usage extraction, interrupt handling, and response normalization- adapter tests
File checklist
Required for every built-in provider
hermes_cli/auth.pyhermes_cli/models.pyhermes_cli/runtime_provider.pyhermes_cli/main.pyagent/auxiliary_client.pyagent/model_metadata.py- tests
- user-facing docs under
website/docs/
:::tiphermes_cli/setup.py does not need changes. The setup wizard delegates provider/model selection to select_provider_and_model() in main.py — any provider added there is automatically available in hermes setup.
:::
Additional for native / non-OpenAI providers
agent/_adapter.pyrun_agent.pypyproject.tomlif a provider SDK is required
Fast path: Simple API-key providers
If your provider is just an OpenAI-compatible endpoint that authenticates with a single API key, you do not need to touch auth.py, runtime_provider.py, main.py, or any of the other files in the full checklist below.
All you need is:
- A plugin directory under
plugins/model-providers/<your-provider>/containing:__init__.py— callsregister_provider(profile)at module-levelplugin.yaml— manifest (name, kind: model-provider, version, description)
- That's it. Provider plugins auto-load the first time anything calls
get_provider_profile()orlist_providers()— bundled plugins (this repo) and user plugins at$HERMES_HOME/plugins/model-providers/both get picked up.
When you add a plugin and it calls register_provider(), the following wire up automatically:
PROVIDER_REGISTRYentry inauth.py(credential resolution, env-var lookup)api_modeset tochat_completionsbase_urlsourced from the config or the declared env varenv_varschecked in priority order for the API keyfallback_modelslist registered for the provider--providerCLI flag accepts the provider idhermes modelmenu includes the providerhermes setupwizard delegates tomain.pyautomaticallyprovider:modelalias syntax works- Runtime resolver returns the correct
base_urlandapi_key --provider <name>CLI flag accepts the provider id- Fallback model activation can switch into the provider cleanly
User plugins at $HERMES_HOME/plugins/model-providers/<name>/ override bundled plugins of the same name (last-writer-wins in register_provider()) — so third parties can monkey-patch or replace any built-in profile without editing the repo.
See plugins/model-providers/nvidia/ or plugins/model-providers/gmi/ as a template, and the full Model Provider Plugin guide for field reference, hook idioms, and end-to-end examples.
Full path: OAuth and complex providers
Use the full checklist below when your provider needs any of the following:
- OAuth or token refresh (Nous Portal, Codex, Qwen Portal, Copilot)
- A non-OpenAI API shape that requires a new adapter (Anthropic Messages, Codex Responses)
- Custom endpoint detection or multi-region probing (z.ai, Kimi)
- A curated static model catalog or live
/modelsfetch - Provider-specific
hermes modelmenu entries with bespoke auth flows
Step 1: Pick one canonical provider id
Choose a single provider id and use it everywhere.
Examples from the repo:
openai-codexkimi-codingminimax-cn
That same id should appear in:
PROVIDER_REGISTRYinhermes_cli/auth.py_PROVIDER_LABELSinhermes_cli/models_catalog_static.py(re-exported byhermes_cli/models.py)_PROVIDER_ALIASESin bothhermes_cli/auth.pyandhermes_cli/models_catalog_static.py- CLI
--providerchoices inhermes_cli/main.py - setup / model selection branches
- auxiliary-model defaults
- tests
If the id differs between those files, the provider will feel half-wired: auth may work while /model, setup, or runtime resolution silently misses it.
Step 2: Add auth metadata in `hermes_cli/auth.py`
For API-key providers, add a ProviderConfig entry to PROVIDER_REGISTRY with:
idnameauth_type="api_key"inference_base_urlapi_key_env_vars- optional
base_url_env_var
Also add aliases to _PROVIDER_ALIASES.
Use the existing providers as templates:
- simple API-key path: Z.AI, MiniMax
- API-key path with endpoint detection: Kimi, Z.AI
- native token resolution: Anthropic
- OAuth / auth-store path: Nous, OpenAI Codex
Questions to answer here:
- What env vars should Hermes check, and in what priority order?
- Does the provider need base-URL overrides?
- Does it need endpoint probing or token refresh?
- What should the auth error say when credentials are missing?
If the provider needs something more than "look up an API key", add a dedicated credential resolver instead of shoving logic into unrelated branches.
Step 3: Add model catalog and aliases in `hermes_cli/models.py`
Update the provider catalog so the provider works in menus and in provider:model syntax.
Typical edits:
_PROVIDER_MODELS_PROVIDER_LABELS_PROVIDER_ALIASES- provider display order inside
list_available_providers() provider_model_ids()if the provider supports a live/modelsfetch
If the provider exposes a live model list, prefer that first and keep _PROVIDER_MODELS as the static fallback.
This file is also what makes inputs like these work:
anthropic:claude-sonnet-4-6
kimi:model-nameIf aliases are missing here, the provider may authenticate correctly but still fail in /model parsing.
Step 4: Resolve runtime data in `hermes_cli/runtime_provider.py`
resolve_runtime_provider() is the shared path used by CLI, gateway, cron, ACP, and helper clients.
Add a branch that returns a dict with at least:
{
"provider": "your-provider",
"api_mode": "chat_completions", # or your native mode
"base_url": "https://...",
"api_key": "...",
"source": "env|portal|auth-store|explicit",
"requested_provider": requested_provider,
}If the provider is OpenAI-compatible, api_mode should usually stay chat_completions.
Be careful with API-key precedence. Hermes already contains logic to avoid leaking an OpenRouter key to unrelated endpoints. A new provider should be equally explicit about which key goes to which base URL.
Step 5: Wire the CLI in `hermes_cli/main.py`
A provider is not discoverable until it shows up in the interactive hermes model flow.
Update these in hermes_cli/main.py:
provider_labelsdictproviderslist inselect_provider_and_model()- provider dispatch (
if selected_provider == ...) --providerargument choices- login/logout choices if the provider supports those flows
- a
_model_flow_()function, or reuse_model_flow_api_key_provider()if it fits
:::tiphermes_cli/setup.py does not need changes — it calls select_provider_and_model() from main.py, so your new provider appears in both hermes model and hermes setup automatically.
:::
Step 6: Keep auxiliary calls working
Two files matter here:
`agent/auxiliary_client.py`
Add a cheap / fast default aux model to _API_KEY_PROVIDER_AUX_MODELS if this is a direct API-key provider.
Auxiliary tasks include things like:
- vision summarization
- web extraction summarization
- context compression summaries
- session-search summaries
- memory flushes
If the provider has no sensible aux default, side tasks may fall back badly or use an expensive main model unexpectedly.
`agent/model_metadata.py`
Add context lengths for the provider's models so token budgeting, compression thresholds, and limits stay sane.
Step 7: If the provider is native, add an adapter and `run_agent.py` support
If the provider is not plain chat completions, isolate the provider-specific logic in agent/_adapter.py.
Keep run_agent.py focused on orchestration. It should call adapter helpers, not hand-build provider payloads inline all over the file.
A native provider usually needs work in these places:
New adapter file
Typical responsibilities:
- build the SDK / HTTP client
- resolve tokens
- convert OpenAI-style conversation messages to the provider's request format
- convert tool schemas if needed
- normalize provider responses back into what
run_agent.pyexpects - extract usage and finish-reason data
`run_agent.py`
Search for api_mode and audit every switch point. At minimum, verify:
__init__chooses the newapi_mode- client construction works for the provider
_build_api_kwargs()knows how to format requests_interruptible_api_call()dispatches to the right client call- interrupt / client rebuild paths work
- response validation accepts the provider's shape
- finish-reason extraction is correct
- token-usage extraction is correct
- fallback-model activation can switch into the new provider cleanly
- summary-generation and memory-flush paths still work
Also search run_agent.py for self.client.. Any code path that assumes the standard OpenAI client exists can break when a native provider uses a different client object or self.client = None.
Prompt caching and provider-specific request fields
Prompt caching and provider-specific knobs are easy to regress.
Examples already in-tree:
- Anthropic has a native prompt-caching path
- OpenRouter gets provider-routing fields
- not every provider should receive every request-side option
When you add a native provider, double-check that Hermes is only sending fields that provider actually understands.
Step 8: Tests
At minimum, touch the tests that guard provider wiring.
Common places:
tests/hermes_cli/test_runtime_provider_resolution.pytests/hermes_cli/test_cli_provider_resolution.pytests/hermes_cli/test_model_switch_custom_providers.py(and adjacenttests/hermes_cli/test_model_switch_*.py)tests/hermes_cli/test_setup_model_provider.pytests/agent/test_provider_parity.pytests/agent/test_run_agent.pytests/agent/test__adapter.pyfor a native provider
For docs-only examples, the exact file set may differ. The point is to cover:
- auth resolution
- CLI menu / provider selection
- runtime provider resolution
- agent execution path
- provider:model parsing
- any adapter-specific message conversion
Prepare the independent test environment,
then use the canonical runner, which isolates each file and scrubs credentials:
scripts/run_tests.sh tests/hermes_cli/test_runtime_provider_resolution.py tests/hermes_cli/test_cli_provider_resolution.py tests/hermes_cli/test_setup_model_provider.py tests/agent/test_provider_parity.py -qFor deeper changes, run the full suite before pushing:
scripts/run_tests.sh tests/ -qStep 9: Live verification
After tests, run a real smoke test from the checkout using the
PM developer workflow and
its isolated development home. Leave any test venv before PM activation.
source ./activate
python hermes chat -q "Say hello" --provider your-provider --model your-modelAlso test the interactive flows if you changed menus:
source ./activate
python hermes model
python hermes setupFor native providers, verify at least one tool call too, not just a plain text response.
Step 10: Update user-facing docs
If the provider is meant to ship as a first-class option, update the user docs too:
website/docs/getting-started/quickstart.mdwebsite/docs/user-guide/configuration.mdwebsite/docs/reference/environment-variables.md
A developer can wire the provider perfectly and still leave users unable to discover the required env vars or setup flow.
OpenAI-compatible provider checklist
Use this if the provider is standard chat completions.
-
ProviderConfigadded inhermes_cli/auth.py - aliases added in
hermes_cli/auth.pyandhermes_cli/models.py - model catalog added in
hermes_cli/models.py - runtime branch added in
hermes_cli/runtime_provider.py - CLI wiring added in
hermes_cli/main.py(setup.py inherits automatically) - aux model added in
agent/auxiliary_client.py - context lengths added in
agent/model_metadata.py - runtime / CLI tests updated
- user docs updated
Native provider checklist
Use this when the provider needs a new protocol path.
- everything in the OpenAI-compatible checklist
- adapter added in
agent/_adapter.py - new
api_modesupported inrun_agent.py - interrupt / rebuild path works
- usage and finish-reason extraction works
- fallback path works
- adapter tests added
- live smoke test passes
Common pitfalls
1. Adding the provider to auth but not to model parsing
That makes credentials resolve correctly while /model and provider:model inputs fail.
2. Forgetting that `config["model"]` can be a string or a dict
A lot of provider-selection code has to normalize both forms.
3. Assuming a built-in provider is required
If the service is just OpenAI-compatible, a custom provider may already solve the user problem with less maintenance.
4. Forgetting auxiliary paths
The main chat path can work while summarization, memory flushes, or vision helpers fail because aux routing was never updated.
5. Native-provider branches hiding in `run_agent.py`
Search for api_mode and self.client.. Do not assume the obvious request path is the only one.
6. Sending OpenRouter-only knobs to other providers
Fields like provider routing belong only on the providers that support them.
7. Updating `hermes model` but not `hermes setup`
Both flows need to know about the provider.
Good search targets while implementing
If you are hunting for all the places a provider touches, search these symbols:
PROVIDER_REGISTRY_PROVIDER_ALIASES_PROVIDER_MODELSresolve_runtime_provider_model_flow_select_provider_and_modelapi_mode_API_KEY_PROVIDER_AUX_MODELSself.client.
Related docs
Developer Guide Adding Tools (`website/docs/developer-guide/adding-tools.md`)
sidebar_position: 2 title: "Adding Tools"
Adding Tools
Before writing a tool, ask yourself: should this be a skill instead?
:::warning Built-in Core Tools Only
This page is for adding a built-in Hermes tool to the repository itself.
If you want a personal, project-local, or otherwise custom tool without
modifying Hermes core, use the plugin route instead:
Default to plugins for most custom tool creation. Only follow this page when
you explicitly want to ship a new built-in tool in tools/ and toolsets.py.
:::
Make it a Skill when the capability can be expressed as instructions + shell commands + existing tools (arXiv search, git workflows, Docker management, PDF processing).
Make it a Tool when it requires end-to-end integration with API keys, custom processing logic, binary data handling, or streaming (browser automation, TTS, vision analysis).
Overview
Adding a tool touches 2 files:
tools/your_tool.py— handler, schema, check function,registry.register()calltoolsets.py— add tool name to_HERMES_CORE_TOOLS(or a specific toolset)
Any tools/*.py file with a top-level registry.register() call is auto-discovered at startup — no manual import list required.
Step 1: Create the Built-in Tool File
Every tool file follows the same structure:
/* Detailed source-code truncated for AI context efficiency. */Key Rules
:::danger Important
- Handlers MUST return a JSON string (via
json.dumps()), never raw dicts - Errors MUST be returned as
{"error": "message"}, never raised as exceptions - The
check_fnis called when building tool definitions — if it returnsFalse, the tool is silently excluded - The
handlerreceives(args: dict, **kwargs)whereargsis the LLM's tool call arguments
:::
Step 2: Add the Built-in Tool to a Toolset
In toolsets.py, add the tool name:
# If it should be available on all platforms (CLI + messaging):
_HERMES_CORE_TOOLS = [
...
"weather", # <-- add here
]
# Or create a new standalone toolset:
"weather": {
"description": "Weather lookup tools",
"tools": ["weather"],
"includes": []
},~~Step 3: Add Discovery Import~~ (No longer needed)
Tool modules with a top-level registry.register() call are auto-discovered by discover_builtin_tools() in tools/registry.py. No manual import list to maintain — just create your file in tools/ and it's picked up at startup.
Async Handlers
If your handler needs async code, mark it with is_async=True:
async def weather_tool_async(location: str) -> str:
async with aiohttp.ClientSession() as session:
...
return json.dumps(result)
registry.register(
name="weather",
toolset="weather",
schema=WEATHER_SCHEMA,
handler=lambda args, **kw: weather_tool_async(args.get("location", "")),
check_fn=check_weather_requirements,
is_async=True, # registry calls _run_async() automatically
)The registry handles async bridging transparently — you never call asyncio.run() yourself.
Handlers That Need task_id
Tools that manage per-session state receive task_id via **kwargs:
def _handle_weather(args, **kw):
task_id = kw.get("task_id")
return weather_tool(args.get("location", ""), task_id=task_id)
registry.register(
name="weather",
...
handler=_handle_weather,
)Agent-Loop Intercepted Tools
Some tools (todo_list, memory, session_search, delegate_task) need access to per-session agent state. These are intercepted by the agent loop (agent/tool_executor.py, called from agent/conversation_loop.py) before reaching the registry. The registry still holds their schemas, but dispatch() returns a fallback error if the intercept is bypassed.
Optional: Setup Wizard Integration
If your tool requires an API key, add it to hermes_cli/config.py:
OPTIONAL_ENV_VARS = {
...
"WEATHER_API_KEY": {
"description": "Weather API key for weather lookup",
"prompt": "Weather API key",
"url": "https://weatherapi.com/",
"tools": ["weather"],
"password": True,
},
}Checklist
- Tool file created with handler, schema, check function, and registration
- Added to appropriate toolset in
toolsets.py - Confirmed this really should be a built-in/core tool and not a plugin
- Handler returns JSON strings, errors returned as
{"error": "..."} - Optional: API key added to
OPTIONAL_ENV_VARSinhermes_cli/config.py - Optional: Added to
toolset_distributions.pyfor batch processing - Tested with
hermes chat -q "Use the weather tool for London"
Developer Guide Agent Loop (`website/docs/developer-guide/agent-loop.md`)
sidebar_position: 3 title: "Agent Loop Internals"
Agent Loop Internals
The core orchestration engine is the AIAgent class. run_agent.py is now a thin facade: the loop itself lives in agent/conversation_loop.py, each turn phase in agent/turn_*.py (iteration prep, API call, API error, overflow, truncation, recovery), constructor wiring in agent/agent_init.py, and everything from prompt assembly to tool dispatch to provider failover in focused agent/*.py modules mixed into AIAgent.
Core Responsibilities
AIAgent is responsible for:
- Assembling the effective system prompt and tool schemas via
prompt_builder.py - Selecting the correct provider/API mode (chat_completions, codex_responses, anthropic_messages)
- Making interruptible model calls with cancellation support
- Executing tool calls (sequentially or concurrently via thread pool)
- Maintaining conversation history in OpenAI message format
- Handling compression, retries, and fallback model switching
- Tracking iteration budgets across parent and child agents
- Flushing persistent memory before context is lost
Two Entry Points
# Simple interface — returns final response string
response = agent.chat("Fix the bug in main.py")
# Full interface — returns dict with messages, metadata, usage stats
result = agent.run_conversation(
user_message="Fix the bug in main.py",
system_message=None, # auto-built if omitted
conversation_history=None, # auto-loaded from session if omitted
task_id="task_abc123"
)chat() is a thin wrapper around run_conversation() that extracts the final_response field from the result dict.
API Modes
Hermes supports three API execution modes, resolved from provider selection, explicit args, and base URL heuristics:
| API mode | Used for | Client type |
|---|---|---|
chat_completions |
OpenAI-compatible endpoints (OpenRouter, custom, most providers) | openai.OpenAI |
codex_responses |
OpenAI Codex / Responses API | openai.OpenAI with Responses format |
anthropic_messages |
Native Anthropic Messages API | anthropic.Anthropic via adapter |
The mode determines how messages are formatted, how tool calls are structured, how responses are parsed, and how caching/streaming works. All three converge on the same internal message format (OpenAI-style role/content/tool_calls dicts) before and after API calls.
Mode resolution order:
- Explicit
api_modeconstructor arg (highest priority) - Provider-specific detection (e.g.,
anthropicprovider →anthropic_messages) - Base URL heuristics (e.g.,
api.anthropic.com→anthropic_messages) - Default:
chat_completions
Turn Lifecycle
Each iteration of the agent loop follows this sequence:
run_conversation()
1. Generate task_id if not provided
2. Append user message to conversation history
3. Build or reuse cached system prompt (prompt_builder.py)
4. Check if preflight compression is needed (>50% context)
5. Build API messages from conversation history
- chat_completions: OpenAI format as-is
- codex_responses: convert to Responses API input items
- anthropic_messages: convert via anthropic_adapter.py
6. Inject ephemeral prompt layers (budget warnings, context pressure)
7. Apply prompt caching markers if on Anthropic
8. Make interruptible API call (_interruptible_api_call)
9. Parse response:
- If tool_calls: execute them, append results, loop back to step 5
- If text response: persist session, flush memory if needed, returnMessage Format
All messages use OpenAI-compatible format internally:
{"role": "system", "content": "..."}
{"role": "user", "content": "..."}
{"role": "assistant", "content": "...", "tool_calls": [...]}
{"role": "tool", "tool_call_id": "...", "content": "..."}Reasoning content (from models that support extended thinking) is stored in assistant_msg["reasoning"] and optionally displayed via the reasoning_callback.
Message Alternation Rules
The agent loop enforces strict message role alternation:
- After the system message:
User → Assistant → User → Assistant → ... - During tool calling:
Assistant (with tool_calls) → Tool → Tool → ... → Assistant - Never two assistant messages in a row
- Never two user messages in a row
- Only
toolrole can have consecutive entries (parallel tool results)
Providers validate these sequences and will reject malformed histories.
Interruptible API Calls
API requests are wrapped in _interruptible_api_call() which runs the actual HTTP call in a background thread while monitoring an interrupt event:
┌────────────────────────────────────────────────────┐
│ Main thread API thread │
│ │
│ wait on: HTTP POST │
│ - response ready ───▶ to provider │
│ - interrupt event │
│ - timeout │
└────────────────────────────────────────────────────┘When interrupted (user sends new message, /stop command, or signal):
- The API thread is abandoned (response discarded)
- The agent can process the new input or shut down cleanly
- No partial response is injected into conversation history
Tool Execution
Sequential vs Concurrent
When the model returns tool calls:
- Single tool call → executed directly in the main thread
- Multiple tool calls → executed concurrently via
ThreadPoolExecutor- Exception: tools marked as interactive (e.g.,
clarify) force sequential execution - Results are reinserted in the original tool call order regardless of completion order
- Exception: tools marked as interactive (e.g.,
Execution Flow
for each tool_call in response.tool_calls:
1. Resolve handler from tools/registry.py
2. Fire pre_tool_call plugin hook
3. Check if dangerous command (tools/approval.py)
- If dangerous: invoke approval_callback, wait for user
4. Execute handler with args + task_id
5. Fire post_tool_call plugin hook
6. Append {"role": "tool", "content": result} to historyAgent-Level Tools
Some tools are intercepted by agent/tool_executor.py (called from agent/conversation_loop.py) before reaching handle_function_call():
| Tool | Why intercepted |
|---|---|
todo |
Reads/writes agent-local task state |
memory |
Writes to persistent memory files with character limits |
session_search |
Queries session history via the agent's session DB |
delegate_task |
Spawns subagent(s) with isolated context |
These tools modify agent state directly and return synthetic tool results without going through the registry.
Callback Surfaces
AIAgent supports platform-specific callbacks that enable real-time progress in the CLI, gateway, and ACP integrations:
| Callback | When fired | Used by |
|---|---|---|
tool_progress_callback |
Before/after each tool execution | CLI spinner, gateway progress messages |
thinking_callback |
When model starts/stops thinking | CLI "thinking..." indicator |
reasoning_callback |
When model returns reasoning content | CLI reasoning display, gateway reasoning blocks |
clarify_callback |
When clarify tool is called |
CLI input prompt, gateway interactive message |
step_callback |
After each complete agent turn | Gateway step tracking, ACP progress |
stream_delta_callback |
Each streaming token (when enabled) | CLI streaming display |
tool_gen_callback |
When tool call is parsed from stream | CLI tool preview in spinner |
status_callback |
State changes (thinking, executing, etc.) | ACP status updates |
Budget and Fallback Behavior
Iteration Budget
The agent tracks iterations via IterationBudget:
- Default: 500 iterations (configurable via
agent.max_turns) - Each agent gets its own budget. Subagents get independent budgets capped at
delegation.max_iterations(default 50) — total iterations across parent + subagents can exceed the parent's cap - At 100%, the agent stops and returns a summary of work done
Fallback Model
When the primary model fails (429 rate limit, 5xx server error, 401/403 auth error):
- Check
fallback_providerslist in config - Try each fallback in order
- On success, continue the conversation with the new provider
- On 401/403, attempt credential refresh before failing over
- A Codex Responses turn that stalls on reasoning-only output (three consecutive continuations with no visible text or tool call) also fails over to the next fallback with reason
incomplete_response; if the stall consumed the iteration budget, the fallback gets exactly one bounded grace call
The fallback system also covers auxiliary tasks independently — vision, compression, and web extraction each have their own fallback chain configurable via the auxiliary.* config section.
Compression and Persistence
When Compression Triggers
- Preflight (before API call): If conversation exceeds 50% of model's context window
- Gateway auto-compression: If conversation exceeds 85% (more aggressive, runs between turns)
What Happens During Compression
- Memory is flushed to disk first (preventing data loss)
- Middle conversation turns are summarized into a compact summary
- The last N messages are preserved intact (
compression.protect_last_n, default: 20) - Tool call/result message pairs are kept together (never split)
- A new session lineage ID is generated (compression creates a "child" session)
Session Persistence
After each turn:
- Messages are saved to the session store (SQLite via
hermes_state.py) - Memory changes are flushed to
MEMORY.md/USER.md - The session can be resumed later via
/resumeorhermes chat --resume
Key Source Files
| File | Purpose |
|---|---|
run_agent.py |
AIAgent facade — public entry points; loop and turn phases live in agent/ |
agent/conversation_loop.py |
The agent loop (run_conversation() body) |
agent/turn_*.py |
Turn phases: iteration_prep, api_call, api_error, overflow, truncation, recovery |
agent/tool_executor.py |
Tool-call execution and agent-level tool interception |
agent/prompt_builder.py |
System prompt assembly from memory, skills, context files, personality |
agent/context_engine.py |
ContextEngine ABC — pluggable context management |
agent/context_compressor.py |
Default engine — lossy summarization algorithm |
agent/prompt_caching.py |
Anthropic prompt caching markers and cache metrics |
agent/auxiliary_client.py |
Auxiliary LLM client for side tasks (vision, summarization) |
model_tools.py |
Tool schema collection, handle_function_call() dispatch |
Related Docs
- Provider Runtime Resolution
- Prompt Assembly
- Context Compression & Prompt Caching
- Tools Runtime
- Architecture Overview
Developer Guide Architecture (`website/docs/developer-guide/architecture.md`)
sidebar_position: 1 title: "Architecture"
Architecture
This page is the top-level map of Hermes Agent internals. Use it to orient yourself in the codebase, then dive into subsystem-specific docs for implementation details.
System Overview
/* Detailed source-code truncated for AI context efficiency. */Directory Structure
/* Detailed source-code truncated for AI context efficiency. */Data Flow
CLI Session
User input → HermesCLI.process_input()
→ AIAgent.run_conversation()
→ prompt_builder.build_system_prompt()
→ runtime_provider.resolve_runtime_provider()
→ API call (chat_completions / codex_responses / anthropic_messages)
→ tool_calls? → model_tools.handle_function_call() → loop
→ final response → display → save to SessionDBGateway Message
Platform event → Adapter.on_message() → MessageEvent
→ GatewayRunner._handle_message()
→ authorize user
→ resolve session key
→ create AIAgent with session history
→ AIAgent.run_conversation()
→ deliver response back through adapterCron Job
Scheduler tick → load due jobs from jobs.json
→ create fresh AIAgent (no history)
→ inject attached skills as context
→ run job prompt
→ deliver response to target platform
→ update job state and next_runRecommended Reading Order
If you are new to the codebase:
- This page — orient yourself
- Agent Loop Internals — how AIAgent works
- Prompt Assembly — system prompt construction
- Provider Runtime Resolution — how providers are selected
- Adding Providers — practical guide to adding a new provider
- Tools Runtime — tool registry, dispatch, environments
- Session Storage — SQLite schema, FTS5, session lineage
- Gateway Internals — messaging platform gateway
- Context Compression & Prompt Caching — compression and caching
- ACP Internals — IDE integration
Major Subsystems
Agent Loop
The synchronous orchestration engine (AIAgent, exposed by the run_agent.py facade; the loop lives in agent/conversation_loop.py and agent/turn_*.py). Handles provider selection, prompt construction, tool execution, retries, fallback, callbacks, compression, and persistence. Supports three API modes for different provider backends.
Prompt System
Prompt construction and maintenance across the conversation lifecycle:
system_prompt.py+prompt_builder.py— assembles the ordered system-prompt tiers (stable→context→volatile): identity/tool guidance/skills, context files, then memory/profile/timestamp blocksprompt_caching.py— Applies Anthropic cache breakpoints for prefix cachingcontext_compressor.py— Summarizes middle conversation turns when context exceeds thresholds
→ Prompt Assembly, Context Compression & Prompt Caching
Provider Resolution
A shared runtime resolver used by CLI, gateway, cron, ACP, and auxiliary calls. Maps (provider, model) tuples to (api_mode, api_key, base_url). Handles 18+ providers, OAuth flows, credential pools, and alias resolution.
Tool System
Central tool registry (tools/registry.py) with 70+ registered tools across ~28 toolsets. Each tool file self-registers at import time. The registry handles schema collection, dispatch, availability checking, and error wrapping. Terminal tools support 7 backends (local, Docker, SSH, Daytona, Modal, Singularity, Vercel Sandbox).
Session Persistence
SQLite-based session storage with FTS5 full-text search. Sessions have lineage tracking (parent/child across compressions), per-platform isolation, and atomic writes with contention handling.
Messaging Gateway
Long-running process with 25+ platform adapters (built-in + bundled plugins), unified session routing, user authorization (allowlists + DM pairing), slash command dispatch, hook system, cron ticking, and background maintenance.
Plugin System
Three discovery sources: ~/.hermes/plugins/ (user), .hermes/plugins/ (project), and pip entry points. Plugins register tools, hooks, and CLI commands through a context API. Two specialized plugin types exist: memory providers (plugins/memory/) and context engines (plugins/context_engine/). Both are single-select — only one of each can be active at a time, configured via hermes plugins or config.yaml.
→ Plugin Guide, Memory Provider Plugin
Cron
First-class agent tasks (not shell tasks). Jobs store in JSON, support multiple schedule formats, can attach skills and scripts, and deliver to any platform.
ACP Integration
Exposes Hermes as an editor-native agent over stdio/JSON-RPC for VS Code, Zed, and JetBrains.
Trajectories
Generates ShareGPT-format trajectories from agent sessions for training data generation.
→ Trajectories & Training Format
Design Principles
| Principle | What it means in practice |
|---|---|
| Prompt stability | System prompt doesn't change mid-conversation. No cache-breaking mutations except explicit user actions (/model). |
| Observable execution | Every tool call is visible to the user via callbacks. Progress updates in CLI (spinner) and gateway (chat messages). |
| Interruptible | API calls and tool execution can be cancelled mid-flight by user input or signals. |
| Platform-agnostic core | One AIAgent class serves CLI, gateway, ACP, batch, and API server. Platform differences live in the entry point, not the agent. |
| Loose coupling | Optional subsystems (MCP, plugins, memory providers, RL environments) use registry patterns and check_fn gating, not hard dependencies. |
| Profile isolation | Each profile (hermes -p <name>) gets its own HERMES_HOME, config, memory, sessions, and gateway PID. Multiple profiles run concurrently. |
File Dependency Chain
tools/registry.py (no deps — imported by all tool files)
↑
tools/*.py (each calls registry.register() at import time)
↑
model_tools.py (imports tools/registry + triggers tool discovery)
↑
run_agent.py, cli.py, batch_runner.py, environments/This chain means tool registration happens at import time, before any agent instance is created. Any tools/*.py file with a top-level registry.register() call is auto-discovered — no manual import list needed.
Developer Guide Billing Lifecycle (`website/docs/developer-guide/billing-lifecycle.md`)
title: "Billing Lifecycle (TUI)"
Billing lifecycle: client-side state, errors, and recovery
This is the map from every billing.*/subscription.* state shape the gateway
serves (from NAS) to what the terminal actually renders, and from every typed
refusal/error code to its exact user-facing copy and recovery action. The
guarantee: no NAS billing state and no typed refusal falls through to a
generic toast — every case below is an explicit branch inui-tui/src/app/slash/commands/topup.ts, ui-tui/src/components/billingOverlay.tsx,
or ui-tui/src/components/subscriptionOverlay.tsx. An unknown code still
degrades gracefully: it hits the default branch (a generic-but-real message
pulled from the server payload, never a blank toast) rather than crashing or
silently dropping the refusal.
1. `billing.state` shapes → render
Source: ui-tui/src/components/billingOverlay.tsx (OverviewScreen,BuyScreen, AutoReloadScreen), ui-tui/src/app/slash/commands/topup.ts (/topup run).
| State shape | Render |
|---|---|
Logged out (s.logged_in === false) |
Overlay never opens. sys: 💳 Not logged into Nous Portal — run /portal to log in, then /topup. |
billing.state RPC fetch fails (transport/timeout) |
Fail-closed: .catch(ctx.guardedErr) — overlay never opens, no state is assumed. sys: error: <message or "request failed">. Never renders "no card" or any other guessed state; user must retry /topup. |
card: null (no saved card), full menu (is_admin && cli_billing_enabled) |
Overview shows No saved card on file — "Add funds" walks you through adding one. "Add funds" opens the add-card path: Add a card on the portal / I've added it — check again / Back (never an amount picker, which would 403 no_payment_method). |
card present, resolved_via set |
Card: {display} (e.g. Visa ····4242 — the card on your subscription) using the provenance-aware display field. |
card present, resolved_via absent (older NAS) |
Falls back to the generic Card: {masked}; Confirm screen adds Your card saved on the portal will be charged. |
auto_reload: null |
No auto-reload line at all (autoReloadLine returns null) — the feature isn't surfaced. |
auto_reload.card.kind: 'canonical' |
No distinct-card warning; card line falls back to the card on file. |
auto_reload.card.kind: 'distinct' |
⚠ Auto-refill is charging {brand} ••{last4} — not your card on file. in the Auto-reload screen (the divergence notice). |
auto_reload.card.kind: 'none' |
Same as canonical rendering-wise — no distinct-card warning shown. |
monthly_cap present, limit_usd != null |
{spent_display} of {limit_display} used this month (+ (default ceiling) iff is_default_ceiling). |
monthly_cap absent or limit_usd == null |
No monthly cap visible (managed on the portal). |
Role without billing capability (!is_admin, menu collapses) |
Note: Billing actions need someone with billing permissions (owner, admin, or finance admin). Menu collapses to Manage on portal / Cancel. |
Org kill-switch off (is_admin but !cli_billing_enabled) |
Note: Remote spending is off for this org — a billing admin can turn it on from the portal's Hermes Agent page. Same collapsed menu. |
Note: full = s.is_admin && s.cli_billing_enabled gates the org-level
switch, not the per-terminal billing:manage scope — that's discovered
reactively (a charge 403s insufficient_scope) and routes to the resumable
step-up screen instead of a preflight check.
2. Refusal codes (`renderBillingError`, in code order)
Source: renderBillingError in ui-tui/src/app/slash/commands/topup.ts:37-149.
"Portal" row = sys('Portal: {portal_url}') is appended whenever portal_url is present, for every code (including default).
error code |
Copy | Portal URL | retry_after |
|---|---|---|---|
insufficient_scope |
This needs Remote Spending allowed. Start a top-up to allow it, then retry. |
if present | — |
remote_spending_revoked (CF-4) |
{An admin stopped remote spending for this terminal. | You stopped remote spending for this terminal.} (by actor) Reconnect to restore — run /portal to re-authorize this terminal. Also clears billing overlay state immediately (doesn't wait for token refresh). |
if present | — |
session_revoked |
Your session was logged out. Run /portal to log in again. Also clears billing overlay state. |
if present | — |
cli_billing_disabled / remote_spending_disabled (dual-emitted) |
Remote spending is off for this account — a billing admin can turn it on from the portal's Hermes Agent page. |
if present | — |
role_required |
Adding funds needs someone with billing permissions (owner, admin, or finance admin), or manage this on the portal. |
if present | — |
consent_required |
This action needs a one-time card confirmation and consent step on the portal before it can proceed. |
if present | — |
org_access_denied |
This token isn't bound to an org you can manage. Sign in with the right org, or manage this on the portal. |
if present | — |
upgrade_cap_exceeded |
🔴 Daily plan-change limit reached (5 per org) — try again tomorrow, or manage this on the portal. |
if present | — |
auto_top_up_disabled_failures |
Auto-reload was turned off after repeated charge failures. Fix the card issue, then re-enable it from /topup → Auto-reload. |
if present | — |
idempotency_conflict |
🔴 That charge key was already used for a different amount. Start a fresh top-up. |
if present | — |
no_payment_method |
💳 No saved card for terminal charges yet. Set one up on the portal (one-time credit buys don't save a reusable card). |
if present | — |
monthly_cap_exceeded |
🔴 Monthly spend cap reached — ${remainingUsd} headroom left. if payload.remainingUsd present, else 🔴 Monthly spend cap reached. |
if present | — |
rate_limited / temporarily_unavailable |
🟡 Too many charges right now{ (try again in ~N min)}. This isn't a payment failure. |
if present | yes — minutes computed as max(1, round(retry_after/60)) |
stripe_unavailable |
🟡 Stripe is having trouble right now — try again shortly{ (try again in ~N min)}. |
if present | yes (same formula) |
| default (unknown/other) | 🔴 {message || error || 'Billing request failed.'} — still surfaces whatever the server said, never a blank toast. |
if present | — |
3. Charge settlement outcomes (`pollCharge` / `renderChargeFailed`)
Source: pollCharge (ui-tui/src/app/slash/commands/topup.ts:170-258) andrenderChargeFailed (:260-290). Poll cadence: 2s interval, 5-minute cap
(POLL_INTERVAL_MS=2000, POLL_CAP_MS=5*60*1000), applied on every
non-terminal path (pending and throttled), so a sustained 429/503 can't
keep the poll alive forever.
| Outcome | Copy | Notes |
|---|---|---|
status: 'settled' |
✅ ${amount_usd} added. (or ✅ Credits added. if no amount) |
Terminal success. |
status: 'failed', reason: 'authentication_required' |
🔴 Your bank requires verification (3DS). Complete it on the portal to finish this purchase. |
+ Portal: line if portalUrl. |
status: 'failed', reason: 'payment_method_expired' |
🔴 Your card has expired. Update it on the portal. |
+ Portal: line. |
status: 'failed', reason: 'card_declined' |
🔴 Your card was declined. Try another card on the portal. |
+ Portal: line. |
status: 'failed', reason: 'processing_error' |
🔴 The charge didn't go through (processing_error). |
+ Portal: line. |
status: 'failed', unrecognized/missing reason |
🔴 The charge didn't go through ({reason || 'processing_error'}). |
Same portal funnel — parity with cli.py's _billing_portal_hint. |
Poll timeout (still pending past the 5-min cap) |
🟡 Still processing after 5 minutes — this is a timeout, not a failure. Check /topup or the portal shortly. |
+ Portal: line if portalUrl. Explicitly NOT called a failure. |
Revocation mid-poll (remote_spending_revoked / session_revoked while polling) |
Renders the matching §2 copy, then appends: 🟡 Your last charge's outcome is unconfirmed — check your balance/history before retrying. |
CF-7 rule 4: a post-revoke 403 while polling is ambiguous (the charge may have already settled) — never call it "failed". |
429/503 while polling (rate_limited/temporarily_unavailable/stripe_unavailable) |
No error shown; backs off using retry_after (default 5s, capped at 30s) and keeps polling until the 5-min cap, then reads as timeout. |
Not a payment failure. |
Other !ok status-check error |
🔴 Could not check the charge: {message || error || 'error'} |
|
| Transport loss (poll RPC throws/rejects) | 🟡 Your last charge's outcome is unconfirmed — check your balance/history before retrying. (UNCONFIRMED_CHARGE_MESSAGE) |
Same "unconfirmed, check balance" framing as revocation mid-poll — a dropped connection can never be read as "failed". |
4. Subscription preview / pending-change / upgrade outcomes
Source: previewAndRoute, applyPendingAndRoute, upgradeResult,stepUpDenialResult in ui-tui/src/components/subscriptionOverlay.tsx.
Preview effect values (drive the Confirm screen):
effect |
Confirm screen copy | Primary action |
|---|---|---|
charge_now |
Upgrade to {target}. You will be charged {amount} now (prorated). (+ monthly-credits delta, + which card if resolver confidently knows) |
Pay {amount} & upgrade now |
scheduled |
Change to {target} — takes effect {date}. No charge now; you keep your current plan until then. |
Schedule change to {target} |
no_op |
You are already on {target} — nothing to change. |
none (Back only) |
blocked |
{preview.reason} or fallback That change cannot be made here — manage it on the portal. |
Manage on portal |
Preview RPC returns null/transport failure |
routes straight to Result: Could not preview that change. |
— |
Preview !ok, insufficient_scope |
routes to stepup screen ({kind:'preview', tierId}) |
— |
Preview !ok, other error |
routes to Result with errorResult(p) (message || error || 'Something went wrong. Try again, or manage on the portal.') |
— |
Pending-change apply outcomes (applyPendingAndRoute):
pending.kind |
Success copy |
|---|---|
cancellation |
Scheduled — your plan stays active until the end of the billing period, then it cancels. Nothing changes today. |
tier_change (downgrade/schedule) |
Scheduled — your plan doesn't change today. You keep your current plan until the end of the billing period, then it switches. |
upgrade |
routed through upgradeResult (below) |
any kind, mutation insufficient_scope |
routes to stepup ({kind:'apply'}) |
Upgrade status × reason matrix (upgradeResult, checked in this
order — reason is checked before status):
| Condition | Result |
|---|---|
r === null (transport failure on the charging route) |
Couldn't confirm the upgrade — your card may or may not have been charged. Re-run /subscription to check your plan before trying again. — ambiguous, never a blind retry. |
reason: 'authentication_required' or reason: 'subscription_payment_intent_requires_action' |
Please verify your card in the portal to finish this upgrade. → recovery_url. Both reasons map to the same SCA copy — the client branches on reason, not status, specifically so an SCA case that pre-#711 NAS mislabels with status: 'payment_failed' (no distinguishing reason yet) still routes to the correct "verify your card" copy instead of reading as a hard decline. |
reason: 'card_declined' |
Your card was declined — try a different card on the portal. → recovery_url. |
ok && status: 'already_on_tier' |
You are already on {target_tier_name}. (success) |
ok && status: 'upgraded' |
Upgraded to {target_tier_name}. Your new monthly credits land in a moment. — starts the eventual-consistency apply-poll (below). |
status: 'requires_action' (no distinguishing reason) |
This upgrade needs extra verification (3DS). Finish it on the portal. → recovery_url. |
status: 'payment_failed' (no distinguishing reason) |
Your card was declined. Update your payment method on the portal and try again. → recovery_url. |
| anything else | errorResult(r): message || error || 'Something went wrong. Try again, or manage on the portal.' |
Eventual-consistency apply-poll (ResultScreen, only after status: 'upgraded'): polls billing/subscription state every 2s
(UPGRADE_CONFIRM_INTERVAL_MS) up to 15 attempts
(UPGRADE_CONFIRM_ATTEMPTS, i.e. ~30s) until current.tier_id flips to the
target. While waiting the screen reads Applying…; if it never flips inside
the budget it reads Still applying / Your upgrade succeeded and is still applying — refresh in a moment. — the upgrade is never re-reported as failed
just because NAS hasn't caught up yet.
Step-up denial copy (stepUpDenialResult, subscription flow):
error |
Copy |
|---|---|
session_revoked |
Your session expired — run /portal to log in again, then retry the change. |
remote_spending_revoked |
{message} or Remote spending was stopped for this terminal — reconnect from the portal, then retry. |
rate_limited |
Too many attempts — wait a moment, then try again. |
| other/unknown | {message} or Remote Spending was not allowed — someone with billing permissions (owner, admin, or finance admin) must approve it. You can also make this change on the portal. |
A repeat scope denial during a post-grant replay never re-enters the
step-up screen (it's already mounted there — re-patching would freeze it);allowStepUp=false instead surfaces a terminal result: Remote Spending still isn’t active for this terminal — the authorization didn’t take. Retry, or make this change on the portal.
Text-mode (CLI) parity
cli.py's _show_billing / _billing_overview and _show_subscription /_subscription_overview render the same state shapes (balance title, two-bar
dollar usage, auto-reload line, card line, monthly cap) and share the
"fail-open on logged-out/portal-hiccup, never crash" discipline. The CLI's/subscription gives a paid admin/owner in an interactive context the full
in-terminal change flow (tier picker → preview → confirm → apply, parity
with the TUI overlay); members and non-interactive contexts fall back to_billing_portal_hint's deep-link to subscription_manage_url. /topup's
interactive modal (prompt_toolkit) mirrors the TUI overlay the same way, and
non-interactive contexts fall back to the same text + portal-link rendering,
never prompting.
| CLI surface / state | Behavior (parity with TUI / desktop) |
|---|---|
/subscription on Free + admin/owner + interactive |
_subscription_free_catalog prints the plan catalog from the same tiers[] data the TUI uses — one row per enabled paid tier, cheapest first, name · $/mo · $credits/mo (monthly credits are DOLLARS → $22 credits/mo, never a bare number). A numbered pick opens the /manage-subscription deep-link with plan=<tier_id> appended so the portal preselects the chosen plan. Starting a new subscription needs a fresh card, so the only action is the portal hand-off (the terminal never charges here). |
| Any CLI-built manage/subscribe URL | subscription_manage_url(state, tier_id=…) appends plan=<tier_id> (the stable tiers[] id, never a name/slug) only when a tier was picked (the Free catalog). The portal validates it server-side and ignores an unknown tier, so the CLI appends unconditionally on a pick, mirroring the TUI's ?plan=. org_id is emitted first, plan second. |
| Downgrades in the CLI | Stay native / in-app for normal changes (chargeless scheduling via put_subscription_pending_change). A blocked downgrade may still print the generic manage URL, but it never carries plan=<tier_id> — selected-tier deep-links are reserved for new subscriptions and upgrades. |
/topup overview action copy |
Splits one-time top-up from automatic refill, the distinction stated up front in each first sentence: Add funds now — a single charge, added to your balance today. vs Refill when low — charges $X automatically when your balance falls below $Y. ("credits" stays out of the dollars-only /topup surface — "Add funds now" carries the one-time meaning without it). When auto-reload is off, the automatic line omits concrete amounts. |
Forward compatibility
Any error/status/reason code not in the tables above lands on thedefault branch in renderBillingError (§2) or errorResult/upgradeResult's
fallthrough (§4): it still renders the server's own message (never blank,
never a crash), just without bespoke copy or a typed recovery affordance.
NAS W3 introduces card-health codes (card_paused, card_expired,card_mismatch) that are not yet typed here — until a client update adds
explicit branches, they will arrive as unknown codes and degrade to this
default path.
Developer Guide Browser Provider Plugin (`website/docs/developer-guide/browser-provider-plugin.md`)
sidebar_position: 13 title: "Browser Provider Plugins"
Building a Browser Provider Plugin
Browser provider plugins register a cloud browser backend that services cloud-mode browser_* tool calls (navigate, click, screenshot, …). Built-in providers — Browserbase, Browser Use, and Firecrawl — all ship as plugins under plugins/browser/<name>/. You can add a new one, or override a bundled one, by dropping a directory next to them.
:::tip
Browser backends are one of several backend plugins Hermes supports. The others (with their own ABCs) are Web Search Provider Plugins (which this ABC deliberately mirrors), Image Generation, Video Generation, Memory Providers, Context Engines, Secret Sources, and Model Providers. General tool/hook/CLI plugins live in Build a Hermes Plugin.
:::
How it fits together
A browser provider does not implement browsing. It implements session lifecycle: create a remote browser session, hand back a CDP websocket URL, and tear the session down. Hermes' own browser stack (agent-browser + tools/browser_tool.py) connects to whatever CDP URL you return and drives the page from there — every provider gets the full browser_* toolset for free.
The active provider is selected by browser.cloud_provider in config.yaml; the dispatcher in tools/browser_tool.py is a pure registry lookup with no per-provider conditionals.
Discovery
Hermes scans for browser backends in three places:
- Bundled —
<repo>/plugins/browser/<name>/(auto-loaded withkind: backend) - User —
~/.hermes/plugins/browser/<name>/(opt-in viaplugins.enabledorhermes plugins enable <name>) - Pip — packages declaring a
hermes_agent.pluginsentry point
Each plugin's register(ctx) calls ctx.register_browser_provider(...), which puts the instance into the registry in agent/browser_registry.py.
Directory structure
plugins/browser/my-backend/
├── __init__.py # register() entry point
├── provider.py # BrowserProvider subclass
└── plugin.yaml # Manifest with kind: backend and provides_browser_providersplugin.yaml:
name: browser-my-backend
version: 1.0.0
description: "My cloud browser backend. Requires MY_BACKEND_API_KEY."
author: you
kind: backend
provides_browser_providers:
- my-backend__init__.py:
from plugins.browser.my_backend.provider import MyBackendProvider
def register(ctx) -> None:
ctx.register_browser_provider(MyBackendProvider())The BrowserProvider ABC
Implement agent.browser_provider.BrowserProvider. Three lifecycle methods plus identity:
/* Detailed source-code truncated for AI context efficiency. */The session-metadata contract
create_session() must return at least session_name, bb_session_id, cdp_url, and features. Two quirks worth knowing:
bb_session_idis a legacy key name kept verbatim for backward compatibility withtools/browser_tool.py— it holds your provider's session ID regardless of vendor. Don't rename it.create_session()may raise —ValueErrorfor missing credentials,RuntimeErrorfor network/API failures. The dispatcher surfaces these to the user. This differs fromclose_session/emergency_cleanup, which must never raise.
An optional external_call_id key supports managed-gateway billing.
`get_setup_schema()` — the `hermes tools` picker row
Override this to appear as a first-class option in the Browser Automation picker with API-key prompts and an install hook:
def get_setup_schema(self) -> dict:
return {
"name": "My Backend",
"badge": "paid",
"tag": "Cloud browser with stealth and proxies",
"env_vars": [
{"key": "MY_BACKEND_API_KEY",
"prompt": "My Backend API key",
"url": "https://mybackend.example"},
],
"post_setup": "agent_browser", # ensures local Chromium is installed (agent-browser itself resolves via npx)
}Per the project standard for tool backends: if a backend can't be selected and configured through hermes tools, it isn't done — "set this env var manually" is not an integration.
Users configure it
browser:
cloud_provider: my-backendReference implementations
The three bundled providers under plugins/browser/ are the canonical examples, in ascending complexity: firecrawl (simplest), browser_use, and browserbase (stealth/proxy/keep-alive feature flags with graceful fallback when paid features are unavailable). Copy the closest one.
Checklist
-
nameis lowercase and stable (it's a config value users write) -
is_available()makes zero network calls -
create_session()returns the full metadata contract (bb_session_idkey name intact) -
close_session()/emergency_cleanup()never raise -
get_setup_schema()exposes your env vars sohermes toolscan configure the backend -
plugin.yamldeclareskind: backend+provides_browser_providers
Developer Guide Browser Supervisor (`website/docs/developer-guide/browser-supervisor.md`)
sidebar_position: 18 title: "Browser CDP Supervisor"
Browser CDP Supervisor
The CDP supervisor closes two long-standing gaps in Hermes' browser tooling:
- Native JS dialogs (
alert/confirm/prompt/beforeunload) block the
page's JS thread. Without supervision, the agent has no way to know a
dialog is open — subsequent tool calls hang or throw opaque errors. - Cross-origin iframes (OOPIFs) are invisible to top-level
Runtime.evaluate. The agent can see iframe nodes in the DOM snapshot but
can't click, type, or eval inside them without a CDP session attached to
the child target.
The supervisor solves both by holding a persistent WebSocket to the backend's
CDP endpoint per browser task, surfacing pending dialogs and frame structure
into browser_snapshot, and exposing a browser_dialog tool for explicit
responses.
Backend support
| Backend | Dialog detect | Dialog respond | Frame tree | OOPIF Runtime.evaluate via browser_cdp(frame_id=...) |
|---|---|---|---|---|
Local Chrome (--remote-debugging-port) / /browser connect |
✓ | ✓ full workflow | ✓ | ✓ |
| Browserbase | ✓ (via bridge) | ✓ full workflow (via bridge) | ✓ | ✓ |
| Camofox | ✗ no CDP (REST-only) | ✗ | partial via DOM snapshot | ✗ |
Browserbase quirk. Browserbase's CDP proxy uses Playwright internally and
auto-dismisses native dialogs within ~10ms, so Page.handleJavaScriptDialog
can't keep up. The supervisor injects a bridge script viaPage.addScriptToEvaluateOnNewDocument that overrideswindow.alert/confirm/prompt with a synchronous XHR to a magic host
(hermes-dialog-bridge.invalid). Fetch.enable intercepts those XHRs before
they touch the network — the dialog becomes a Fetch.requestPaused event the
supervisor captures, and respond_to_dialog fulfills viaFetch.fulfillRequest with a JSON body the injected script decodes.
From the page's perspective, prompt() still returns the agent-supplied
string. From the agent's perspective, it's the same browser_dialog(action=...)
API either way.
Camofox is unsupported — no CDP surface, REST-only.
Architecture
CDPSupervisor
One asyncio.Task running in a background daemon thread per Hermes task_id.
Holds a persistent WebSocket to the backend's CDP endpoint. Maintains:
- Dialog queue —
List[PendingDialog]with{id, type, message, default_prompt, session_id, opened_at} - Frame tree —
Dict[frame_id, FrameInfo]with parent relationships, URL, origin, whether cross-origin child session - Session map —
Dict[session_id, SessionInfo]so interaction tools can route to the right attached session for OOPIF operations - Recent console errors — ring buffer of the last 50 for diagnostics
Subscribes on attach:
Page.enable—javascriptDialogOpening,frameAttached,frameNavigated,frameDetachedRuntime.enable—executionContextCreated,consoleAPICalled,exceptionThrownTarget.setAutoAttach {autoAttach: true, flatten: true}— surfaces child OOPIF targets; supervisor enablesPage+Runtimeon each
Thread-safe state access via a snapshot lock; tool handlers (sync) read the
frozen snapshot without awaiting.
Lifecycle
- Start:
SupervisorRegistry.get_or_start(task_id, cdp_url)— called bybrowser_navigate, Browserbase session create,/browser connect.
Idempotent. - Stop: session teardown or
/browser disconnect. Cancels the asyncio
task, closes the WebSocket, discards state. - Dropped endpoint: after a successful attach the supervisor reconnects with
backoff (≤10 s) but gives up afterMAX_POST_ATTACH_RECONNECT_FAILURES
consecutive failures — one final warning, the thread exits and the registry
entry is dropped. A dead local Chrome (its task finished) therefore never leaves
a retrying thread behind; the next browser call starts a fresh supervisor. - Rebind: if the CDP URL changes (user reconnects to a new Chrome), the
old supervisor is stopped and a fresh one started — state is never reused
across endpoints.
Dialog policy
Configurable via config.yaml under browser.dialog_policy:
must_respond(default) — capture, surface inbrowser_snapshot, wait
for explicitbrowser_dialog(action=...)call. After a 300s safety timeout
with no response, auto-dismiss and log. Prevents a buggy agent from stalling
forever.auto_dismiss— record and dismiss immediately; agent sees it after the
fact viabrowser_stateinsidebrowser_snapshot.auto_accept— record and accept (useful forbeforeunloadwhere the
workflow wants to navigate away cleanly).
Policy is per-task; no per-dialog overrides.
Trusted plugin CDP access
In-process plugin code can send raw CDP commands over a task's supervisor
connection through one public call, instead of writing the supervisor's private
call table or socket:
from tools.browser_supervisor import SUPERVISOR_REGISTRY
from tools.browser_supervisor_capture import CapturedCDPInvalid
cdp = SUPERVISOR_REGISTRY.capture(task_id) # raises CapturedCDPInvalid if no attached supervisor
reply = cdp.call("Runtime.evaluate", {"expression": "document.title", "returnByValue": True},
session_id=cdp.page_session_id, timeout=5)
title = reply["result"]["result"]["value"]SUPERVISOR_REGISTRY.capture(task_id, *, timeout=10.0) -> CapturedCDPpins the
supervisor's current WebSocket and the default page session attached on it. It
never starts, reconnects or refocuses a supervisor.CapturedCDP.call(method, params=None, *, session_id=None, timeout=10.0, before_send=None) -> dict
returns the raw CDP reply ({"id", "result"}).session_id=Noneaddresses the
browser endpoint (Target.*); passpage_session_idor a session you attached
yourself for page domains. A CDP error reply raisesRuntimeError; no reply
within a finitetimeoutraisesTimeoutError. Finite timeouts fence queue
admission with a monotonic deadline.timeout=Noneretains the reply until
response or connection closure; acquisition owners can keep late attach IDs
for cleanup beyond their own outer deadline. The call blocks its thread, so call it
from a worker thread, never from the supervisor's own event loop.CapturedCDP.is_valid()stays true until the supervisor reconnects, stops, or
is replaced in the registry. After that everycallraisesCapturedCDPInvalid, including a call already in flight when the socket
drops. A handle never follows the supervisor to a new connection: capture
again and re-check whatever state you depend on.page_session_idis the default page session at capture time. A laterfocus_pageon the supervisor does not change it.- Sessions you attach (
Target.attachToTarget) are yours to detach. A reply
that arrives aftertimeoutis dropped, so a timed-out attach can leave a
session attached until the connection closes. Usetimeout=Nonewith a retained
acquisition worker to own and detach late replies, rather than guessing a large timeout. before_sendis a trusted synchronous, nonblocking validator on the supervisor
loop immediately before dispatch. ReturnNone, or raise to refuse; do not call
capture/CDP APIs from it. It does not revoke commands already dispatched.
This is a trusted, in-process seam. It grants nothing in-process Python could
not already reach, and it enforces no origin, consent or target-ownership
policy: a plugin that acts on pages must enforce its own. It is not exposed as a
model tool and has no config switch.
Agent surface
`browser_dialog` tool
browser_dialog(action, prompt_text=None, dialog_id=None)action="accept"/"dismiss"→ responds to the specified or sole pending dialog (required)prompt_text=...→ text to supply to aprompt()dialogdialog_id=...→ disambiguate when multiple dialogs are queued (rare)
Tool is response-only. The agent reads pending dialogs from browser_snapshot
output before calling.
`browser_snapshot` extension
Adds three optional fields to the existing snapshot output when a supervisor
is attached:
{
"pending_dialogs": [
{"id": "d-1", "type": "alert", "message": "Hello", "opened_at": 1650000000.0}
],
"recent_dialogs": [
{"id": "d-1", "type": "alert", "message": "...", "opened_at": 1650000000.0,
"closed_at": 1650000000.1, "closed_by": "remote"}
],
"frame_tree": {
"top": {"frame_id": "FRAME_A", "url": "https://example.com/", "origin": "https://example.com"},
"children": [
{"frame_id": "FRAME_B", "url": "about:srcdoc", "is_oopif": false},
{"frame_id": "FRAME_C", "url": "https://ads.example.net/", "is_oopif": true, "session_id": "SID_C"}
],
"truncated": false
}
}pending_dialogs— dialogs currently blocking the page's JS thread.
The agent must callbrowser_dialog(action=...)to respond. Empty on
Browserbase because their CDP proxy auto-dismisses within ~10ms.recent_dialogs— ring buffer of up to 20 recently-closed dialogs with
aclosed_bytag:"agent"(we responded),"auto_policy"(local
auto_dismiss/auto_accept),"watchdog"(must_respond timeout hit), or"remote"(browser/backend closed it on us, e.g. Browserbase). This is
how agents on Browserbase still get visibility into what happened.frame_tree— frame structure including cross-origin (OOPIF) children.
Capped at 30 entries + OOPIF depth 2 to bound snapshot size on ad-heavy
pages.truncated: truesurfaces when limits were hit; agents needing
the full tree can usebrowser_cdpwithPage.getFrameTree.
No new tool schema surface for any of these — the agent reads the snapshot it
already requests.
Availability gating
Both surfaces gate on _browser_cdp_check (supervisor can only run when a CDP
endpoint is reachable). On Camofox / no-backend sessions, the dialog tool is
hidden and the snapshot omits the new fields — no schema bloat.
Cross-origin iframe interaction
browser_cdp(frame_id=...) routes CDP calls (notably Runtime.evaluate)
through the supervisor's already-connected WebSocket using the OOPIF's childsessionId. Agents pick frame_ids out ofbrowser_snapshot.frame_tree.children[] where is_oopif=true and pass them
to browser_cdp. For same-origin iframes (no dedicated CDP session), the
agent uses contentWindow/contentDocument from a top-levelRuntime.evaluate instead — the supervisor surfaces an error pointing at that
fallback when frame_id belongs to a non-OOPIF.
On Browserbase, this is the only reliable path for iframe interaction —
stateless CDP connections (opened per browser_cdp call) hit signed-URL
expiry, while the supervisor's long-lived connection keeps a valid session.
File layout
tools/browser_supervisor.py—CDPSupervisor,SupervisorRegistry,PendingDialog,FrameInfotools/browser_supervisor_capture.py—CapturedCDP/CapturedCDPInvalid, the trusted-plugin CDP seam behindSUPERVISOR_REGISTRY.capturetools/browser_dialog_tool.py—browser_dialogtool handlertools/browser_tool.py—browser_navigatestart-hook,browser_snapshotmerge,/browser connectreattach,_cleanup_browser_sessionteardowntoolsets.py— registersbrowser_dialoginbrowser,hermes-acp,hermes-api-server, and core toolsets (gated on CDP reachability)hermes_cli/config.py—browser.dialog_policyandbrowser.dialog_timeout_sdefaults
Non-goals
- Detection/interaction for Camofox (upstream gap; tracked separately)
- Streaming dialog/frame events live to the user (would require gateway hooks)
- Persisting dialog history across sessions (in-memory only)
- Per-iframe dialog policies (agent can express this via
dialog_id) - Replacing
browser_cdp— it stays as the escape hatch for the long tail (cookies, viewport, network throttling)
Testing
Unit tests (tests/tools/test_browser_supervisor.py) use an asyncio mock CDP
server that speaks enough of the protocol to exercise all state transitions:
attach, enable, navigate, dialog fire, dialog dismiss, frame attach/detach,
child target attach, session teardown. Real-backend E2E (Browserbase + local
Chromium-family browser) is manual — exercise via /browser connect to a
live Chromium-family browser and run the dialog/frame test cases described
above.
Developer Guide Chronos Managed Cron Contract (`website/docs/developer-guide/chronos-managed-cron-contract.md`)
title: "Chronos Managed-Cron Contract"
Chronos managed-cron — agent ↔ NAS wire contract
Status: authoritative wire spec for the Chronos cron provider.
Audience: the NAS-side implementer of the agent-cron endpoints
(nous-account-service) and anyone debugging the managed-cron path.
Chronos lets a hosted Hermes gateway scale to zero while idle and still
fire cron jobs. Instead of an in-process 60-second ticker, the agent asks NAS
to arm exactly one external one-shot per job at that job's real next-fire
time. NAS calls the agent back at fire time over an authenticated webhook;
the agent runs the job and re-arms the next one-shot. Between fires the agent
process can be fully stopped — it wakes only on a genuine fire.
The external scheduler NAS uses to implement the one-shots is an internal NAS
implementation detail. The agent never talks to it, never holds its
credentials, and never names it. The agent only knows the three NAS endpoints
below.
create/update/pause/resume/remove a cron job (agent side)
│
▼
ChronosCronScheduler.reconcile() ── agent computes next_run_at
│ POST {portal}/api/agent-cron/provision (auth: agent's Nous access token)
▼
NAS arms a one-shot for fire_at ── NAS owns the scheduler + its creds
│
⏰ at fire_at
▼
scheduler → POST {portal}/api/agent-cron/relay (auth: scheduler signature, NAS-verified)
│
▼
NAS mints a short-lived agent-audience JWT (purpose=cron_fire)
│ POST {agent_callback_url}/api/cron/fire (auth: that JWT)
▼
agent verifies the NAS JWT → store CAS claim → run_one_job → re-arm next one-shotTrust model (read this first)
| Hop | Who calls whom | Auth mechanism | Verified by |
|---|---|---|---|
| 1 | agent → NAS (provision/cancel/list) |
the agent's existing Nous Portal access token (Bearer) — for a hosted agent this is the bootstrap-session token NAS planted in auth.json (client hermes-cli-vps), NOT an agent:* client token |
NAS (its normal agent-token path) |
| 2 | scheduler → NAS (relay) |
the scheduler's request signature | NAS (the signature path it already has) |
| 3 | NAS → agent (/api/cron/fire) |
a short-lived NAS-minted JWT (aud=agent:{instance_id}, purpose=cron_fire) |
agent (PyJWT against NAS JWKS) |
Which token, exactly (hop 1). A hosted agent never holds an
agent:{instance_id}
OAuth client credential — that shape is minted only by the interactive dashboard
auth-code grant (a browser user). For all of its own outbound portal calls the
agent uses the bootstrap-session access token (resolve_nous_access_token),
minted under the bootstrap-only clienthermes-cli-vpsand seeded into the
container on first boot. NAS therefore must resolve the calling agent's instance
id from EITHER anagent:{id}client (self-hosted/dashboard callers) OR — for the
bootstrap token — fromAgentInstance.bootstrapSessionIdmatching the token's
session id (sid), org-scoped. The fire JWT minted at hop 3 still carriesaud=agent:{instance_id}regardless. (Gating hop 1 on anagent:*client alone
403s every real hosted-agent provision — seesrc/server/agent-cron/instance-auth.ts.)
Why NAS-mediated rather than scheduler→agent direct: the scheduler signs with
NAS's keys, which the agent does not (and should not) hold. The agent can
only verify a NAS-minted token — a trust path it already has. This keeps
all scheduler credentials inside NAS. (Full rationale: the plan's DQ-4.)
No new secret is introduced on the agent: hop 1 reuses the token the agent
already uses for the portal, and hop 3 reuses the NAS-JWT verification the agent
already performs.
Endpoint 1 — `POST /api/agent-cron/provision` (agent → NAS)
Arm (or re-arm, idempotently) exactly one one-shot for a job.
- Auth:
Authorization: Bearer <agent Nous access token>. NAS validates via
its normal agent-token path and scopes the row to the calling agent/org. - Request body:json
{ "job_id": "ab12cd34", "fire_at": "2026-06-18T12:34:56+00:00", "agent_callback_url": "https://agent-xyz.fly.dev", "dedup_key": "ab12cd34:2026-06-18T12:34:56+00:00" }fire_at— ISO 8601, agent-computed. May be sub-minute in the future;
NAS must honor second-granularity (the agent owns the time, so there is no
1-minute scheduler floor).agent_callback_url— the agent's own publicly-reachable base URL. NAS
POSTs{agent_callback_url}/api/cron/fireat fire time.dedup_key—"{job_id}:{fire_at}". NAS upserts by(agent_id, job_id)
so re-arming the same fire is idempotent (no duplicate one-shots). A newfire_atfor the samejob_idreplaces the prior arm.
- Action: arm one one-shot to fire at
fire_at, destined for the NAS
relay route (Endpoint 3) — NOT the agent directly, so NAS stays in the
loop to mint the agent JWT. Persist(agent_id, job_id, schedule_id, agent_callback_url). - Response:
200 {"schedule_id": "<opaque>"}.
Endpoint 2 — `POST /api/agent-cron/cancel` (agent → NAS)
- Auth: same as Endpoint 1.
- Body:
{"job_id": "ab12cd34"}. - Action: cancel the armed one-shot for
(agent_id, job_id)and delete the
row. Idempotent — cancelling an unknown job is a 200 no-op. - Response:
200 {"ok": true}.
Endpoint 3 — `POST /api/agent-cron/relay` (scheduler → NAS, the fire relay)
- Auth: the scheduler's request signature, verified by NAS with the
signature path it already has. This is the trust boundary for the fire — a
forged relay call must be rejected here. - Action:
- Look up
(agent_id, job_id) → agent_callback_urlfrom the persisted row. - Mint a short-lived JWT:
aud = "agent:{instance_id}",iss = {portal_url},purpose = "cron_fire", smallexp(≈60–120s),
signed with NAS's normal asymmetric signing key (published via JWKS). POST {agent_callback_url}/api/cron/firewithAuthorization: Bearerand body{"job_id": "...", "fire_at": "..."}.- Treat a non-2xx agent response as a retryable failure (let the
scheduler retry the relay). The agent's store CAS de-dupes a double fire,
so retries are safe.
- Look up
- Response to the scheduler: 2xx once the agent POST is accepted (202), so
the scheduler does not retry a delivered fire.
Inbound `POST /api/cron/fire` (NAS → agent) — agent side, already implemented
This is the agent endpoint NAS calls in Endpoint 3 step 3. Two hops on hosted
deployments:
- Dashboard app (
hermes_cli/web_server.py) — the agent's only public
HTTP surface (the Fly proxy exposes exactly one port, the dashboard's). It
is inPUBLIC_API_PATHSso the dashboard cookie gate lets the bearer-JWT
callback through to the verifier. The dashboard verifies the JWT, resolves
the job's profile, then forwards the fire to hop 2 on loopback with the
NAS bearer preserved — it does NOT execute the job itself. - Gateway
APIServerAdapter(gateway/platforms/api_server.py, loopback
bind, default port 8642) — re-verifies the JWT (defense in depth) and runs
the job with the gateway's live platform adapters, which is what makes
delivery work for relay-fronted logical platforms and E2EE rooms (the
standalone send path can serve neither). Self-host API-server deployments
that expose the api_server directly hit hop 2 without hop 1.
Gateway unreachable from hop 1 (scale-to-zero wake still booting, restart
window, api_server disabled) → the dashboard returns 503 and NAS retries
(non-2xx = retryable, below); the store CAS de-dupes the eventual double fire.
There is deliberately no in-dashboard execution fallback. The verifier isplugins/cron/chronos/verify.py.
- Auth:
Authorization: Bearer <NAS-minted JWT>. The agent verifies:- signature against the NAS JWKS (
cron.chronos.nas_jwks_url), aud==cron.chronos.expected_audience(this agent'sagent:{instance_id}),iss==cron.chronos.portal_url,exp/nbf(30s leeway),purpose == "cron_fire"— a general agent JWT (no/other purpose) is
rejected so it can't be replayed against this endpoint.
- signature against the NAS JWKS (
- Body:
{"job_id": "ab12cd34", "fire_at": "..."}(onlyjob_idis used). - Behavior:
- invalid/missing/forged/expired/wrong-aud/wrong-purpose token → 401, no
execution. - missing
job_id→ 400. - gateway still starting (a scale-to-zero wake) → the fire waits up to 7 s,
counted from receipt, for the gateway to finish starting. If it doesn't, or
a drain begins meanwhile → 503{"error": "gateway unreachable; retry"}
withRetry-After: 60, nothing claimed (NAS retries). - valid → 202
{"status": "accepted", "job_id": "..."}immediately, and
the job runs in the background. 202-before-run means a long agent turn never
trips the relay's HTTP timeout.
- invalid/missing/forged/expired/wrong-aud/wrong-purpose token → 401, no
- At-most-once: the agent claims the job with a store-level compare-and-set
(claim_job_for_fire) before running. A relay/scheduler retry that arrives
while the first fire is in flight (or after it completed) loses the claim and
does not double-run.
At-most-once & re-arm semantics
- Recurring (cron/interval): on fire, the agent advances
next_run_at
(under its store lock) as part of the claim, runs the job, then re-provisions
a one-shot for the newnext_run_at. A duplicate relay for the oldfire_at
finds the claim taken / time advanced and is dropped. - One-shot (
30m,+90s, etc.): fires once;mark_job_runmarks it
completed. No re-arm. repeat.times = N:mark_job_rundeletes the job at the limit, soget_jobreturnsNoneafter the final fire → the agent does not re-arm
→ the schedule stops cleanly with no orphaned one-shot.- Multi-replica agents: the store CAS makes the fire at-most-once across N
gateway replicas sharing oneHERMES_HOME— exactly one replica runs each
fire.
Reconcile (self-healing)
The agent reconciles desired (jobs.json) vs armed on:
start()(gateway boot / wake),- every successful job mutation (
on_jobs_changed), - piggybacked after each fire (re-arm).
Reconcile arms missing/changed-time jobs and cancels orphans. A missed
provision (transient NAS error) self-heals on the next reconcile. There is no
periodic wake of a sleeping agent — that would negate scale-to-zero.
Config (agent side)
All non-secret (cron.chronos.* in config.yaml); the agent holds no scheduler
credentials. For hosted agents NAS sets these at provision time:
| key | meaning |
|---|---|
cron.provider |
"chronos" to activate (empty = built-in ticker) |
cron.chronos.portal_url |
NAS base URL (also the expected JWT iss) |
cron.chronos.callback_url |
the agent's own public base URL for NAS→agent fires |
cron.chronos.expected_audience |
this agent's JWT aud (agent:{instance_id}) |
cron.chronos.nas_jwks_url |
NAS JWKS for verifying the fire JWT |
If callback_url / portal_url is blank or the agent has no Nous login,is_available() returns False and the resolver falls back to the built-in
in-process ticker — cron never loses its trigger.
Identity rejection at runtime (403 invalid_client). is_available() is
config-only, so it cannot tell whether the stored Nous token is the identity NAS
maps to a provisioned instance (hop 1 above). When provision answers 403invalid_client — the token in auth.json is a plain hermes-cli user login
rather than the hermes-cli-vps bootstrap session or an agent:* client — the
rejection is deterministic for the life of that credential: every arm, re-arm
and list would fail the same way, and a hermes auth re-login makes it
permanent (it replaces the bootstrap session; only NAS can re-mint one). The
provider therefore logs ONE warning naming that remedy, stops calling NAS, and
starts the built-in ticker for the rest of the process so jobs keep firing on
time instead of only through the late misfire sweep
(cron.misfire_grace_minutes). Transient failures (5xx, transport) do not
degrade; the next reconcile retries them.
Escape hatch (not default)
The inbound /api/cron/fire verifier is pluggable (get_fire_verifier()). If
relay volume through NAS ever saturates, a direct scheduler→agent mode with a
per-job NAS-minted cron-key can replace the NAS-JWT verifier with no change to
the webhook handler. NAS-mediated (this contract) is the default.
Developer Guide Cli Internals (`website/docs/developer-guide/cli-internals.md`)
sidebar_position: 15 title: "CLI Internals"
CLI Internals
Companion to hermes_cli/AGENTS.md (the rules) — this page holds the longer explanations.
Update pipeline
User-facing behaviour (receipts, --plan, snapshot modes) is in
Updating; hermes_cli/AGENTS.md carries the short form of the rules.
Fleet-update campaign #91277 (Aug 2026). A PR that weakens a stage must answer for the failure class
it guards. plan → snapshot → apply → restart-per-kind → verify → report
- Plan (
update_inventory.py,hermes update --plan): read-only inventory — install kind, all
profiles, every live gateway with supervisor + running code version. Deployment kinds are
first-class:gitupdates in place;docker/nix/aptare NOT in-place-updatable and the
updater reports the correct external command instead of fighting the deployment model. - Snapshot (
backup.py): pre-update quick snapshot for EVERY profile (the code swap + fleet
restart touch all of them), each into its ownstate-snapshots/, identical file set, 1 GiB
per-file cap, keep=1. Never add a partial/tiered snapshot set — mixed coverage creates
torn-restore states across schema generations. Quick snapshots are FILE-LOSS RECOVERY (the
per-profile cron-jobs safety net restores from them), NOT code-rollback insurance;--backup
full mode owns rollback. - Apply: git pull, or the Windows ZIP fallback — which fires ONLY when git itself failed
(_should_zip_fallback_on_update_error, argv-classified; a dependency-install failure must never
trigger a tree-clobbering re-download), REFUSES a dirty working tree (-uall+ a pre-swap TOCTOU
re-check — but classifies a!!line by whether the swap would destroy it: an ignored path under a
root entry the ZIP does not ship (.bytecode-fingerprint,.hermes-bootstrap-complete,hermes_agent.egg-info/; tracked root entries stand in for the ZIP set before the download, the
re-check gets the real one), a nested__pycache__/node_modules, or a_ZIP_PRESERVED_NESTED
output is admitted; other ignored files under shipped dirs still block), and grafts the live nested
build outputs (_ZIP_PRESERVED_NESTED:apps/desktop/{release,dist,node_modules,build},hermes_cli/web_dist,ui-tui/{dist,node_modules,packages/hermes-ink/dist},web/node_modules,scripts/whatsapp-bridge/node_modules) into the staged swap by hardlink (the GitHub source ZIP has
none of them; without the graft the swap deletes them). Post-swap, the Desktop
rebuild decision also trusts the build stamp under HERMES_HOME, so an install that already lost
its artifacts in an earlier update is rebuilt instead of "forgotten" (#90495). - Restart-per-kind: systemd and launchd restarts are FLEET-WIDE within the updating install (every
hermes-gateway*unit /ai.hermes.gateway*LaunchAgent whose home is the updating root or one of itsprofiles/<name>), drain-first (SIGUSR1), with per-unit/per-label failure isolation. Restarting only the
invoking profile's service leaves siblings on stalesys.modulesuntil they crash — the largest dupe-PR
cluster in the repo's history came from that bug. The fleet is bounded by HOME, not by namespace:hermes_cli/update_fleet_scope.pyjudges every unit/label/process by the home it actually runs on
(live environ, unitEnvironment=, plistHERMES_HOME), and a runtime of anotherHERMES_HOMEon the
same account — a sibling install, the realhermes-gateway.serviceseen from a scratch home — is named and
left alone, never restarted (#93349). - Verify: gateways stamp
code_sha/code_versionintogateway_state.jsonon every
runtime-status write (gateway/status.py); the updater compares each live gateway against the
fresh checkout and prints a fleet version matrix. After the commit point (the tree moved) nothing
failshermes update(exit 0 unless the code was rolled back): a provably-stale or stopped
gateway, a failed build/launcher/config migration/bytecode sweep/channel adoption/Windows resume
each print⚠, land in the receipt asoutcome: "success"withfollowups: [{step, reason}],
and keep their own obligation armed (source-completion-pendingfor the tail, the fleet-restart
obligation for gateways) so the next launch orhermes update— including "Already up to date" —
retries them. The owed-fleet-restart CLI startup warning is what keeps a mixed-version fleet from
ever looking healthy. A completion process that could not be started (temporary directory,
request file, spawn) or died without a correlated result (OOM, SIGKILL) is the same kind of
debt: an owedcompletionfollow-up with the source-update tail re-armed, exit 0. Exit 2
(refused/concurrent) and exit 1 (nothing committed / rolled back) keep their meaning; the one
committed run that exits 1 is an autostash whose restore conflicted and stays parked: the
receipt ispartialwith auser_action, since nothing retries what only the user can re-apply
(#122557). - Report: every run writes a machine-readable receipt to the ROOT home's
logs/update_receipts/(never a sticky profile's;update.loglikewise) —latest.jsonpointer;
steps, skips WITH reasons, restart outcome, plan, fleet snapshot,followups. The receipt is on
disk asoutcome: "running"from the start and refreshed at each stage boundary; the next
update marks arunningrecord whose processes are goneinterruptedand says so. Readers
(hermes logs update, debug bundle, dashboard status, pm receipts) use the root home too.
A dependency-sync failure in the completion bootstrap is adependenciesfollow-up (A6), and
Ctrl-C after the commit point finalizesinterrupted(exit 130), neverfailed.
Before a source swap, the parent captures plan/snapshots/receipt and its Windows pause token.update_completion.pyruns new-code PM preparation with site initialization disabled, then
selected-Python builds, maintenance, scans/restarts and verification. Git/current/ZIP share
this owner; never reload or purge modules to continue in the old interpreter. The parent keeps
the lock, waits, and accepts only a correlated terminal result.cmd_updatestill finalizes
early failures and missing/killed-child outcomes; PM refusal data survives the handoff.
Seewebsite/docs/developer-guide/source-update-completion.md. A begun-but-unwritten receipt is a bug. - Nothing runs pulled code in the pre-pull interpreter. This tree finishes updates through
update_completion.run_completion/_update_takeover.py.hermes_cli/update_handoff.pyandhermes_cli/update_serve_obligations.pyare the
FROZEN COMPAT SURFACE for releases that lazily import those module
names from the NEW tree after the checkout swap.
Keep their public names importable and behavior-preserving. They call into_old_updater.stop_for_relaunch→_run_child. Removing a name
bricks every release mid-update. seetests/compat/old_updater_surface.json.
Process-scan coordination between updater, serve/dashboard, and gateway is being replaced by a
gateway-owned control socket (#92091); scans are the fallback layer for old/crashed processes — read
#92091 before adding any heuristic. Process identity rules (never argv substrings; canonical
matchers; parser-derived flag sets; never blanket-exclude gateway ancestors, #87594): rootAGENTS.md and website/docs/developer-guide/cli-internals.md.
The systemd blunt-restart fallback waits for the unit's TimeoutStopUSec plusTimeoutStartUSec, with 15 seconds of client-side slack. It reads the target unit
in the same manager scope as the restart; both the initial attempt and retry use
this budget, including the catch-up restart after an interrupted update.
A start after a graceful drain uses only the start budget plus slack.
A missing, unparseable, or infinite phase limit falls back to 90 seconds
for that phase, keeping unattended updates bounded. Timing out the systemctl
client does not cancel the manager's transaction. Custom multi-command stop
chains or EXTEND_TIMEOUT_USEC can still outlast this estimate; a real timeout
remains an incomplete restart, and successful commands still require the existing
service-health and fleet-version verification. Raw numeric *USec values are
microseconds, while formatted values use systemd's fixed units, including days,
weeks, months and years. The combined timeout is capped below the native signed
32-bit millisecond poll limit (with rounding headroom), so exceptionally long
unit limits cannot overflow subprocess polling. Zero/unknown/infinite phase
limits use the bounded fallback. This does not change active-turn drain settings.
Nous free tier sign-in
Sign-in completion is one function, settle_after_upgrade, called by every caller that persists an
account over a free-tier identity (CLI upgrade_guest, the desktop poller): it moves a config on the
welcome route to the account's host and the tier's recommended default
(models.recommended_nous_default_model, shared with GET /api/model/recommended-default).
The shared flow, states, and copy live in anon_sign_in.py; CLI rendering lives inanon_sign_in_cli.py. anon_auth.py keeps identity, promotion polling, and settlement, and
re-exports the existing sign-in API. The flow resolves identity and persistence collaborators
through anon_auth at call time to preserve module-attribute monkeypatch seams.
The sign-in itself is one composition: anon_auth.run_sign_in() yields SignInStates (Code,Waiting, Completed, Declined, Superseded, TimedOut, Retired, Failed,AlreadySignedIn, Unavailable). It reads the current state itself, holds one absolute deadline
across both waits, persists only after a completed promotion and a token grant, runssettle_after_upgrade exactly once per completion, and never lets a persist or settle failure
escape as an exception — it becomes Failed. Every state carries its own .copy (the chat form,
which never contains a raw exception, a URL or a hermes verb) and .copy_terminal, so no caller
maps a reason to a string. cancelled() stops an attempt; cancel_wins_after_promotion decides
what happens when the server had already completed the transfer — the desktop keeps True (a
DELETE means "not on this machine"), the gateway passes False (a supersede must not discard a
transfer the user actually approved). scope is entered only around the precondition and persist
blocks, never across a yield or a network wait, because run_in_executor does not carry
contextvars. upgrade_guest (hermes auth upgrade), the CLI /login handler and the desktop
promotion poller are renderers over it; a surface that needs the cancel check and the save to be
atomic passes persist_guard. The desktop's plain "connect another Nous account" device-code login
is a separate path (_nous_plain_poller) and must stay one.
Process identity: never infer it from argv substrings
The bug class behind ~10 fleet-update issues (#90778, #87594, #78089, #76129, #91964, ...):
classifying a process by "serve" in cmdline or similar. kanban --preserve-cache contains
"serve"; a flag VALUE can equal a subcommand (-m dashboard serve); truncated cmdlines hide the real
subcommand. Rules:
- Use the canonical matchers:
gateway.status.looks_like_gateway_command_line(gateway run),hermes_cli.update_cmd._hermes_holder_subcommand(top-level subcommand of any Hermes argv). Never
hand-roll token scans. - Flag sets must be DERIVED from the parser (
_holder_value_flags()introspectsbuild_top_level_parser()), never hand-written lists — they drift. - Never blanket-exclude ancestors from process scans: when
/updateruns as the gateway's child, a
gateway ancestor must stay visible to the pause machinery (#87594). Exclude interactive ancestry,
carve out gateway-shaped ancestors. - Match on FULL cmdlines; truncate only at display time (#78089).
- Before adding any new scan heuristic, read #92091 — the gateway control socket replaces scans as
the primary coordination mechanism; scans are the fallback layer for old/crashed processes.
Skin engine — what skins customize
| Element | Skin key | Used by |
|---|---|---|
| Banner panel border / title / section headers / dim / body | colors.banner_border, banner_title, banner_accent, banner_dim, banner_text |
banner.py |
| Response box border | colors.response_border |
cli.py |
| Spinner faces (waiting / thinking) | spinner.waiting_faces, spinner.thinking_faces |
display.py |
| Spinner verbs / wings (optional) | spinner.thinking_verbs, spinner.wings |
display.py |
| Tool output prefix / per-tool emojis | tool_prefix, tool_emojis |
display.py → get_tool_emoji() |
| Agent name / welcome / response label / prompt symbol | branding.agent_name, welcome, response_label, prompt_symbol |
banner.py, cli.py |
Built-in skins (_BUILTIN_SKINS in hermes_cli/skin_engine.py): default (classic gold/kawaii),ares (crimson/bronze with custom spinner wings), mono (grayscale), slate (cool blue). Add a
built-in as a dict entry {"name", "description", "colors", "spinner", "branding", "tool_prefix"}.
User skins are ~/.hermes/skins/<name>.yaml with the same keys, activated with /skin <name> ordisplay.skin: <name>; the full YAML template is in the
Skins & Themes user guide.
Profiles: multi-instance support
Hermes supports profiles — fully isolated instances, each with its own HERMES_HOME (config, API
keys, memory, sessions, skills, gateway). For single-profile commands (hermes -p x <cmd>),_apply_profile_override() in hermes_cli/main.py sets HERMES_HOME before any module imports, so
every get_hermes_home() reference scopes to the active profile. The multiplex gateway and the
Desktop/dashboard serve backend serve several profiles from one process instead: the active
profile is a contextvar override bound per activity, os.environ["HERMES_HOME"] stays the launch
profile's, and a module-level constant derived from the home freezes to that launch profile (see
Gateway Internals § Multiplexed profiles). Profile
operations are HOME-anchored (_get_profiles_root() returnsPath.home() / ".hermes" / "profiles", not get_hermes_home() / "profiles") sohermes -p coder profile list sees all profiles regardless of which one is active — intentional.
Profile-safe coding rules are in the root AGENTS.md; multiplex secret-scope rules ingateway/AGENTS.md.
Developer Guide Codebase Ownership (`website/docs/developer-guide/codebase-ownership.md`)
title: "Codebase Ownership Map"
Codebase Ownership Map
Hermes is a large repository, and most contributions touch exactly one subsystem. This page maps each subsystem to its source directories and the documentation entry point you should read before changing it. Use it to find the right starting doc, the right place for a change, and the right test directory (tests mirror source: code in tools/ is tested in tests/tools/, plugins in tests/plugins/<type>/, and so on).
| Subsystem | Source directories | Docs entry point |
|---|---|---|
| Agent core (loop, transports, compression) | agent/, run_agent.py |
Agent Loop, Context Compression & Caching |
| Prompt assembly | agent/prompt_builder.py, agent/system_prompt.py |
Prompt Assembly |
| Model providers & transports | agent/transports/, plugins/model-providers/, hermes_cli/models.py |
Adding Providers, Model Provider Plugins, Provider Runtime |
| Built-in tools | tools/ |
Adding Tools, Tools Runtime |
| Messaging gateway | gateway/, plugins/platforms/ |
Gateway Internals, Adding Platform Adapters |
| CLI | hermes_cli/ |
Extending the CLI |
| Plugins system | plugins/ |
Build a Hermes Plugin |
| Skills (bundled & optional) | skills/, optional-skills/ |
Creating Skills |
| Cron / scheduled jobs | cron/ |
Cron Internals |
| Session storage | hermes_state.py, hermes_state_*.py |
Session Storage |
| Browser stack | tools/browser_tool.py, tools/browser_supervisor.py, tools/browser_cdp_tool.py |
Browser Supervisor |
| Egress firewall | agent/proxy_sources/iron_proxy.py |
Egress Internals |
| ACP (IDE integration) | acp_adapter/ |
ACP Internals |
| Desktop app | apps/desktop/ |
Desktop Plugin SDK, Worktree UI Development |
| TUI | ui-tui/, tui_gateway/ |
Worktree UI Development |
| Docs site | website/ |
Contributing |
| Tests | tests/, tests-js/ |
Contributing → Before Submitting |
A few conventions that fall out of this map:
- Changes should stay inside their subsystem. A plugin that needs to edit core files is a design smell — widen the generic plugin surface instead (see the contribution rubric in the repository's
AGENTS.md). - Run the mirror test directory for every source directory you touch. A change to
plugins/platforms/telegram/needstests/plugins/platforms/green, not just the test file you happened to think of. - When two subsystems are involved, the narrower one owns the change. Prefer a fix in an adapter or plugin over a branch in the agent core; the core is a narrow waist, and every addition there is paid for on every API call.
Developer Guide Completion Backlog Delivery (`website/docs/developer-guide/completion-backlog-delivery.md`)
title: Background completion backlogs sidebar_label: Completion backlogs
Background completion backlogs
Interactive surfaces coalesce consecutive background-process completions that are
already ready for one conversation into a single notification turn. This does not
add a delay or promise to combine jobs that finish at different times. Failures and
successful outputs remain in the batch; one completion keeps its original text.
Process identity remains available until dispatch. Explicit process_manage
wait/log/kill consumption can therefore suppress a CLI completion even after it
has left the process registry and entered the input queue. An entirely consumed
batch starts no turn. Watching output and async-delegation results remain separate
notifications, in their original order; they are not folded into completion batches.
Consumers and ownership
- Classic CLI:
hermes_cli/cli_process_notifications.pyowns the idle/post-turn
drain, compression-aware ownership and final input unwrapping. - TUI and Desktop:
tui_gateway/session_notifications.pygroups the poller's
ready snapshot after checking ownership. Each process still emits its own UI
status. Busy sessions requeue structured events, not rendered batch strings. - Post-turn TUI safety net:
tui_gateway/prompt_turn.pyuses the same routing
and rendering path. Desktop and dashboard chat clients share this backend. - Messaging gateway:
gateway/run_notifications.pyalready uses its own
route-keyed short-window batching. This interactive-backlog change does not
replace that mechanism or alter adapter sends. - Noninteractive/headless consumers: this change does not create a new
autonomous notification loop for an API request, ACP client, or one-shot CLI.
The shared renderer is tools/process_registry_notifications.py::ProcessNotificationBatch.
Neither a batch nor its delivery status is persisted into the system prompt.
Addressed events still require a provable owner; another live session cannot adopt
them. Delegation delivery continues through its existing durable claim/complete
ledger, once per delegation rather than once per process batch.
Local validation and its limits
evals/completion_backlog_probe.py REPO OUTPUT.json starts real local shell children
in temporary directories, reads their real completion events, and drives the
production CLI, TUI poller and post-turn notification routes. A loopback HTTP turn
sink replaces chat / _run_prompt_submit; it records actual dispatches but does
not exercise model inference, native renderer interaction or a hosted platform.
Synthetic watch and delegation envelopes are labeled fixtures; delegation claims
use the real temporary SQLite ledger. The backlog case includes a nonzero exit.
The probe checks a ready backlog, a single exact payload, explicitly consumed
results, foreign ownership, and watches/delegations interleaved with completions.
It measures turn admission at the notification boundary, not model token savings.
Developer Guide Context Compression And Caching (`website/docs/developer-guide/context-compression-and-caching.md`)
Context Compression and Caching
Hermes Agent uses a dual compression system and Anthropic prompt caching to
manage context window usage efficiently across long conversations.
Source files: agent/context_engine.py (ABC), agent/context_compressor.py (default engine),agent/prompt_caching.py, gateway/run_turn_hygiene.py (session hygiene), agent/compression_facade.py (search for _compress_context)
Bedrock context window cache
Bedrock context resolution in agent/model_metadata.py uses this precedence:
- Explicit overrides win. Configured context lengths take priority over cache,
probes, and the static table. - Provider-confirmed limits persist. A successful probe or a limit learned
from a provider error remains authoritative, even below the static table.
The compressor uses the same value after restart. - Legacy entries are revalidated. Old scalar entries have no provenance and
may be either probe results or fallbacks. Their size does not establish which. - Failed probes use the current table without persisting it. Failures have a
five-minute in-memory cooldown scoped to Hermes home, endpoint, model, and
region. Expiry or explicit cache invalidation permits another attempt.
The cache remains at context_length_cache.yaml under the active Hermes home.context_lengths retains scalar values for older readers. An additivebedrock_confirmed_v1 map binds each confirmed key to its exact value in the
same atomic write. Generic writes clear that key's provenance. Older writers
may drop the additive map, which causes revalidation after upgrading again.
Downgrading remains readable but restores the older runtime's resolution rules.
The static fallback for xai.grok-4.6 (including global. and us. inference
profiles) is 500,000 tokens, per the
AWS model card.
This is Bedrock-specific, not the direct xAI API window. Existing compression
rules still apply: without output reservation or an explicit token cap, the
small-window 75% threshold floor yields a 375,000-token trigger at this window.
Pluggable Context Engine
Context management is built on the ContextEngine ABC (agent/context_engine.py). The built-in ContextCompressor is the default implementation, but plugins can replace it with alternative engines (e.g., Lossless Context Management).
context:
engine: "compressor" # default — built-in lossy summarization
engine: "lcm" # example — plugin providing lossless contextThe engine is responsible for:
- Deciding when compaction should fire (
should_compress()) - Performing compaction (
compress()) - Optionally exposing tools the agent can call (e.g.,
lcm_grep) - Tracking token usage from API responses
Selection is config-driven via context.engine in config.yaml. The resolution order:
- Check
plugins/context_engine/<name>/directory - Check general plugin system (
register_context_engine()) - Fall back to built-in
ContextCompressor
Plugin engines are never auto-activated — the user must explicitly set context.engine to the plugin's name. The default "compressor" always uses the built-in.
Configure via hermes plugins → Provider Plugins → Context Engine, or edit config.yaml directly.
For building a context engine plugin, see Context Engine Plugins.
Dual Compression System
Hermes has two separate compression layers that operate independently:
┌──────────────────────────┐
Incoming message │ Gateway Session Hygiene │ Fires at 85% of context
─────────────────► │ (pre-agent, rough est.) │ Safety net for large sessions
└─────────────┬────────────┘
│
▼
┌──────────────────────────┐
│ Agent ContextCompressor │ Fires at 50% of context (default)
│ (in-loop, real tokens) │ Normal context management
└──────────────────────────┘1. Gateway Session Hygiene (85% threshold)
Located in gateway/run_turn_hygiene.py (search for Session hygiene). This is a safety net that
runs before the agent processes a message. It prevents API failures when sessions
grow too large between turns (e.g., overnight accumulation in Telegram/Discord).
- Threshold: Fixed at 85% of model context length
- Token source: Prefers actual API-reported tokens from last turn, then the
usage anchor persisted on the session row (real count + delta of what was
appended since; survives gateway restarts), and only then the rough
character-based estimate (estimate_messages_tokens_rough) - Fires: Only when
len(history) >= 4and compression is enabled - Purpose: Catch sessions that escaped the agent's own compressor
The gateway hygiene threshold is intentionally higher than the agent's compressor.
Setting it at 50% (same as the agent) caused premature compression on every turn
in long gateway sessions.
2. Agent ContextCompressor (50% threshold, configurable)
Located in agent/context_compressor.py. This is the primary compression
system that runs inside the agent's tool loop with access to accurate,
API-reported token counts.
Token accounting: provider anchors and explicit heuristic fallbacks
Every compaction gate (turn-start preflight, idle, pre-API pressure, post-tool)
asks the usage anchor first (agent/usage_anchor.py): the provider's last
prompt and completion token counts plus a rough estimate of ONLY the messages appended since
that response. The anchor identifies the priced transcript by a content
fingerprint, so it survives the gateway re-reading history from the DB every
turn, and it is persisted on the session row so a fresh process (--resume,
desktop per-turn serve) restores it while the durable transcript still
matches. Compaction, session reset and codex-native compaction clear it.
For the built-in engine's turn-start and pre-API threshold gates, without an
anchor (first request, rewind/edit-resend), a whole-context rough estimate over
threshold waits one request for provider evidence
(should_defer_preflight_to_real_usage). This includes estimates at or above the
entire context window: estimate magnitude does not prove that a request will fail.
After a model switch, old usage is cleared and the new provider adjudicates the
first request too; a genuinely oversized request can incur one rejected request
before reactive recovery.
The wait is not a disable. Once a response omits usage, the existing heuristic
fallback remains available; real usage already over threshold and provider-proven
overflow still allow compression. A post-compaction latch waits for one response
and is consumed even if that response omits usage. Recovery remains bounded by the
compression attempt budget and no-progress guards, not an indefinite resend loop.
This is not an exact-count-only policy, nor closure of #104462's literal
never-estimate acceptance. The following policies remain unchanged:
- An anchor includes the provider's prompt and completion tokens plus a rough
appended-message delta (the first appended assistant is already covered by
completion usage). A large new tool result can therefore still cross a threshold
on an estimated delta. Boundary fingerprint matching does not fingerprint the
whole prefix, model, tools, or system prompt. - Opt-in idle compaction uses its own floor/cooldown and can act on unanchored
pressure; it does not share the threshold gate's one-request wait. - Pre-agent gateway hygiene retains its rough-history fallback and hard-message
safety valve. The replay harness'sgatewayshape reloads transcript dictionaries;
it does not exercise that separate hygiene policy. - Post-tool usage-less fallback, micro-compaction, summary/tail sizing, pruning and
overflow progress checks still use local estimates. Native compaction keeps its
provider-specific ownership and checkpoint latch.
Provider count endpoints remain deferred. Eliminating these remaining estimates
requires an explicit policy decision: accept the documented liveness fallbacks,
or replace them with provider evidence while defining behavior for providers that
never return usage. Simply disabling all unanchored maintenance is not equivalent.
evals/token_accounting/replay_gates.py covers below-window and past-window
inflation, real-over-threshold controls, reload/restore anchors, and local HTTP
overflow/usage-less recovery with real compression but fixed local summary text.
These are scripted control-flow checks, not vendor tokenizer or billing evidence.
Opaque provider blobs (encrypted_content on Codex reasoning / compaction
items) contribute 0 to every local estimate; only real usage ever prices them.
Images are priced at the per-image cost learned from the provider's usage
(agent/image_token_cost.py), not a vendor formula: on a response whose delta
since the previous anchor introduced N images, the residual between the realprompt_tokens and the text-only projection is N × the provider's price. The
value is kept per model@host in ~/.hermes/cache/image_token_costs.json and
bound per turn so the trigger estimator, the tail-budget walk and gateway
hygiene all use the same figure. Before the first vision turn a flat 1,500
default applies.
Failure cooldown and provider-proven overflow
A failed or stalled summary attempt arms a per-session failure cooldown
(escalating 60s → 300s → 900s, never shorter thancompression.context_timeout_seconds, persisted in state.db). While it is
armed, ordinary threshold-triggered compaction is deferred so a broken summary
backend does not re-fire every turn. Timeouts and stalls escalate on one
counter; a summary that ends in finish_reason=length (output cap hit, the
transcript is preserved) escalates on its own counter along the same
60s → 300s → 900s rungs, so a later turn — such as an async delegation
completion arriving after the cooldown lapsed — cannot re-issue the same
capped request every 30 seconds (#69637). JSON-decode, closed-stream and
empty-content failures stay on a flat 30 s cooldown. Three paths run a real
attempt anyway:
- Manual
/compress(force=True) — clears the cooldown and retries. - The same-turn
fallback_chainretry after a stalled primary route — the
cancelled primary's own stall cooldown must not suppress it (bypass_cooldown).
If that pinned route's summary call fails, compress() still commits its
deterministic fallback summary (defaultabort_on_summary_failure: false);
the log then says "committed a deterministic fallback summary", not
"recovered". - Repeated stall → deterministic fallback. A first stall keeps the
transcript, arms the cooldown and lets the LLM route retry after it lapses.
When the route stalls again while a stall-class failure is still on the
ladder (_consecutive_timeout_failures >= 1), the retry ladder ends with a
deterministic rung: the worker is re-run with the summary LLM skipped
(DETERMINISTIC_SUMMARY_ROUTEpin) and commits the static fallback summary
through the ordinary lease/fence/watermark pipeline — the same degrade a
failed summary call gets — instead of "continuing without compression" and
re-entering the same silent stream every turn (#112420).abort_on_summary_failure: truestill aborts (nothing dropped). A committed
compaction rebinds the compressor and resets the ladder count, so each
compaction cycle grants the LLM route one stall before escalating; the
persisted cooldown row still paces attempts across turns and restarts. - Summary provider overloaded → abort, transcript kept. When the summary
call fails with a provider-overload error (overloaded,at capacity,
HTTP 529) and the one-shot main-model retry also fails, compress() aborts
and preserves the transcript unchanged instead of committing the
deterministic fallback; the warning names the overload
(failure_class=summary_overload_failure) and/compressretries once
capacity recovers. Auth/quota, network and empty-content failures already
abort the same way. Sustained overload escalates (#123167): after 3
consecutive overload aborts in one session the overload stops counting as
terminal and compress() commits the deterministic fallback
(failure_class=summary_overload_degraded) — a bounded middle-window loss
instead of letting the transcript grow intocompression_exhaustedand a
gateway auto-reset that discards the whole session. A successful summary
resets the budget;abort_on_summary_failure: truestill hard-aborts every
attempt. - Provider-proven overflow — when the provider itself rejects the request
with a context-length error, the recovery pass ignores the cooldown for one
bounded attempt (max_compression_attempts) without clearing it. Deferring
here would wedge the session: every turn would bounce off the provider and
the next failure would extend the ladder (#100661). If that attempt fails,
the cooldown is recorded normally.
Attempt telemetry
Every compress_context call that returns logs one content-free INFO line,context compression attempt telemetry: {...} (loggeragent.conversation_compression). It never carries message text, summary
text, the focus topic or error messages.
trigger_sourcenames why the attempt ran:manual,idle,turn_start_threshold,engine_preflight,pre_api,post_tool,gateway_hygieneoroverflow. An automatic caller that passes no label
readsauto; a refusal before any attempt began (pool saturation) readsunknown.routeishermesfor the local compressor andcodex_app_serverwhen the
Codex thread owns compaction.commit_statusiscommitted,aborted,failed,skippedorblocked.
Ablockedattempt was refused by an automatic guard; itsfailure_class
names the guard (blocked:cooldown,blocked:structural_backoff,blocked:ineffective). Under the session lease,session_ownership_lost
(skipped) means another path already rotated the session, andsession_ownership_unreadable/cooldown_state_unreadable(aborted)
mean that state could not be read.empty_transcript(aborted) means the
engine returned no messages, so nothing was committed.attempt_idnames this attempt andsession_idthe session it started
in, even when the attempt then adopts a rotated child session or unwinds
after a newer attempt (a stall fallback) has begun. An attempt
that stops before the compressor runs, or rolls its state back, logsmethod: noneand no token counts, never an earlier attempt's numbers.methodsays how the summary was produced:llm_summary,aux_fallback_main(the summary model failed and the main model wrote it),deterministic_fallback(static anchors summary;items_droppedcounts the
replaced messages),provider(Codex) ornone.messages_before/messages_afterandtokens_before/tokens_after/tokens_reclaimeddescribe the transcript that crossed the commit boundary,
including retained/compress here Ntail rows and boundary anchors.
A refused or rolled-back candidate never reached the transcript, so its
record has nomessages_after,tokens_after,tokens_reclaimedoritems_dropped.token_count_method: estimate_roughmarks them as rough message-only
estimates (system prompt and tool schemas excluded), so they compare like
for like.tool_results_prunedandreasoning_items_prunedcount the
deterministic passes that ran before and after the summary.
The opt-in hermes.compression.count shared metric keeps coarse triggers
(auto, manual, overflow) and counts only attempts the local compressor
ran; blocked and Codex-routed attempts appear in the log line only. When the
NeMo Relay runtime is live, the same record, plus micro-compaction passes and
committed proactive prunes, is also emitted as a hermes.compaction mark (see
NeMo Relay Shared Metrics).
Configuration
All compression settings are read from config.yaml under the compression key:
/* Detailed source-code truncated for AI context efficiency. */Parameter Details
| Parameter | Default | Range | Description |
|---|---|---|---|
threshold |
0.50 |
0.0-1.0 | Compression triggers when prompt tokens ≥ threshold × context_length (floored at 0.75 below 512K windows) |
threshold_tokens |
null |
int or null |
Optional absolute cap on the trigger: when set, compaction fires at the lower of the ratio trigger and this count. null = ratio-only |
model_thresholds |
{} |
map | Per-model overrides of threshold. Keys are substring-matched against the model name (longest match wins); ":<substring>" keys apply only on that provider. The small-context floor still applies on top (see below) |
target_ratio |
0.20 |
0.10-0.80 | Controls tail protection token budget: threshold_tokens × target_ratio (legacy mode only — lean uses its own clamp) |
tail_mode |
lean |
lean, legacy |
Tail retention policy. legacy keeps a target_ratio-sized verbatim tail (~100K+ tokens on big-window models). lean keeps a clamped tail of 2.5% × context window (10K floor, 25K cap) and instead carries continuity in the summary: a detailed identifier-preserving session log (produced by the same single summary request — lean compaction makes exactly one auxiliary LLM call per attempt), a mechanically extracted anchor index (PR numbers, SHAs, paths, error strings — regex, never paraphrased), every real user message quoted verbatim (newest-first budget), and a session_search recovery pointer so the agent can re-access anything summarized away. Oversized regions are evenly sampled into the summarizer input (with explicit elision markers) rather than triggering extra calls. Result on 500K-token real sessions: ~49K retained vs ~162K, with higher recall when paired with recovery (see evals/compaction/results/). Old tool results inside the lean tail are demoted to one-line stubs carrying a recovery pointer |
protect_last_n |
20 |
≥1 | Minimum number of recent messages always preserved |
min_tail_user_messages |
1 |
≥1 | Minimum number of REAL (actionable) user messages guaranteed to survive in the uncompressed tail. 1 = the existing single last-user anchor (behavior-preserving default). Raise to e.g. 3 to keep the last 3 real user turns verbatim even when bulky tool outputs fill the tail token budget. Blank platform echoes, compaction handoffs, and synthetic continuation rows never count toward N. The guarantee wins over the tail token budget — the tail may exceed the budget when the anchor pulls the cut back |
protect_first_n |
3 |
(hardcoded) | System prompt + first exchange always preserved |
idle_compact_after_seconds |
0 |
≥0 seconds | Opt-in: compact up front when a session resumes after this many seconds idle (0 = disabled). Skips when context ≤ threshold × target_ratio; honors cooldown/anti-thrash/lock guards |
codex_gpt55_autoraise |
true |
bool | Raise the trigger to 85% for gpt-5.4/5.5/5.6 and gpt-6 Astra on the ChatGPT Codex OAuth route (see below). Set false to keep the global threshold |
codex_gpt55_autoraise_notice |
true |
bool | Show the one-time Codex gpt-5.5 autoraise notice. Set false to keep the 85% autoraise but suppress the banner |
codex_app_server_auto |
native |
native, hermes, off |
Thread-compaction mode for Codex app-server sessions (see below) |
codex_responses_native |
false |
bool | Opt in to OpenAI's server-side compaction on the Responses API. Engages for gpt-5.6-family models on the direct OpenAI API or a ChatGPT Codex subscription, and gpt-6-astra (including its -900k picker alias) on official Codex OAuth (see below) |
codex_responses_compact_threshold |
null |
null or positive integer |
Server-side compaction trigger, read only when codex_responses_native: true — it never changes when local compression fires; the local trigger is threshold (ratio) capped by threshold_tokens. null follows the resolved local compression trigger with an 8,192 token safety margin. A positive integer remains absolute and only clamps downward when required. Invalid values use automatic behavior. Automatic mode falls back to 200000 when no usable local trigger exists |
in_place |
true |
bool | Compact on the same session id instead of rotating to a new one (see below) |
In-place compaction (single stable session id)
With compression.in_place: true (the default), a compaction rewrites the live message list on the same session id: the system prompt is rebuilt, the summarized middle is swapped in, and the pre-compaction turns are soft-archived under the same id (active=0, compacted=1 in the session store) — still searchable via session_search and recoverable, never deleted. There is no parent_session_id chain and no name #N renumbering; one conversation keeps one durable id for its whole life. This eliminated the session-rotation bug cluster (lost /goal state, orphaned sessions, search gaps across boundaries).
Consumers observe the mode rather than diffing session ids:
- The
session:compressevent carriesin_place: true/falseandold_session_id(empty string in in-place mode, since there is no old id). - The gateway re-baselines transcript handling from the agent's rotation-independent
_last_compaction_in_placeflag, not from an id-change diff.
Set in_place: false to restore the legacy rotating path, where each compaction commits a new session id linked to the previous one via parent_session_id.
Canonical /compress uses the session's frozen compression.in_place andmin_tail_user_messages settings too. In-place mode soft-archives the current physical
transcript; rotating mode publishes a successor. Both keep the logical admission owner
and previously issued input IDs unchanged, and commit the transcript, generation,
revision and retry receipt together. Safe-mode sessions use code defaults rather than
profile compression settings.
Auxiliary feasibility and tail retention
A smaller auxiliary compression model can lower the live compression trigger without
changing the selected tail policy. In lean mode the selection budget remains based
on the main model's context window: 2.5%, clamped to 10K–25K tokens, and never more
than 20% of that window (the 10K floor alone is 61% of a 16K local window, so without the cap a
small model's "protected" tail was the whole request and compaction reclaimed nothing). For example,
a 1M main model with a 512K auxiliary model retains a 25K
selection budget even when feasibility lowers its trigger from 850K to 512K. Explicit legacy mode instead
recomputes threshold_tokens × target_ratio (102,400 tokens at 512K × 0.20).
These are tail-selection budgets, not strict limits on the entire compacted context:
protected messages, boundary alignment, summaries, and anchors can add tokens.
The lowered trigger is a durable ceiling on the compressor, so window corrections for the
same model (a provider-reported limit, a grown local window) keep it. Whenever the main
runtime changes — /model, fallback activation, or the restore back to the primary — the
auxiliary model is re-probed immediately: the trigger is clamped again before the first
compaction on the new window, or restored to the main model's own value when the
auxiliary model now fits.
Per-model threshold overrides
compression.model_thresholds lets you trigger compaction at different points
depending on the active model — useful when you swap between models with very
different context windows (e.g. a 1M-context model can compress later while a
128K model should compress earlier):
compression:
threshold: 0.50
model_thresholds:
"glm-5.2": 0.40
"glm-5.2-1M": 0.25
"claude-sonnet": 0.35
"openai-codex:astra": 0.85 # only on the Codex OAuth route (272K cap)Resolution rules:
- Keys are substring-matched against the model name; the longest
matching key wins (glm-5.2-1Mbeatsglm-5.2for modelglm-5.2-1M). - Keys may be provider-scoped as
":<substring>"(e.g."openai-codex:astra": 0.85). A scoped key only matches when the session's
provider is that route, so the same slug served with a different window
elsewhere (OpenRouter, Nous, direct OpenAI) keeps the globalthreshold.
Ranking uses the model substring only, so"astra-900k"still beats"openai-codex:astra"for the 900K picker; a scoped key beats a bare key
with the identical substring. - When no key matches (or the map is empty), the global
thresholdapplies. - The override is re-resolved on every
/modelswitch; switching to a model
with no matching key falls back to the globalthreshold. - The small-context floor still applies on top of overrides (raise-only):
models with context windows below 512K are floored at0.75, so an
override below the floor is raised to0.75, while an override above it
(e.g.0.80) wins.
Plugin context engines can reuse the same resolution logic viafrom agent.context_compressor import resolve_model_threshold; engines that
override update_model() own their own compaction policy and may ignore the
map.
Codex gpt-5.x / Astra threshold autoraise
The ChatGPT Codex OAuth backend hard-caps gpt-5.4/5.5/5.6 and gpt-6 Astra at a 272K context window
(the same slug exposes 1.05M on OpenAI's direct API and OpenRouter, and 400K on
GitHub Copilot). At the default 50% trigger, compaction would fire at ~136K —
half the window the model can actually use. When the active route is Codex
OAuth (provider: openai-codex) and the model is one of those families (Astra
matches any slug containing astra; the opt-in -900k picker variants are
excluded because they already unlock the wider window), Hermes raises the
trigger to 85% (~231K) and shows a notice with the opt-out command. The
notice is shown once per profile — a marker under $HERMES_HOME
(.codex_gpt55_autoraise_notice) records that it ran, so repeated agent/session
inits (e.g. every inbound gateway message) don't re-emit it; if the raised
threshold later changes it re-notifies once. Only this exact route is affected;
the same models on any other provider keep your global threshold. To opt back down to
the global value:
hermes config set compression.codex_gpt55_autoraise falseTo keep the 85% autoraise but hide only the one-time notice:
hermes config set compression.codex_gpt55_autoraise_notice falseCodex large-context `-900k` picker variants (opt-in)
The ChatGPT Codex backend advertises a 272K window for the gpt-5.4, gpt-5.6
(Sol/Terra/Luna) and GPT-6 (Sol/Terra/Luna) families, but actually accepts ~911K input tokens
for ChatGPT-subscription accounts (live-verified Aug 2026). Hermes keeps the
advertised 272K as the default for the base slugs — a bigger window means
more tokens per request and much faster subscription-usage burn, so the large
window is strictly opt-in.
To use the large window, pick the explicit -900k variant in /model (e.g.gpt-6.1-sol-900k, gpt-6-sol-900k, gpt-6-luna-900k, gpt-5.6-sol-900k,gpt-5.6-terra-900k, gpt-5.6-luna-900k, gpt-5.4-900k). These are Hermes-side aliases: the suffix is stripped before
the model id is sent to the backend, and pricing/usage accounting treats them
as the base model. Slugs that genuinely enforce 272K (gpt-5.5, gpt-5.4-mini)
have no -900k variant. When the authenticated Codex catalog publishes amax_context_window below 900K for the base slug (e.g. 872K), the -900k
variant resolves to that live maximum instead; 900K remains the offline
fallback and a published maximum above 900K does not raise it.
Compaction thresholds follow the window: base slugs (272K) get the 85%
autoraise described above, while -900k variants keep your globalcompression.threshold (default 50%, ~450K) — the autoraise exists to stop
wasting a small window, which a 900K window doesn't need.
Codex app-server thread compaction
Codex app-server sessions (api_mode: codex_app_server — the codex CLI/agent
runtime) are different from every other route: the codex agent owns the backing
thread context, so Hermes' auxiliary summarizer cannot shrink it — rewriting the
local transcript mirror leaves the real thread growing unbounded until a hard
context reset. For this runtime, compaction goes through the app-server's own
mechanism instead:
- Manual compaction (
/compress) asks the app-server to compact the thread
(thread/compact/start) and waits for the compaction turn to complete. - Automatic compaction is controlled by
compression.codex_app_server_auto:
the defaultnativelets the app-server decide when to compact and Hermes
records the resulting compaction events (compression counters, session
events). Sethermesto let Hermes' compression threshold initiate
app-server compaction, oroffto disable Hermes-initiated automatic
compaction entirely (codex may still compact natively).
Hermes' local transcript is never rewritten on this runtime — state.db records
the compaction boundary while the visible transcript stays intact. All other
routes (including Codex OAuth chat sessions) keep Hermes' summary compressor.
Native Responses compaction (gpt-5.6 and Astra on supported routes)
OpenAI's Responses API supports server-side compaction: when a request includescontext_management: [{type: "compaction", compact_threshold: N}] and the
rendered input crosses N tokens, the server prunes older context into an opaque
encrypted compaction output item. Hermes captures that item into the
assistant message's existing replay sidecar and sends it back on subsequent
turns, standing in for the pruned history — long-horizon recall without a
client-side summary pass, and ZDR-friendly (store: false, noprevious_response_id).
Opt in with compression.codex_responses_native: true. The gate is deliberately
narrow, re-checked on every request:
- Models: the gpt-5.6 family, plus
gpt-6-astra(and its-900kpicker
alias) on official Codex subscription OAuth. Astra on the direct API, other
Astra variants and other GPT-6 models are excluded. gpt-5.1/5.2 return HTTP 500 or stall the stream when the
field is present (no structured rejection to downgrade on, verified live Aug 2026). - Routes:
api.openai.com(OpenAI API key) or the ChatGPT Codex backend
(Codex subscription OAuth) only. xAI, GitHub/Copilot, OpenRouter, relays, and
local servers never see the field.
For Astra, both the resolved openai-codex provider and an official HTTPSchatgpt.com/backend-api/codex endpoint are required. A trusted proxy override
does not enable Astra compaction. This uses the existing automaticcontext_management path and does not add configuration_update history.
Everything else about compression is unchanged: the local compressor stays
armed as the fallback owner (the native threshold is clamped ~8K tokens below
the local trigger so the server compacts first), and a structured provider
rejection of the field disables native compaction for the session and retries
the request without it. Switching the session to a non-eligible model or route
simply stops the field from being sent — captured checkpoints are dropped from
replay by the existing cross-issuer guard when the endpoint changes.
compression.codex_responses_compact_threshold is consulted only whilecodex_responses_native: true is in effect; with native compaction off (the
default) it is ignored and local compression triggers on threshold /threshold_tokens alone. By default, codex_responses_compact_threshold: null
derives the native threshold from the resolved local trigger. For example, a local trigger
of 765,000 selects 756,808. Set a positive integer to preserve an absolute
threshold such as 200,000. Invalid values select automatic behavior. If no
usable local trigger exists, automatic mode uses 200,000. The provider minimum
is 1,024 tokens, so an unusually small local trigger at or below that floor
cannot preserve strict native first ordering.
Computed Values (for a 200K context model at defaults)
context_length = 200,000
threshold_tokens = 200,000 × 0.50 = 100,000
tail_token_budget = 100,000 × 0.20 = 20,000
max_summary_tokens = min(200,000 × 0.05, 12,000) = 10,000:::note Threshold is derived from the MAIN model's context windowthreshold_tokens is threshold × context_length (then capped by compression.threshold_tokens when set), where context_length
is the main agent model's context window — never the auxiliary/summary
model's. On a 262,144-token model at the default 0.50, the threshold is262,144 × 0.50 = 131,072. That number being close to a common "128K context"
is a coincidence of the percentage, not a sign that the auxiliary model's window
is the trigger. The auxiliary model's context window is a separate concern — see
the "Summary model context length" warning below for how it affects whether a
summary can be produced, not when compression fires.
:::
Compression Algorithm
The ContextCompressor.compress() method follows a 4-phase algorithm:
Phase 1: Prune Old Tool Results (cheap, no LLM call)
Old tool results (>200 chars) outside the protected tail are replaced with:
[Old tool output cleared to save context space]This is a cheap pre-pass that saves significant tokens from verbose tool
outputs (file contents, terminal output, search results).
A tool round the model has not answered yet (compaction fired right after it ran, with or
without /steer messages delivered after it) keeps its text results verbatim and its image
results intact in the tail, so the model can use the output it requested. A round that alone
exceeds 20% of the input budget (the context window minus the output reservation) can be
summarized, and its older images are retired so compaction can still make room.
Loaded skills across a compaction
A skill's instructions are not kept verbatim forever, but the model is always told when they are
gone. Every skill_view result that a pass stubs or summarizes away (whatever its size) carries[SKILL_PRUNED: content lost in compression; reload with skill_view(name='…')], and the summary
lists each such skill under ## Pruned Skills. Skills loaded inline by /skill-name, bundles,
stacked invocations and gateway channel auto-load get the same marker when their turn is
summarized, and the summary quotes that turn as typed (/skill-name do X) rather than the skill
body. A reload after the boundary returns the full skill again. Skills preloaded with -s orskills.auto_load live in the system prompt and are never compacted.
Phase 2: Determine Boundaries
┌─────────────────────────────────────────────────────────────┐
│ Message list │
│ │
│ [0..2] ← protect_first_n (system + first exchange) │
│ [3..N] ← middle turns → SUMMARIZED │
│ [N..end] ← tail (by token budget OR protect_last_n) │
│ │
└─────────────────────────────────────────────────────────────┘Tail protection is token-budget based: walks backward from the end,
accumulating tokens until the budget is exhausted. The budget — and the 1.5× soft
ceiling whole rows may overrun it by — is capped at 20% of the context window on every
model, so protect_last_n is a minimum only up to a small count floor (8 rows) and never
forces a tail that cannot leave room to compact; only the required last-user / last-assistant
anchors and atomic tool groups may exceed the cap.
Boundaries are aligned to avoid splitting tool_call/tool_result groups.
The _align_boundary_backward() method walks past consecutive tool results
to find the parent assistant message, keeping groups intact.
Phase 3: Generate Structured Summary
:::warning Summary model context length
The summary model must have a context window at least as large as the main agent model's. The entire middle section is sent to the summary model in a single call_llm(task="compression") call. If the summary model's context is smaller, the API returns a context-length error — _generate_summary() catches it, logs a warning, and returns None. The compressor then drops the middle turns without a summary, silently losing conversation context. This is the most common cause of degraded compaction quality.
:::
The middle turns are summarized using the auxiliary LLM with a structured
template:
## Goal
[What the user is trying to accomplish]
## Constraints & Preferences
[User preferences, coding style, constraints, important decisions]
## Progress
### Done
[Completed work — specific file paths, commands run, results]
### In Progress
[Work currently underway]
### Blocked
[Any blockers or issues encountered]
## Key Decisions
[Important technical decisions and why]
## Relevant Files
[Files read, modified, or created — with brief note on each]
## Next Steps
[What needs to happen next]
## Critical Context
[Specific values, error messages, configuration details]Summary budget scales with the amount of content being compressed:
- Formula:
content_tokens × 0.20(the_SUMMARY_RATIOconstant) - Minimum: 2,000 tokens
- Maximum:
min(context_length × 0.05, 12,000)tokens
Phase 4: Assemble Compressed Messages
The compressed message list is:
- Head messages (with a note appended to system prompt on first compression)
- Summary message (role chosen to avoid consecutive same-role violations)
- Tail messages (unmodified)
Orphaned tool_call/tool_result pairs are cleaned up by _sanitize_tool_pairs():
- Tool results referencing removed calls → removed
- Tool calls whose results were removed → stub result injected
Iterative Re-compression
On subsequent compressions, the previous summary is passed to the LLM with
instructions to update it rather than summarize from scratch. This preserves
information across multiple compactions — items move from "In Progress" to "Done",
new progress is added, and obsolete information is removed.
The _previous_summary field on the compressor instance stores the last summary
text for this purpose. A deterministic fallback summary is stored there too, since
it is the handoff the transcript now carries.
Before/After Example
Before Compression (45 messages, ~95K tokens)
[0] system: "You are a helpful assistant..." (system prompt)
[1] user: "Help me set up a FastAPI project"
[2] assistant: <tool_call> terminal: mkdir project </tool_call>
[3] tool: "directory created"
[4] assistant: <tool_call> write_file: main.py </tool_call>
[5] tool: "file written (2.3KB)"
... 30 more turns of file editing, testing, debugging ...
[38] assistant: <tool_call> terminal: pytest </tool_call>
[39] tool: "8 passed, 2 failed\n..." (5KB output)
[40] user: "Fix the failing tests"
[41] assistant: <tool_call> read_file: tests/test_api.py </tool_call>
[42] tool: "import pytest\n..." (3KB)
[43] assistant: "I see the issue with the test fixtures..."
[44] user: "Great, also add error handling"After Compression (25 messages, ~45K tokens)
[0] system: "You are a helpful assistant...
[Note: Some earlier conversation turns have been compacted...]"
[1] user: "Help me set up a FastAPI project"
[2] assistant: "[CONTEXT COMPACTION] Earlier turns were compacted...
## Goal
Set up a FastAPI project with tests and error handling
## Progress
### Done
- Created project structure: main.py, tests/, requirements.txt
- Implemented 5 API endpoints in main.py
- Wrote 10 test cases in tests/test_api.py
- 8/10 tests passing
### In Progress
- Fixing 2 failing tests (test_create_user, test_delete_user)
## Relevant Files
- main.py — FastAPI app with 5 endpoints
- tests/test_api.py — 10 test cases
- requirements.txt — fastapi, pytest, httpx
## Next Steps
- Fix failing test fixtures
- Add error handling"
[3] user: "Fix the failing tests"
[4] assistant: <tool_call> read_file: tests/test_api.py </tool_call>
[5] tool: "import pytest\n..."
[6] assistant: "I see the issue with the test fixtures..."
[7] user: "Great, also add error handling"Prompt Caching (Anthropic)
Source: agent/prompt_caching.py
Reduces input token costs by ~75% on multi-turn conversations by caching the
conversation prefix. Uses Anthropic's cache_control breakpoints.
Strategy: system_and_3
Anthropic allows a maximum of 4 cache_control breakpoints per request. Hermes
uses the "system_and_3" strategy:
Breakpoint 1: System prompt (stable across all turns)
Breakpoint 2: 3rd-to-last non-system message ─┐
Breakpoint 3: 2nd-to-last non-system message ├─ Rolling window
Breakpoint 4: Last non-system message ─┘How It Works
apply_anthropic_cache_control() deep-copies the messages and injectscache_control markers:
# Cache marker format
marker = {"type": "ephemeral"}
# Or for 1-hour TTL:
marker = {"type": "ephemeral", "ttl": "1h"}The marker is applied differently based on content type:
| Content Type | Where Marker Goes |
|---|---|
| String content | Converted to [{"type": "text", "text": ..., "cache_control": ...}] |
| List content | Added to the last element's dict |
| None/empty | Added as msg["cache_control"] |
| Tool messages | Added as msg["cache_control"] (native Anthropic only) |
Cache-Aware Design Patterns
Stable system prompt: The system prompt is breakpoint 1 and cached across
all turns. Avoid mutating it mid-conversation (compression appends a note
only on the first compaction).Message ordering matters: Cache hits require prefix matching. Adding or
removing messages in the middle invalidates the cache for everything after.Compression cache interaction: After compression, the cache is invalidated
for the compressed region but the system prompt cache survives. The rolling
3-message window re-establishes caching within 1-2 turns.TTL selection: Default is
5m(5 minutes). Use1hfor long-running
sessions where the user takes breaks between turns.Model identity is part of the cache key: Provider-side caches are scoped
to the model (and account/API key) serving the request. Any mid-conversation
model change — an explicit/modelswitch, primary-model fallback, or a
credential-pool rotation onto a different account — means the next request
gets zero cache hits and re-reads the full conversation at undiscounted
input price. This is inherent to how provider caches work, not something
Hermes can avoid; user-facing docs for/model, fallback providers, and
credential pools carry cost warnings for this reason. Don't add features
that silently swap the model or credentials mid-session.
Enabling Prompt Caching
Prompt caching is automatically enabled when:
- The model is an Anthropic Claude model (detected by model name)
- The provider supports
cache_control(native Anthropic API or OpenRouter)
# config.yaml — TTL is configurable: "5m", "1h", or "auto"
prompt_caching:
cache_ttl: "5m""auto" resolves once per session in agent/agent_init.py::_init_prompt_cache_config via agent/prompt_caching.py::auto_cache_ttl_for_source: 1h for human-paced sources, 5m for MACHINE_PACED_SOURCES (subagent, cron, oneshot, webhook, kanban, api, tool, batch). Auxiliary/stub calls (configured_cache_ttl()) treat auto as 5m.
The CLI shows caching status at startup:
💾 Prompt caching: ENABLED (Claude via OpenRouter, 5m TTL)Context Pressure Warnings
Intermediate context-pressure warnings have been removed (see the iteration-budget block in agent/turn_iteration_prep.py, which notes: "No intermediate pressure warnings — they caused models to 'give up' prematurely on complex tasks"). Compression fires when prompt tokens reach the configured compression.threshold (default 50%) with no prior warning step; gateway session hygiene fires as the secondary safety net at 85% of the model's context window.
Developer Guide Context Engine Plugin (`website/docs/developer-guide/context-engine-plugin.md`)
sidebar_position: 9 title: "Context Engine Plugins"
Building a Context Engine Plugin
Context engine plugins replace the built-in ContextCompressor with an alternative strategy for managing conversation context. For example, a Lossless Context Management (LCM) engine that builds a knowledge DAG instead of lossy summarization.
How it works
The agent's context management is built on the ContextEngine ABC (agent/context_engine.py). The built-in ContextCompressor is the default implementation. Plugin engines must implement the same interface.
Only one context engine can be active at a time. Selection is config-driven:
# config.yaml
context:
engine: "compressor" # default built-in
engine: "lcm" # activates a plugin engine named "lcm"Plugin engines are never auto-activated — the user must explicitly set context.engine to the plugin's name.
Directory structure
Each context engine lives in plugins/context_engine/<name>/:
plugins/context_engine/lcm/
├── __init__.py # exports the ContextEngine subclass
├── plugin.yaml # metadata (name, description, version)
└── ... # any other modules your engine needsThe ContextEngine ABC
Your engine must implement these required methods:
from agent.context_engine import ContextEngine
class LCMEngine(ContextEngine):
@property
def name(self) -> str:
"""Short identifier, e.g. 'lcm'. Must match config.yaml value."""
return "lcm"
def update_from_response(self, usage: dict) -> None:
"""Called after every LLM call with the usage dict.
Update self.last_prompt_tokens, self.last_completion_tokens,
self.last_total_tokens from the response.
"""
def should_compress(self, prompt_tokens: int = None) -> bool:
"""Return True if compaction should fire this turn."""
def compress(self, messages: list, current_tokens: int = None,
focus_topic: str = None) -> list:
"""Compact the message list and return a new (possibly shorter) list.
The returned list must be a valid OpenAI-format message sequence.
``focus_topic`` is an optional topic string from manual
``/compress <focus>``; engines that support guided compression should
prioritise preserving information related to it, others may ignore it.
"""Class attributes your engine must maintain
The agent reads these directly for display and logging:
last_prompt_tokens: int = 0
last_completion_tokens: int = 0
last_total_tokens: int = 0
threshold_tokens: int = 0 # when compression triggers
context_length: int = 0 # model's full context window
compression_count: int = 0 # how many times compress() has runOptional methods
These have sensible defaults in the ABC. Override as needed:
| Method | Default | Override when |
|---|---|---|
on_session_start(session_id, **kwargs) |
No-op | You need to load persisted state (DAG, DB) |
on_session_end(session_id, messages) |
No-op | You need to flush state, close connections |
on_session_reset() |
Resets token counters | You have per-session state to clear |
update_model(model, context_length, ...) |
Updates context_length + threshold | You need to recalculate budgets on model switch |
get_tool_schemas() |
Returns [] |
Your engine provides agent-callable tools (e.g., lcm_grep) |
handle_tool_call(name, args, **kwargs) |
Returns error JSON | You implement tool handlers |
should_compress_preflight(messages) |
Returns False |
You can do a cheap pre-API-call estimate |
get_status() |
Standard token/threshold dict | You have custom metrics to expose |
select_context(request_messages, *, conversation_messages, incoming_message, budget_tokens) |
Returns None (no-op) |
You select/route which context enters this request (retrieval, topic routing) — see below |
on_turn_complete(messages, usage=None, **kwargs) |
No-op | You ingest/index/observe the finished turn — see below |
clone_for_agent() |
copy.deepcopy(self) |
Your engine holds uncopyable state (locks, SQLite/DB connections) — see Via general plugin system |
Per-turn context selection and observation
compress() answers "context is too long → make it shorter". Two optional,
no-op-default hooks cover the orthogonal selection / observation axis, so an
engine no longer has to force should_compress() to True and abusecompress() as a per-turn callback:
def select_context(self, request_messages, *, conversation_messages=None,
incoming_message=None, budget_tokens=0):
"""Choose/replace the context for THIS request, before dispatch.
Return a new message list to use for this one provider call (retrieval,
topic routing, role/branch switching), or None to leave it unchanged.
Request-only: the persisted conversation history is never mutated.
"""
def on_turn_complete(self, messages, usage=None, **kwargs):
"""Observe a finished turn after the assistant/tool loop completes.
Receives a shallow copy of the finalized transcript plus the turn's
canonical usage dict (or None if no provider response was reached), so the
engine can ingest/index/summarize for the next select_context(). The return
value is ignored.
"""Contract:
- No-op by default, fail-open. Both default to
return None. A missing hook, an exception, or an invalid return value leaves the request untouched — so a failing engine is never worse than not installing one. The host also identity-checks for the inherited ABC default and skips it entirely, so non-implementing engines (including the built-in compressor) pay no per-request work at all. select_context()is request-only. The returned list replaces the messages for a single provider call; persisted history is never written. ReturningNone,[], a non-list, or a list containing non-dicts all fall open to the unmodified request.- Ordering / cache stability. The hook runs before prompt cache-control and every request sanitizer, so (a) a replacement still passes the same validation as any request, and (b) the no-op default leaves the request byte-identical — prompt-cache behaviour is unchanged for non-implementing engines. An engine that replaces the list changes only its own cache prefix. Evaluated per provider request (re-runs on retries).
on_turn_complete()is post-turn observation only; treatmessagesas read-only. Coverage is best-effort: it fires from the standard turn-finalization seam. Some abnormal early-return paths in the loop (e.g. a content-policy block or a provider terminal failure) persist and return without routing through finalization, so they do not currently emit this hook — treat it as a best-effort observation for completed turns, not a guaranteed callback for every early exit. Unifying all terminal paths behind one finalization seam is a separate follow-up.
Stable message identity: `message_uid`
Every message the host has persisted carries message_uid: a 32-hex id
(uuid4().hex) minted once, at the row's first insert, and stored inmessages.message_uid. It is the key to use when an engine keeps its own
per-message state (a verbatim store, a summary DAG, per-message embeddings)
and needs to recognise a message it has already seen. The physical row id
(_row_id) is not that key: it is re-issued by every copy and only present on
some restore paths.
What the host guarantees:
- Present on every engine surface once a row exists: the
compress()
input list,on_turn_complete()clones,post_llm_call'sconversation_history,on_session_end()messages, and every restored
history (CLI, TUI, ACP, gateway, compression's durable-snapshot adoption). It
is restored unconditionally, unlike_row_id. The one exception is a row
written before the column existed: on a large store the upgrade mints those
over the next few opens, so a legacy row can briefly arrive without one.
Treat a missing uid as "no identity yet", never as an error. - Kept across every host copy of the same logical message: in-place
compaction generations and their concurrent-tail clones, rotation-child
handoff copies and foreign-tail clones,replace_messagesre-issues,
rewind, export/import, and/branch/ Desktop branch copies (the child
session's copied rows keep the parent's uids). - Kept across content rewrites of the same row: the persist override, the
sanitizer's row-addressed rewrite, the interrupted-stream fill. Treat(message_uid, content)as a version of the message; never fail closed on
a content change under a known uid. - Merges keep the first constituent's uid and record the rest. Whenever
the host folds one durable message into another (alternation repair's
consecutive-user and consecutive-assistant merges, the compressor restating
an in-flight task onto its summary carrier, the real user anchor folded into
a trailing scaffolding turn, micro-compaction's adjacent-user merge), the
composite keeps the uid of the constituent whose text comes first and
records the others in_absorbed_message_uids(text order). The list is
persisted on the survivor's row (messages.absorbed_message_uids) and
restored with it, so after a restart an engine still sees thatA\n\nBis
the host's fold ofAandBrather than a new message. Merges made while
restoring a history (repair_alternation=True) record the witness the same
way. A row the host discards rather than folds (a provisional verification
candidate superseded by the final answer, or an assistant turn whose text
sits beside multimodal content, which is never joined) is not recorded: the
witness names text that lives on in the composite, nothing else. - Engine-authored rows keep the uid the engine sets. If your
compress()
output pre-stampsmessage_uidon a summary carrier (or any row it emits),
the host writes that value through the commit, both in place and on
rotation, and every later restore and copy returns it; rows without one are
minted at insert. An engine can therefore recognise its own rows by uid. - A uid names one logical message, not one row. Copies of a message share
it by design, and a host path that re-appends an edited or merged message
next to a still-active earlier row (a persist override on a restored list,
a merged survivor flushed as a new row) leaves two active rows with one uid.
Never assume per-row uniqueness in the active set; key your own state on
the uid and treat the later row as the current version. - Tool calls get per-occurrence ids too. Provider tool-call ids repeat
(Hermes mints deterministiccall_<12hex>ids for identical calls, and models
reuse ids), so an assistant message carries_tool_call_uids, a{tool_call_id: uid}map for itstool_calls, and each tool-result message
carries the matching_tool_call_uid. Calls that repeat a provider id inside
one response share one uid (every result carries it, so no call looks
unanswered). When a fold leaves one message holding calls from two turns
that share an id, that id maps to a list of uids, one per occurrence intool_callsorder. The provider-facingidinsidetool_callsis untouched. Both are minted at the assistant row's first
insert, paired onto the result when it is flushed (same batch, or from the
live list when the result lands in a later flush) and on restore (from the
preceding assistant row), persisted (messages.tool_call_uids,messages.tool_call_uid), and kept across the same copy and rewrite paths
asmessage_uid. A result whose call was never persisted with a uid has none. - Never on the wire.
message_uid,_absorbed_message_uids,_tool_call_uidsand_tool_call_uidare inPERSISTENCE_ONLY_MESSAGE_FIELDS: stripped from every outgoing provider
copy and ignored by the token estimator. Engines that never read them are
unaffected. - Absent only before the row exists. The current turn's user message has
no uid during a preflightcompress()that runs before the turn-start
flush; the same dict object receives it at that flush. Stores upgraded from
an older schema backfill a uid onto every existing row once (schema v31),
and an insert trigger mints one for any row an older build writes into a
v31 store afterwards (that build's live dicts still lack it until restored).
When to use these hooks — and when NOT to
- Implement
select_context()only when your engine must replace the
per-request context — retrieval-augmented selection, topic/branch routing,
role switching. It is the only verb that can swap which messages enter a
request: thepre_llm_callplugin hook is inject-only by documented design
(it appends to the user message and never rewrites the list, to preserve the
prompt-cache prefix). If you don't need replacement, don't implement it. - If your plugin only needs post-turn observation / ingestion (indexing,
memory sync, analytics), implement a memory provider (sync_turn()—
see Memory Provider Plugins) instead of a
context engine. A context engine takes ownership of the session's compaction
policy; a memory provider observes turns without owning anything.on_turn_complete()exists as the observation mirror for engines that
already needselect_context()— so the same component can learn from the
turn it just routed — not as a general-purpose turn callback. - Prompt-cache impact of a real
select_context(). A non-no-op selection
naturally changes the prompt-cache prefix for the turns where it changes the
selection — that request's prefix no longer matches the provider's cached
prefix, so those turns re-write cache instead of reading it. Engines should
return stable selections when nothing has changed (same object or an
equal list), and only reshape the context when the routing decision actually
differs; a selection that shuffles per turn silently forfeits cache reuse
every turn.
Engine tools
Context engines can expose tools the agent calls directly. Return schemas from get_tool_schemas() and handle calls in handle_tool_call():
def get_tool_schemas(self):
return [{
"name": "lcm_grep",
"description": "Search the context knowledge graph",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Search query"}
},
"required": ["query"],
},
}]
def handle_tool_call(self, name, args, **kwargs):
if name == "lcm_grep":
results = self._search_dag(args["query"])
return json.dumps({"results": results})
return json.dumps({"error": f"Unknown tool: {name}"})Engine tools are injected into the agent's tool list at startup and dispatched automatically — no registry registration needed.
Registration
Via directory (recommended)
Place your engine in plugins/context_engine/<name>/ (bundled) or ~/.hermes/plugins/<name>/ (user-installed; $HERMES_HOME/plugins/<name>/). The __init__.py must export a ContextEngine subclass or a register(ctx) that calls ctx.register_context_engine(...). Setting context.engine: <name> is the activation — a user-installed engine does not need a plugins.enabled entry. Bundled names win on collision.
Via general plugin system
A general plugin can also register a context engine:
def register(ctx):
engine = LCMEngine(context_length=200000)
ctx.register_context_engine(engine)Only one engine can be registered. A second plugin attempting to register is rejected with a warning.
The registered instance is shared process-wide, but every AIAgent (parent, subagents, gateway
sessions) needs its own engine so a child's update_model() cannot mutate the parent's budget.
Hermes therefore calls engine.clone_for_agent() on the registered instance at each agent init.
The default is copy.deepcopy(self); override it when the engine holds state that cannot be
deep-copied (locks, SQLite or HTTP connections) and return a fresh engine sharing the durable
backend while copying only