- Python 71.9%
- Shell 25.4%
- Dockerfile 2.7%
Registers unsloth/Qwen3.8-27B-GGUF:UD-IQ3_S as a second llamacpp preset and LiteLLM route (qwen3.8-27b-local), reported to punch above its weight for coding despite the aggressive quant. Added alongside, not in place of, qwen3-coder-30b -- --models-max 1 means only one is ever GPU-resident, so nothing is lost by keeping both available and no profile's default model changes. Verified via `just eval -k code-agent`: scores 1.00 across all cases, matching the existing model's baseline. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
||
|---|---|---|
| compose | ||
| profiles | ||
| scripts | ||
| skills | ||
| tests | ||
| zsh | ||
| .env.example | ||
| .gitignore | ||
| .justfile | ||
| a2a-wire.py | ||
| compose.yaml | ||
| config.yaml | ||
| Dockerfile | ||
| entrypoint.sh | ||
| pytest.ini | ||
| README.md | ||
hermes-local
Local Hermes agent container for dev work. Runs the Hermes gateway with an OpenAI-compatible API on port 8642, backed by a LiteLLM proxy and Mem0 for persistent memory.
Architecture
Neovim / hermes CLI
↓
hermes-local (localhost:8642)
↓
[caveman] ─→ [headroom] ← optional compression hops
↓
LiteLLM proxy ← the `litellm` profile, or your own remote
↓
OpenRouter / llama.cpp / models
The agent runs locally (with access to your local files and terminal), while all LLM inference goes through the LiteLLM proxy. Memory is persisted to the Mem0 instance.
The two bracketed hops are opt-in, and easy to lose track of: enabling them
rewrites what config.yaml and the *_BASE_URL variables point at, so the
effective path is often longer than the middle of that diagram — see
Token-reduction tools. One path deliberately skips all
of it: the claude subagent on the work account talks to Anthropic directly,
see Two Anthropic accounts.
Prerequisites
- Docker + Docker Compose
- Just runner (optional, but recommended)
Image variants
Every tool group in Dockerfile is optional, gated on a build arg. The default
build (full) includes everything.
Switch (.env) |
Installs |
|---|---|
HERMES_WITH_K8S |
kubectl, kind |
HERMES_WITH_PNPM |
pnpm |
HERMES_WITH_PI |
pi (Pi coding agent) |
HERMES_WITH_CLAUDE_CODE |
claude (Claude Code) |
HERMES_WITH_AIDER |
aider + its managed Python 3.12 |
HERMES_WITH_GO |
Go toolchain |
HERMES_WITH_RUST |
rustup, cargo |
HERMES_WITH_DIND |
nested dockerd (the code_execution tool) |
HERMES_WITH_CAVEMAN |
caveman (context + output compression) |
Each coding agent is its own switch, so you can take Claude Code without Pi or either without aider, and bumping one agent's pinned version doesn't reinstall the others.
1 installs, anything else skips. The core utilities — git, curl, jq,
ripgrep, fd-find, tmux, build-essential, python3-venv — are always
installed, as is Hermes itself.
The blocks in Dockerfile are ordered stable-first, because flipping a switch
or bumping a version invalidates that layer and every layer after it. The groups
whose versions churn most (aider, the node agents) therefore sit last, and the
uid/gid remap sits below them so the tool layers stay cacheable across host
users.
Every variant gets its own tag, so they coexist in docker images:
# .env
HERMES_IMAGE=hermes-local
HERMES_TAG=agents
HERMES_WITH_K8S=0
HERMES_WITH_GO=0
HERMES_WITH_RUST=0
just rebuild # builds and runs hermes-local:agents
Because each variant is its own tag, a plain just up also builds one that does
not exist yet — but it will happily reuse an existing tag, so after changing a
switch without changing HERMES_TAG you need just rebuild.
.env.example ships three ready-made presets to uncomment — agents (the
coding agents, no language toolchains), devops (k8s + nested docker, no coding
agents), and minimal (Hermes and the core utilities only) — and they are only
starting points; every switch is independent. All five hermes services share the
one image, so a single build covers both single-container and agents mode.
For a one-off variant without touching .env — give it its own tag, or it
overwrites the tag .env currently names:
HERMES_TAG=nolang docker compose build --build-arg WITH_GO=0 --build-arg WITH_RUST=0
Tool versions are pinned as ARG defaults in Dockerfile and can be overridden
from .env (CLAUDE_CODE_VERSION, PI_CODING_AGENT_VERSION, AIDER_VERSION,
GO_VERSION + GO_SHA256, KUBECTL_VERSION, KIND_VERSION,
CAVEMAN_VERSION). Leave them commented out to use the Dockerfile default — a
bare AIDER_VERSION= passes an empty string and overrides it.
Leaving a tool out is silent
Nothing validates the tool selection at startup. entrypoint.sh guards only
dockerd, so HERMES_WITH_DIND=0 starts cleanly (just without
code_execution); every other omission surfaces later as a failed agent tool
call, not a startup error. In particular the code-agent and
code-review-agent profiles instruct the agent to invoke claude and pi, so
turning HERMES_WITH_CLAUDE_CODE or HERMES_WITH_PI off degrades those two
profiles.
Setup
Run the interactive wizard:
just setup
It creates .env from .env.example (backing up an existing one first),
then walks an fzf picker over the build-time tools
(HERMES_WITH_*/presets — see Image variants) and the
optional compose profiles below, generating the credentials each one needs
and keeping config.yaml's browser toolset line in sync with whether you
picked the browser profile. It prints the just rebuild/just up
commands to run next; it doesn't start anything itself, and it leaves
GATEWAY_BASE_URL/GATEWAY_API_KEY/the HERMES_MODEL* vars to you (see the
table below) except for the one headroom-routing shortcut noted there.
Prefer to do it by hand instead: copy .env.example to .env and fill in
the values yourself:
cp .env.example .env
Environment variables
| Variable | Description |
|---|---|
API_SERVER_KEY |
Bearer token for the local API server — any strong random string (openssl rand -hex 32) |
API_SERVER_HOST |
Bind address for the API server (default: 0.0.0.0) |
GATEWAY_BASE_URL |
Generic shared base URL — every OpenAI-protocol lane (hermes, pi, aider) and LITELLM_BASE_URL itself fall back to this when left unset. |
GATEWAY_API_KEY |
Generic shared API key — LITELLM_API_KEY, ANTHROPIC_API_KEY, PI_API_KEY, and OPENAI_API_KEY all fall back to this when left unset. |
LITELLM_BASE_URL |
LiteLLM API base URL. Defaults to GATEWAY_BASE_URL; set this only to point the gateway's own traffic (or the litellm profile's master key) somewhere different. |
LITELLM_API_KEY |
LiteLLM API key. Defaults to GATEWAY_API_KEY. |
MEM0_HOST |
Mem0 API base URL |
MEM0_API_KEY |
Mem0 API key |
MEM0_USER_ID |
Which user's memories to read and write. Defaults to a shared placeholder; set this so memories are scoped to you across all gateways. |
SEARXNG_URL |
URL of your SearXNG instance. Enables private web search — see Web search. |
SEARXNG_SECRET |
Secret key for the local SearXNG instance (only used with the searxng profile — any strong random string) |
BROWSER_CDP_URL |
CDP URL of a headless Chromium instance. Enables the browser toolset — see Headless browser. |
HERMES_MODEL |
Main agent model (default: minimax-m3) |
HERMES_MODEL_MINI |
Lightweight background tasks — mcp, curator, web_extract, etc. (default: mimo-v2.5) |
HERMES_MODEL_FAST |
Fast-turnaround tasks — title gen, approval, vision (default: gemini-3-flash-preview) |
HERMES_MODEL_COMPRESSION |
Context compression model (default: kimi-k3) |
HERMES_PROFILE |
Hermes profile name |
HERMES_BOOTSTRAP_SKILLS |
Comma-separated skill identifiers to install on startup — see Skills. |
HERMES_BOOTSTRAP_PLUGINS |
Comma-separated Hermes plugins (owner/repo or git URL) installed and enabled on container start, in the foreground — e.g. DietrichGebert/ponytail. See Ponytail. |
PONYTAIL_DEFAULT_MODE |
Ponytail's default intensity when that plugin is installed: lite, full or ultra. |
HERMES_BOOTSTRAP_CAVEMAN |
Comma-separated agents to run caveman enable for on start (claude, pi, aider, hermes). Background, idempotent — see Coverage: the subagents. |
HERMES_BOOTSTRAP_PONYTAIL |
Comma-separated agents to install Ponytail's own adapter into on start (claude, pi). |
PROJECTS_PATH |
Host path mounted as the agent's working directory (default: ./projects) |
HERMES_IMAGE |
Repository name for the image built from Dockerfile (default: hermes-local) — see Image variants. |
HERMES_TAG |
Tag for that image (default: full). Bump it when you change the tool selection so each variant keeps its own tag. |
HERMES_WITH_K8S, HERMES_WITH_PNPM, HERMES_WITH_PI, HERMES_WITH_CLAUDE_CODE, HERMES_WITH_AIDER, HERMES_WITH_GO, HERMES_WITH_RUST, HERMES_WITH_DIND, HERMES_WITH_CAVEMAN |
1/0 switches for the optional tools in the image (all default 1) — see Image variants. |
HERMES_DNS |
Comma-separated DNS servers for the nested dockerd. Empty = probe the host's real upstream resolvers so internal names resolve the same way they do there. |
GITHUB_TOKEN |
Optional. Raises the skills hub's GitHub API limit from 60 to 5,000 req/hr — worth setting once you run just up agents, which does the bootstrap install once per container. |
ANTHROPIC_BASE_URL |
LiteLLM proxy base URL (without /v1) for the Claude Code CLI on the personal account — see Claude Code. |
ANTHROPIC_API_KEY |
API key for the Claude Code CLI on the personal account — defaults to GATEWAY_API_KEY if unset. Setting it (directly or via the fallback) is also what shadows a stored claude.ai login, which is how the account switch works. |
ANTHROPIC_MODEL |
LiteLLM model name Claude Code uses on the personal account (default: deepseek-v3.2). |
HERMES_ACCOUNT |
Which Anthropic identity the claude subagent boots as: personal (default) or work. Boot default only — flip a running stack with just account <name>. See Two Anthropic accounts. |
ANTHROPIC_WORK_MODEL |
Model for the work account. Empty takes the org's configured default. ANTHROPIC_MODEL is a LiteLLM name and means nothing to api.anthropic.com, so this lane needs its own. |
ANTHROPIC_WORK_BASE_URL |
Endpoint for the work account. Empty goes straight to api.anthropic.com; set to http://headroom-work:8787 to route that lane through its own Headroom. |
CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT |
1 (default) for LiteLLM proxy; set to 0 when using a real Anthropic API key. |
PI_CODING_AGENT_DIR |
Directory where entrypoint.sh writes ~/.pi/agent/models.json for the Pi coding agent. Defaults to /opt/data/pi/agent — see Pi config persistence. |
HERMES_A2A_TIMEOUT |
Per-peer A2A request timeout in seconds (default: 300). Raised over the plugin's own 120s default because a peer doing real work routinely runs longer — see Running multiple agents + cross-talk. |
HERMES_MANAGED_DIR |
Directory where entrypoint.sh publishes resolved ${VAR} values for the gateway, which reads config.yaml raw — see Why there is a generated managed/config.yaml. Set per service in compose; unset disables the mechanism. |
HEADROOM_LOSSLESS_ONLY, HEADROOM_SAVINGS_PROFILE |
Compression aggressiveness for the headroom profile, defaulted to 1 / balanced in compose/inference/headroom.yaml so no unredeemable CCR marker can reach an agent — see Headroom. |
DO_NOT_TRACK |
Set to 1 (the default in .env.example and in compose) to keep Headroom and Caveman from reporting anonymous usage stats. Set it empty to opt back into their upstream defaults — see Telemetry. |
The multi-container mode adds its own per-agent port/token variables — see Running multiple agents + cross-talk.
Usage
# Start hermes only
just up
# Start hermes + local mem0
just up mem0
# Start hermes + local litellm
just up litellm
# Start hermes + local SearXNG
just up searxng
# Start hermes + headless Chromium (browser tool)
just up browser
# Start hermes + local litellm + local GPU-backed llama.cpp model
just up litellm llamacpp
# Start everything
just up mem0 litellm searxng browser llamacpp
# Stop
just down
# Rebuild after Dockerfile, entrypoint, or tool-selection changes
# (a HERMES_WITH_* change needs this — `just up` reuses the existing image)
just rebuild
# Drop into the Hermes CLI
just chat
# Run any hermes subcommand
just run <subcommand>
Or with plain Docker Compose:
docker compose up -d
docker compose --profile mem0 --profile litellm up -d
Dropping into a CLI (just agent)
just agent claude
just agent pi
just agent aider "refactor the parser"
Opens one of the coding CLIs inside the container, with whatever wrappers target it, and says which those are before handing over:
stack: caveman+ponytail wrapping claude (account: personal) in hermes-local
stack: caveman wrapping aider in hermes-local
Wrappers are resolved per CLI, because the target lists differ —
HERMES_BOOTSTRAP_CAVEMAN covers claude,pi,aider,hermes while
HERMES_BOOTSTRAP_PONYTAIL covers only claude,pi, so aider legitimately gets
compression without ponytail. For claude it also names the active Anthropic
account, since just account switches which seat the tokens bill to and the
wrong one is an invisible error.
hermes is deliberately not accepted here: a hermes conversation needs a
profile flag, which is what just chat --profile below is for.
Starting a hermes conversation (just chat)
just chat
just chat --profile research-agent
just chat --profile code-agent "review the staged diff"
--profile is validated against profiles/<name>/ before anything starts.
That matters because hermes itself falls back to its default profile when
-p names one it cannot find, so a typo used to run a different agent with no
error. chat also prints one line naming the wrappers actually in force:
stack ok: compression=caveman caveman=claude,pi,aider,hermes ponytail=claude,pi via hermes-local
Validates the profile name, resolves the container the same way just chat
does, and prints what the tool stack is actually doing before handing over:
stack ok: compression=caveman caveman=claude,pi,aider,hermes ponytail=claude,pi via hermes-local
It cannot turn any of that on. caveman, ponytail and headroom are
container-level — HERMES_BOOTSTRAP_CAVEMAN, HERMES_BOOTSTRAP_PONYTAIL and
the headroom compose profile, all fixed when the container is built and
started. The recipe exists because their absence is silent: headroom down or
caveman not wrapping hermes costs you compression with no error anywhere. It
reads the running container's own environment rather than .env, since the two
drift apart whenever .env is edited without a --force-recreate.
Compression is satisfied by either hop — caveman's /w/hermes lane or a
running headroom — so a caveman-only setup reports healthy rather than being
flagged as broken.
zsh wrapper
# in ~/.config/zsh/ (ZDOTDIR), or wherever you keep shell config:
source /path/to/hermes/zsh/hermes.zsh
Gives you three commands from any directory, with tab-completion:
| Function | Completes on | Runs |
|---|---|---|
hermes-agent <cli> |
claude, pi, aider | just agent |
hermes-profile <name> |
installed profiles | just chat --profile |
hermes-profiles |
— | lists profiles |
The functions are deliberately thin forwarders to just — all logic stays in
the recipes so there is one implementation to fix, and a test enforces that the
wrapper never grows its own docker exec or container resolution.
Local services (profiles)
Every optional feature — memory, a local model proxy, private web search,
headless browsing, a local GPU-backed LLM, context compression — is a separate
Docker Compose profile. None of them are started by a bare just up; opt into
only the ones you want.
Where the compose files live
compose/ is grouped by what a file is for, so the model path, the tool
backends and the agent mesh stay separable:
compose/
inference/ everything in the model path
llamacpp.yaml + llamacpp.presets.ini local GPU model (the `llamacpp` profile)
litellm.yaml + litellm.config.yaml the proxy and its model registry
headroom.yaml compression proxy (two services)
tools/ backends the agent's tools call
searxng.yaml + searxng.settings.yml private web search
browser.yaml headless Chromium (CDP)
memory/
mem0.yaml memory server + its Postgres
agents/
agents.yaml one container per profile, wired by A2A
Two things to know before moving anything here again. Compose resolves relative
paths in an included file against that file's directory, so a *.config
mount sits next to the service that mounts it, and compose/agents/agents.yaml
carries ../../ on its build context and bind mounts — one level for
agents/, one for compose/. Get that wrong and it fails quietly: compose
auto-creates an empty directory and the agent starts with no profile installed.
mem0
Runs the mem0 server on http://localhost:8000 backed by a local Postgres +
pgvector database. When using this profile, set in .env:
MEM0_HOST=http://localhost:8000
MEM0_API_KEY= # leave blank — AUTH_DISABLED is set
MEM0_POSTGRES_PASSWORD= # any strong password
MEM0_JWT_SECRET= # any strong random string
The mem0 image (oci.bvoy.dev/homelab/mem0-server) routes all LLM and embedder
calls through the litellm service, so run both profiles together if you want a
fully local stack:
just up mem0 litellm
litellm
Runs LiteLLM on http://localhost:4000 backed by a local Postgres database.
Model config is in compose/inference/litellm.config.yaml — edit it to add or
remove models. That file is this profile's registry, not a description of
whatever LiteLLM you actually point at: if LITELLM_BASE_URL targets a remote
proxy, the names that work are that deployment's and this file has no say.
(.env.example's own HERMES_MODEL default is not in it, for exactly that
reason.) When using this profile, set in .env:
LITELLM_BASE_URL=http://localhost:4000/v1
LITELLM_API_KEY= # becomes the LiteLLM master key, and the credential
# this proxy uses when forwarding to HOMELAB_PROXY_URL
HOMELAB_PROXY_URL= # upstream LiteLLM for every hosted model, with /v1
LITELLM_POSTGRES_PASSWORD= # any strong password
Model routing
Every route is one of two kinds, and knowing which one you are looking at is the difference between a five-second fix and an hour:
| Kind | Goes to | Needs | Costs |
|---|---|---|---|
| Local | http://llamacpp:8080/v1 |
the llamacpp profile |
nothing |
| Passthrough | HOMELAB_PROXY_URL |
that proxy to be up | per-token, there |
Local is a single route, qwen3-coder-local. The name after openai/ is
not a file or a tag — it is a [section] in
compose/inference/llamacpp.presets.ini, which is where the GGUF is actually
chosen. Change models there, not here.
Passthrough is everything else: the hosted models are forwarded 1:1 to
another LiteLLM that owns the real provider keys. Model names are deliberately
identical on both sides, so deepseek-v3.2 means the same thing whichever end
you ask. This deployment holds no provider credentials — no OpenRouter key,
no Anthropic key, nothing but HOMELAB_PROXY_KEY (which is just
LITELLM_API_KEY reused). Rotating a provider key is done upstream and this
file never changes.
openai/ in front of a model is a protocol marker, not a vendor: llama.cpp
and LiteLLM both speak the OpenAI API, so both upstreams are addressed that
way. It does not mean the request goes to OpenAI.
To add a hosted model, add it to the upstream proxy first, then copy the block:
- model_name: some-new-model # must exist upstream, spelled identically
litellm_params:
model: openai/some-new-model
api_base: os.environ/HOMELAB_PROXY_URL
api_key: os.environ/HOMELAB_PROXY_KEY
Three things that will bite you:
just healthpassing does not mean a model works. It checks the endpoint, not each route. A route whose upstream name is wrong fails at turn time withInvalid model name passed in model=…, and the failing name in that message is the one that reached the upstream, which is how you tell the two hops apart.- LiteLLM re-reads this file while running. Editing it in place can drop a
route out of the live proxy mid-session even without a restart. After editing,
docker compose --profile litellm up -d --force-recreate litellm. - Reasoning models return empty content on a small
max_tokens.minimax-m3andglm-5.3spend the budget thinking; atmax_tokens: 6you getcontent: nulland astopfinish reason, which looks like a broken route and is not. Give them a few hundred tokens before concluding anything.
searxng
Runs a local SearXNG instance on http://localhost:8080, config in
compose/tools/searxng.settings.yml. When using this profile, set in .env:
SEARXNG_URL=http://searxng:8080
SEARXNG_SECRET= # any strong random string
just up searxng
browser
Runs headless Chromium (CDP on http://localhost:9222) for the browser toolset.
It sits on a dedicated static-IP network (hermes-net) because Chrome's CDP
HTTP endpoint rejects any Host header that isn't a literal IP or localhost —
the compose service name won't work as a cdp_url. When using this profile, set
in .env:
BROWSER_CDP_URL=http://172.28.99.10:9222
just up browser
llamacpp
Runs llama.cpp's llama-server on
http://localhost:8081 with GPU passthrough — ROCm for AMD GPUs (via
/dev/kfd + /dev/dri; no NVIDIA-style device reservation exists for AMD).
Wired into the litellm profile's compose/inference/litellm.config.yaml as model
qwen2.5-coder-local, routed to http://llamacpp:8080/v1 with no real API key.
Port 8081 on the host because searxng already publishes 8080.
just up llamacpp litellm
There is no pull step. The server runs in router mode — started without
-m, it owns the port and spawns one child process per model listed in
compose/inference/llamacpp.presets.ini, loading it on the first request that names it:
[qwen2.5-coder-14b]
hf-repo = Qwen/Qwen2.5-Coder-14B-Instruct-GGUF:Q4_K_M
ctx-size = 16384
The hf-repo key is what replaces ollama pull: the GGUF downloads into the
llamacpp-cache volume the first time the model is asked for, which makes that
one request slow and every later one fast. To serve a different model, add a
section and point a litellm.config.yaml entry at it — the section name is the
model name over the wire, so [qwen2.5-coder-14b] is what openai/ is
prefixed to there. --models-max 1 in compose/inference/llamacpp.yaml keeps one model
resident at a time; a request for another swaps the first out rather than
competing for VRAM.
Two things to know about the image. Upstream builds the GPU images but doesn't
CI-test them, so if the server starts and offloads nothing, build locally with
AMDGPU_TARGETS set for your card (gfx1030 for Navi 21 / RX 6800-6900). And
if your GPU isn't AMD/ROCm, edit compose/inference/llamacpp.yaml's image: and
devices: for your hardware — swap the tag for server-cuda or
server-vulkan, and for CUDA drop devices: in favour of the NVIDIA
deploy.resources.reservations.devices block.
headroom
Runs Headroom on
http://localhost:8787 — a compression proxy that sits between the agents and
the model and shrinks what the model reads (tool output, logs, files,
conversation history), keeping the originals in a local cache the agent can
retrieve from. Dashboard at http://localhost:8787/dashboard.
It is a profile rather than a tool in the hermes image for two reasons: it is an HTTP service, so a single instance serves every hermes container and every coding agent inside them, and its Python dependency set is ~1.5 GB that the agent image does not have to carry.
The profile defines two services, because the upstream is per-process (one
ANTHROPIC_TARGET_API_URL each) and the two Anthropic accounts need different
ones. just up headroom starts both:
| service | host port | forwards to | used by |
|---|---|---|---|
headroom |
127.0.0.1:8787 |
HEADROOM_TARGET_URL (a remote LiteLLM, or this repo's own litellm profile) |
the gateway, pi, aider, and claude on personal |
headroom-work |
127.0.0.1:8788 |
api.anthropic.com |
claude on work, if ANTHROPIC_WORK_BASE_URL is set |
headroom-work is inert unless ANTHROPIC_WORK_BASE_URL points at it — see
Two Anthropic accounts.
It only does something once the hermes side is pointed at it. In .env:
GATEWAY_BASE_URL=http://headroom:8787/v1 # gateway, pi and aider
ANTHROPIC_BASE_URL=http://headroom:8787 # claude — own var, and no /v1
HEADROOM_TARGET_URL=https://llm.example.internal # ...forwarding to LiteLLM
HEADROOM_UPSTREAM_ALLOWED_HOSTS=llm.example.internal
HEADROOM_TARGET_URL can equally point at this repo's own litellm profile
(http://litellm:4000, allowlist host litellm) to chain
headroom → local litellm → llamacpp/HOMELAB_PROXY_URL, rather than a remote
LiteLLM you run elsewhere.
ANTHROPIC_BASE_URL does not follow GATEWAY_BASE_URL — Claude Code is the one
client that needs its own line. See
Coverage: the subagents.
HEADROOM_TARGET_URL is an origin, not a /v1 URL — the proxy appends the
inbound request path, so a /v1 suffix here produces /v1/v1/chat/completions
upstream. It is applied to both OPENAI_TARGET_API_URL and
ANTHROPIC_TARGET_API_URL, which covers the OpenAI-compatible path (hermes,
aider, pi) and the Anthropic path (Claude Code) in one setting. With it unset,
the proxy routes to whatever public provider the request names, which is almost
never right in front of a self-hosted LiteLLM.
just up headroom
State (the compressed-originals cache, savings history, memory DB) lives in the
headroom-state volume. Losing it invalidates any retrieval handle an agent is
still holding, so it is a named volume rather than a tmpfs.
Neovim
Point your Neovim AI plugin at:
- Base URL:
http://127.0.0.1:8642/v1 - Model:
hermes-agent - Auth: Bearer token from
API_SERVER_KEYin your.env
Local files
Set PROJECTS_PATH in .env to point at your projects directory:
PROJECTS_PATH=/path/to/your/projects
The container mounts it at /opt/data/projects, which is the agent's default
working directory.
The container's hermes user is automatically built with your host uid/gid
(just up/just rebuild export HERMES_UID/HERMES_GID via id -u/id -g),
so it reads/writes bind-mounted host files as you rather than as the base
image's baked-in uid 10000. If you ever run plain docker compose directly
instead of just, set HERMES_UID/HERMES_GID in .env to match your host
user, or mounted directories will only be readable, not writable.
Workspace (CWD mount)
The container also mounts /opt/data/workspace, which tracks your current
working directory. This is handled automatically — no manual config needed.
Via the zsh function (recommended): add this to your .zshrc:
function hermes {
local running_cwd
# One docker call that does double duty: returns the host path mounted at
# /opt/data/workspace if the container is running, and exits non-zero if
# it isn't.
# Collapsing the old `docker ps | grep` + `docker inspect` pair into a
# single `docker inspect` removes the second docker round-trip on the hot
# path (the common case of "container already up, right cwd").
running_cwd=$(docker inspect hermes-local \
--format '{{range .Mounts}}{{if eq .Destination "/opt/data/workspace"}}{{.Source}}{{end}}{{end}}' 2>/dev/null)
if [[ $? -ne 0 || "$running_cwd" != "$PWD" ]]; then
echo "Starting hermes..." >&2
docker compose -f ~/git/homelab/hermes/compose.yaml down 2>/dev/null || true
# HERMES_UID/GID matter here for the same reason they do in `just up`:
# this bypasses just, so nothing else exports them and bind-mounted
# host files would end up read-only to the container's hermes user.
HOST_CWD="$PWD" \
HERMES_UID="$(id -u)" HERMES_GID="$(id -g)" \
docker compose -f ~/git/homelab/hermes/compose.yaml up -d
fi
docker exec -it hermes-local hermes "$@"
}
The function inspects the running container's /opt/data/workspace mount and
restarts only when your $PWD has changed. Same project — instant exec.
Different project — transparent restart with the new directory mounted.
Via just: just up and just rebuild capture the directory where just was
invoked using invocation_directory() and export it as HOST_CWD before
starting compose.
Memory
Hermes persists memories to the Mem0 instance so context carries over across
sessions and restarts. Set MEM0_USER_ID to your own identifier so memories are
scoped to you rather than the shared default:
MEM0_USER_ID=your-username
Local state persistence
The container mounts two named Docker volumes so state survives container restarts and rebuilds:
| Volume (declared) | Actual name | Mount point | What lives there |
|---|---|---|---|
hermes-home |
hermes_hermes-home |
/opt/data |
Sessions, skills, cron jobs, mem0 history, profile state, auth credentials. |
hermes-dind |
hermes_hermes-dind |
/var/lib/docker |
Images and containers built by the nested docker daemon (docker-in-docker). |
Compose prefixes named volumes with the project name, so the names you pass to
docker volume are hermes_hermes-home / hermes_hermes-dind — not the
hermes-home declared in compose.yaml. just volumes resolves the prefix for
you (including under a renamed project).
When you first start the container, Docker copies the image's /opt/data
contents (including SOUL.md and other built-in files) into hermes-home, so
everything appears as expected. Both volumes are auto-created by
docker compose up and persist across docker compose down and just rebuild.
Why hermes-dind matters: the container runs an isolated nested dockerd
(only on hermes-local, and on hermes-code-agent in agents mode) so the agent
can build and run containers via the terminal/code_execution toolsets. Images
built inside the container — including anything cached during a session — live
in hermes-dind. Without that volume, every rebuild would force the agent to
re-pull base images and lose its build cache.
Inspecting and pruning the volumes:
just volumes ls # every volume compose made for this project
just volumes inspect # hermes-home / hermes-dind mountpoints on the host
# Nuke both — wipes sessions, skills, cron, mem0 history, AND DinD cache:
just volumes prune # confirms with [y/N] — accepts y / Y / yes / YES
just volumes prune-home # only hermes-home (keeps DinD image cache)
just volumes prune-dind # only hermes-dind (keeps agent state)
The same thing with plain Docker, remembering the project prefix:
docker volume ls | grep hermes
docker volume inspect hermes_hermes-home
docker volume rm hermes_hermes-home
docker compose down -v will also remove both, but is silent about it — prefer
just volumes prune which confirms before deleting.
To salvage state from an orphaned anonymous volume (an older setup that predates
the named-volume change), copy files with docker cp from the old container
before it's removed.
Web search
Set SEARXNG_URL to route web searches through your self-hosted SearXNG
instance:
SEARXNG_URL=https://your-searxng-instance
The searxng-search skill activates automatically when this variable is set
(via HERMES_BOOTSTRAP_SKILLS — see below). It also acts as a fallback for any
web search the agent performs when no other search provider is configured.
Headless browser
Set BROWSER_CDP_URL to enable the browser toolset (browser_navigate,
browser_click, browser_console, browser_cdp, etc.), pointed at any Chrome
DevTools Protocol endpoint:
BROWSER_CDP_URL=http://172.28.99.10:9222
The browser profile (see Local services) runs a
headless Chromium instance that satisfies this. Without BROWSER_CDP_URL set,
the browser tools are still registered but fail when invoked.
Skills
Skills are installed at container startup from HERMES_BOOTSTRAP_SKILLS — a
comma-separated list of skill identifiers. Anything already present under
$HERMES_HOME/skills/ is skipped, so restarts don't re-download it.
The install runs in the background: it resolves against the skills hub over
the GitHub API (unauthenticated = 60 req/hr unless GITHUB_TOKEN is set), which
can spend minutes in rate-limit backoff, and the gateway shouldn't wait on an
optional skill fetch. Progress goes to $HERMES_HOME/logs/bootstrap-skills.log.
HERMES_BOOTSTRAP_SKILLS=official/research/searxng-search,some/other/skill
To find and install skills:
just skills search <query>
just skills install <identifier>
Claude Code
The claude CLI (Anthropic's autonomous coding agent) is installed in the
container and wired into the code-agent profile via the
autonomous-ai-agents/claude-code Hermes skill.
It runs as one of two accounts. On personal — the default, and everything this
section describes unless it says otherwise — requests route through your LiteLLM
proxy and no Anthropic subscription is involved. On work they go to Anthropic
on a claude.ai seat instead. See
Two Anthropic accounts.
Configuration
Three env vars in .env point the CLI at LiteLLM. They configure the
personal account; the work account ignores all three.
# Base URL of the LiteLLM proxy — no /v1 suffix
ANTHROPIC_BASE_URL=https://your-litellm-host
# Leave unset to inherit GATEWAY_API_KEY, or set explicitly — any non-empty
# value works against a local proxy
ANTHROPIC_API_KEY=your-litellm-key
# Any model_name from compose/inference/litellm.config.yaml (default: deepseek-v3.2)
ANTHROPIC_MODEL=deepseek-v3.2
The container passes these through automatically. .env only reaches a
container when the container is created, so after changing them:
just up # recreates the container and picks up .env
just restart is not enough — it is docker compose restart, which reuses
the existing container and its environment. just rebuild also works but only
matters when Dockerfile or entrypoint.sh changed.
Session persistence
Claude Code keeps its settings, credentials, and session transcripts in
CLAUDE_CONFIG_DIR (default $HOME/.claude; transcripts land in
$CLAUDE_CONFIG_DIR/projects/<cwd-slug>/*.jsonl). compose.yaml pins it
explicitly:
CLAUDE_CONFIG_DIR: /opt/data/.claude
That path lives on the hermes-home volume (see
Local state persistence), so sessions survive
container restarts, just rebuild, and host reboots. It's the same directory
the default would resolve to — $HOME is /opt/data — but pinning it keeps
sessions on the volume even if the base image ever changes $HOME. The
tradeoff: just volumes prune-home wipes them along with everything else.
Resume a past session the usual way:
just shell
claude --continue # most recent session in this directory
claude --resume # pick from a list
Sessions are keyed by the working directory inside the container, so
everything run from the mounted workspace shares the slug -opt-data-workspace
regardless of which host directory just up was invoked from. If you want
separate session histories per project, run claude from a per-project path
under /opt/data/projects/<name> instead of from the workspace mount.
Using Claude Code via the code-agent
Ask the code-agent profile to use Claude Code for implementation tasks. It
will invoke claude in print mode (-p) for one-shot tasks or spin up an
interactive tmux session for multi-turn work:
# Route a task to code-agent (it picks up claude-code automatically)
just run kanban create "Implement feature X" --assign code-agent
# Or chat with the code-agent directly
just chat --profile code-agent
Inside the session you can also ask it directly:
Use claude-code to refactor the auth module in /opt/data/projects/myapp
Using Claude Code directly
Drop into the container and run the CLI yourself:
docker exec -it hermes-local bash
# One-shot task (print mode — exits when done)
claude -p "Add error handling to all API calls in src/" \
--max-turns 10 \
--allowedTools "Read,Edit,Bash(npm run lint:*)"
# Interactive session (requires tmux inside the container)
tmux new-session -s claude-work
claude
Model selection
ANTHROPIC_MODEL must match a model_name your LiteLLM actually serves. With
the litellm profile that is compose/inference/litellm.config.yaml; against a remote
proxy it is that deployment's list, which this repo does not contain — ask it
with curl -H "Authorization: Bearer $LITELLM_API_KEY" $LITELLM_BASE_URL/models.
The models below are the litellm profile's; a remote proxy may serve none of
them. Good choices for coding:
| Model | Notes |
|---|---|
deepseek-v3.2 |
Default — strong general coder |
qwen3.8-max |
Balanced general model |
qwen2.5-coder-local |
Local llama.cpp model — no API cost, needs just up llamacpp litellm |
Non-Anthropic models aren't in Claude Code's built-in registry, so it can't look
up their context window.
CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT=1 (set in compose.yaml)
tells it to ask the API for the real window instead of defaulting to 200k for
auto-compact.
Two Anthropic accounts (just account)
Claude Code inside the container can run as either of two identities, and you can swap between them without restarting anything:
personal (default) |
work |
|
|---|---|---|
| credential | ANTHROPIC_API_KEY (defaults to GATEWAY_API_KEY) |
your org's Claude.ai Enterprise seat, OAuth |
| endpoint | caveman → headroom → litellm | api.anthropic.com, direct |
CLAUDE_CONFIG_DIR |
/opt/data/.claude |
/opt/data/.claude-work |
| model | ANTHROPIC_MODEL (a LiteLLM name) |
ANTHROPIC_WORK_MODEL, or the org default |
| headroom | yes (headroom, :8787) |
optional (headroom-work, :8788) |
| caveman | yes | skill only (proxy stays off — see below) |
| ponytail | yes | yes |
just account # status: which account, and who each one is
just account work # flip, and persist as the boot default in .env
just account personal
just account work --runtime # flip without writing .env
just account login # one-time interactive seat login, if needed
just account test # prove which endpoint is really being used
HERMES_ACCOUNT in .env is the boot default, and — like every variable in
this repo — it only reaches a container when the container is created.
just restart is docker compose restart, which reuses the existing container
and its environment, so editing .env and restarting changes nothing; use
just up. Because of that, a restart deliberately preserves whichever
account is live rather than resetting it: blindly re-applying the container's
(now stale) HERMES_ACCOUNT would revert a persisted flip to the wrong value
while .env still read the new one. entrypoint.sh tracks the last-applied
default in /opt/data/account.boot and resets the live account only when that
value actually changes. just account and just account status both point out
the mismatch when a container predates an .env edit.
How the switch works. The whole thing turns on one fact: Claude Code ranks
an explicit ANTHROPIC_API_KEY above a stored OAuth credential. So the
LiteLLM key shadows the seat, and clearing it reveals it:
$ docker exec hermes-local claude auth status --json
{"authMethod":"claude.ai","apiKeySource":"ANTHROPIC_API_KEY","email":null}
$ docker exec -e ANTHROPIC_API_KEY= hermes-local claude auth status --json
{"authMethod":"claude.ai","email":"you@example.com","orgName":"…","subscriptionType":"enterprise"}
entrypoint.sh installs a shim at /opt/data/.local/bin/claude — a directory
already ahead of /usr/local/bin on the image's PATH — which reads
/opt/data/account on every invocation and, for work, re-execs the real
binary with ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_BASE_URL
and ANTHROPIC_MODEL unset and CLAUDE_CONFIG_DIR pointed at the work
directory. Every caller of a bare claude is covered without knowing:
the profiles' SOUL.md, the autonomous-ai-agents/claude-code skill, and you
in just shell.
Because each delegation is a fresh process, flipping the state file is enough —
no restart, and one write covers all four containers in agents mode (they share
the hermes-home volume). It is also process-scoped: a claude already running
keeps the account it started on, so a long tmux session won't switch
mid-flight.
Per delegation, without touching the ambient account:
HERMES_ACCOUNT_OVERRIDE=work claude -p '<task>'
Why the work lane gets its own CLAUDE_CONFIG_DIR. This is a safety
property, not tidiness. caveman enable claude writes its proxy URL into
$CLAUDE_CONFIG_DIR/settings.json as an env block:
"env": {"ANTHROPIC_BASE_URL": "http://127.0.0.1:8787/w/claude"}
and a settings env block overrides the process environment — so clearing
ANTHROPIC_BASE_URL in .env does not clear it, and there is no disable path
for a lane dropped from HERMES_BOOTSTRAP_CAVEMAN (only hermes is a real
toggle). Sharing one config directory would therefore send
Authorization: Bearer <your employer's OAuth token> to headroom, and on to
LiteLLM and whatever provider sits behind it. So /opt/data/.claude stays the
personal lane with its caveman wiring intact, and the work directory has env
and hooks stripped on every boot — which makes it self-healing if a stray
caveman enable ever lands there.
just account test is the assertion for exactly this. Each headroom instance
counts the requests it proxies, so the test runs one real turn and checks that
a lane moved its own counter and only its own — a work turn that moves :8787
(the LiteLLM-facing instance) fails loudly. It reads those counters from
/stats rather than parsing claude --debug api, which does not log request
URLs in 2.1.x.
Compression on the work lane. Headroom works here too, but it needs its
own instance: the target URL is per-process (one ANTHROPIC_TARGET_API_URL),
and the headroom service has to keep pointing at LiteLLM for the personal
lane. compose/inference/headroom.yaml therefore defines a second service,
headroom-work, targeting api.anthropic.com — same headroom compose
profile, so just up headroom starts both:
# .env
ANTHROPIC_WORK_BASE_URL=http://headroom-work:8787 # an ORIGIN, no /v1
Empty, the work lane goes straight to Anthropic. This is a supported shape
rather than a workaround: headroom has a dedicated Anthropic pipeline and an
OAuth subscription-usage poller (GET /api/oauth/usage) built for exactly this
— fronting Claude Code on a subscription. Measured on a real work-lane turn:
summary.primary_model = claude-sonnet-5
summary.compression.best_detail = 24,297 → 23,062 tokens (5.1%)
subscription_window.poll_count = 1
subscription_window.poll_errors = 0
Two things to know before turning it on. The headroom-work-state volume
accumulates compressed originals of work-account traffic, and just volumes
does not manage it — drop it by hand if that matters. And the usage poller
makes its own outbound call to Anthropic with the seat's credential every
HEADROOM_SUBSCRIPTION_POLL_INTERVAL seconds (default 300); set
HEADROOM_NO_SUBSCRIPTION_TRACKING=1 to stop it.
Caveman's proxy is still off on the work lane — a real vendor limitation,
not an oversight. A per-account isolated instance (own port, own upstream)
was tried and reverted: this build's caveman CLI hardcodes 127.0.0.1:8787
for its native (hooks-based) Claude Code integration — enable, status and
run -- all probe that fixed port when deciding whether a proxy is already
running, and refuse to start a second one ("something else is listening on
127.0.0.1:8787 — this session is not compressed; caveman will not restart a
process it does not own"). Neither CAVEMAN_LISTEN nor a listen: key in
caveman.yaml changes that for this path — CAVEMAN_LISTEN only governs a
separate standalone proxy mode (caveman start/caveman tools compress),
not the native integration caveman enable claude sets up. So there is no
way, with this caveman version, to run a second Claude Code proxy lane
alongside the personal one without either sharing its single config (which
is the OAuth-token-to-LiteLLM leak CLAUDE_CONFIG_DIR isolation exists to
prevent) or serializing the two accounts onto one proxy that gets
reconfigured on every just account flip (which breaks the "flip without
restarting" property the account switch is built around). Headroom is the
compression layer for the work lane instead — see above.
The skill half of caveman (a ruleset, no proxy, no settings.json changes)
has no such limitation and is installed for both lanes, via
caveman tools skills install in bootstrap_caveman().
What this does not cover. The hermes gateway's own turns. Those always run
on LITELLM_BASE_URL, on both accounts — an Enterprise seat is usable by Claude
Code, the sanctioned client, not by hermes. HERMES_MODEL and friends are
untouched by the account switch. If you need the heavy pass on the work seat,
that is what the claude subagent is for.
Before pointing autonomous delegation at a work seat, check with whoever administers it. An employer-managed seat driven non-interactively from a privileged container, against repos that may not be theirs, is a policy question — and managed settings can change under you.
Aider and Pi coding agents
The container includes two additional coding agents alongside Claude Code:
Aider (AI pair programming tool)
Aider is installed with uv tool install --python 3.12 aider-chat (pinned in
the Dockerfile via AIDER_VERSION; Python 3.12 because 3.13 dropped audioop,
which aider's voice dependency still imports) and available as the aider
command. It's a pair programming tool that works alongside git to edit code in
your existing projects.
Usage:
docker exec -it hermes-local bash
aider --model deepseek --api-key deepseek=<key>
Pi coding agent (pi.dev)
Pi is installed from npm as @earendil-works/pi-coding-agent (pinned in the
Dockerfile via PI_CODING_AGENT_VERSION) and available as the pi command.
It's an autonomous coding agent similar to Claude Code but with different
capabilities.
Usage:
docker exec -it hermes-local bash
pi "Implement a new feature in the current directory"
Pi config persistence
Pi keeps its custom-provider config in <dir>/agent/models.json. On every
container start, entrypoint.sh regenerates that file from the PI_BASE_URL /
PI_MODEL / PI_API_KEY env vars (it does not read credentials from disk).
The apiKey field stays the literal string $PI_API_KEY — Pi resolves it from
the environment at request time, so the API key never gets baked into a
persistent file.
The directory defaults to /opt/data/pi/agent, which sits inside the persisted
/opt/data volume and survives container rebuilds. To point Pi at a different
location, set PI_CODING_AGENT_DIR:
PI_CODING_AGENT_DIR=/path/to/pi/agent
Note: Pi itself still writes an auth.json and models-store.json to
${HOME}/.pi/agent on first use (auth state, model registry). That's an
unavoidable Pi behavior — you can't redirect it via env var. The active
models.json, however, lives wherever PI_CODING_AGENT_DIR points.
Environment variables
Both agents inherit the LiteLLM proxy configuration automatically. Additional environment variables for customization:
| Variable | Description |
|---|---|
AIDER_MODEL |
Model to use with Aider (default: openai/deepseek-v3.2) |
AIDER_OPENAI_API_KEY |
API key for Aider — set from LITELLM_API_KEY by compose (which itself defaults to GATEWAY_API_KEY) |
PI_MODEL |
Model to use with Pi (default: deepseek-v3.2) |
PI_API_KEY |
API key for Pi (inherits from GATEWAY_API_KEY) |
PI_BASE_URL |
Base URL for Pi API (inherits from GATEWAY_BASE_URL) |
Repo-owned skills (skills/)
skills/ at the repo root is mounted read-only at /opt/skills-src, and on
every container start entrypoint.sh copies each subdirectory that contains a
SKILL.md into $HERMES_HOME/skills/autonomous-ai-agents/<name>, replacing the
previous copy. That's the extension point for skills you write yourself and want
version-controlled alongside this repo:
skills/
my-skill/
SKILL.md # required — a directory without it is skipped
The directory ships empty; nothing here is required for Aider or Pi, which are
plain CLIs (aider, pi) inside the container. Hermes' own bundled
autonomous-ai-agents skills (including claude-code) come from the base image
and need no setup.
Note that HERMES_BOOTSTRAP_SKILLS is a different mechanism — it takes skills
hub identifiers, not local paths, so a local directory can't be listed there.
Model selection
Both tools support the same models as Claude Code through the LiteLLM proxy. See the Claude Code model selection section for compatible models.
Token-reduction tools
Three optional add-ons that cut the token bill from different ends. None of them is required, none holds credentials, and each is wired in at the layer it actually belongs to rather than all being installed the same way:
| Tool | Cuts | Shape here | Enable with |
|---|---|---|---|
| Headroom | input — what the model reads | compose profile (its own container) | just up headroom + base URLs |
| Caveman | output (skill) and input (its proxy) | caveman CLI in the hermes image |
HERMES_WITH_CAVEMAN (default on) |
| Ponytail | the code the agent writes in the first place | Hermes plugin, installed at container start | HERMES_BOOTSTRAP_PLUGINS=DietrichGebert/ponytail |
They compose rather than compete: Ponytail shrinks what gets built, Caveman shrinks how the agent talks about it, Headroom shrinks what it had to read to get there.
Headroom
A service, not a tool in the image — see the headroom profile for
the wiring. The short version: just up headroom, point GATEWAY_BASE_URL at
http://headroom:8787/v1, point HEADROOM_TARGET_URL back at a LiteLLM (a
remote one, or this repo's own litellm profile).
Everything the CLI does (headroom learn, headroom memory, the MCP server) is
available in that container:
docker exec -it hermes-headroom-1 headroom stats
docker exec -it hermes-headroom-1 headroom learn --help
headroom wrap <agent> is the one mode that does not fit this layout: it
rewrites the wrapped agent's config and installs
Serena on the machine the agent runs on,
which for claude / pi / aider is the hermes container, not this one.
Pointing the base URLs at the proxy achieves the same routing without any config
rewriting, so prefer that.
Compression is pinned to lossless — do not undo this casually
compose/inference/headroom.yaml sets HEADROOM_LOSSLESS_ONLY=1 and
HEADROOM_SAVINGS_PROFILE=balanced, overriding the image's defaults.
Headroom's lossy SmartCrusher paths replace a chunk of context with an opaque marker:
<<ccr:e22c03bc43f0,string,2.4KB>>
The agent is meant to redeem that through an injected headroom_retrieve tool.
On the OpenAI chat-completions streaming path it is never injected —
headroom forwards SSE immediately and cannot intercept the tool call, so it logs
this on every request and moves on:
CCR: skipping retrieval-tool injection for OpenAI chat streaming;
this path cannot intercept tool calls
Streaming chat-completions is the path hermes, aider and pi all use. The marker
still goes into the context; the means to resolve it does not. The image also
defaults to the coding profile (min_chars_block=25, compress_user=True),
so even a few hundred bytes is a candidate.
The concrete cost, before this was pinned: a kanban swarm worker called
kanban_show, got a marker where its own task body should have been, decided
its tool results had been forged, and spent eight minutes bisecting kanban.db
— where the rows were intact the whole time. With HEADROOM_LOSSLESS_ONLY=1 the
same worker finishes in under 40 seconds.
Lossless tabular/JSON compaction and Kompress still apply; only the
marker-emitting paths are off, so output stays byte-recoverable. Caveman is a
separate layer and is unaffected. Set it to 0 only after verifying that
redemption works on your lane — headroom stats reports
"mcp": {"retrievals": N}, and N staying at 0 while markers are being
emitted is the failure signature.
Caveman
Stays in the hermes image, because it has no standalone server mode: its proxy
lives for the lifetime of the process it wraps, so it has to run next to the
gateway. Installed from npm as @caveman-ai/cli (pinned via CAVEMAN_VERSION),
about a megabyte — the BSL-licensed engine it drives is downloaded on first run
into the persisted /opt/data volume.
Two ways to apply it. caveman enable <agent> is the persistent one — it writes
hooks into that agent's own config and the agent stays caveman-enabled when
invoked normally, which is what HERMES_BOOTSTRAP_CAVEMAN automates (see
Coverage: the subagents).
caveman <agent> and caveman run -- are the launch-time forms, for a session
you want wrapped without changing any config.
docker exec -it hermes-local bash
caveman setup --install # fetch the engine — needed once, before anything else
caveman enable claude # persistent: claude | pi | aider | hermes | codex | …
caveman disable claude # undo it (`--all` for everything)
caveman run -- <cmd> # one-off: wrap any command for this run only
caveman tools compress # standalone: compress stdin, returns a recovery handle
caveman learn # rank token sinks from existing session history
caveman enable hermes is the exception: in single-container mode it fails
EBUSY, because config.yaml is a single-file bind mount and caveman writes
config by rename(2). It works in agents mode, where each config lives inside
the volume.
The skill half needs nothing installed at all — it is a ruleset, no proxy, no
settings.json changes. entrypoint.sh installs it automatically for claude
(both accounts, when claude is in HERMES_BOOTSTRAP_CAVEMAN) via
caveman tools skills install --agent claude --dir <config-dir>/skills/caveman-learn
— local and offline, unlike caveman enable's proxy/hooks half. It can also
come from the skills hub (npx skills add JuliusBrussee/caveman inside the
container) with HERMES_WITH_CAVEMAN=0. caveman login is for the Caveman
Cloud beta; the local proxy and the skill need no account.
Ponytail
Not a binary — a Hermes plugin plus six skills, so it installs into the agent's
home rather than into the image. entrypoint.sh installs everything listed in
HERMES_BOOTSTRAP_PLUGINS on container start:
# .env
HERMES_BOOTSTRAP_PLUGINS=DietrichGebert/ponytail
PONYTAIL_DEFAULT_MODE=lite # lite | full | ultra
just restart # no rebuild — the plugin lands in the /opt/data volume
It injects the active mode before each LLM turn and registers /ponytail,
/ponytail-review, /ponytail-audit, /ponytail-debt, /ponytail-gain and
/ponytail-help, plus its skills as ponytail:<skill>.
Unlike the skills bootstrap, this one runs in the foreground: the gateway
reads its plugin set at startup, so a plugin installed in the background would
not take effect until the next restart. Already-installed plugins are skipped,
so after the first start it costs one hermes plugins list. A failed install
logs a warning and the gateway starts anyway.
HERMES_BOOTSTRAP_PLUGINS is a general mechanism — any owner/repo or git URL
that is a Hermes plugin works, comma-separated.
Coverage: the subagents (claude, pi, aider)
None of the three tools reaches the coding agents automatically. The profiles
shell out to claude and pi as separate processes with their own provider
config and their own rule systems, so each tool needs its own hook in them:
| gateway | pi | aider | claude | |
|---|---|---|---|---|
| Headroom | GATEWAY_BASE_URL |
same (inherits) | same (inherits) | own ANTHROPIC_BASE_URL |
| Caveman | agents mode only | HERMES_BOOTSTRAP_CAVEMAN |
HERMES_BOOTSTRAP_CAVEMAN + endpoint override |
HERMES_BOOTSTRAP_CAVEMAN + endpoint override |
| Ponytail | HERMES_BOOTSTRAP_PLUGINS |
HERMES_BOOTSTRAP_PONYTAIL |
no adapter upstream | HERMES_BOOTSTRAP_PONYTAIL |
That matrix describes the personal account. On the work account the claude
column changes: headroom is optional (headroom-work, its own instance),
caveman's proxy stays off (only its skill half installs — a vendor
limitation, not a choice; see Two Anthropic accounts),
and ponytail stays on unchanged.
Headroom. PI_BASE_URL falls back to GATEWAY_BASE_URL and
AIDER_OPENAI_API_BASE is set from LITELLM_BASE_URL (which itself falls back
to GATEWAY_BASE_URL), so pointing that one variable at the proxy covers the
gateway, pi and aider together. ANTHROPIC_BASE_URL is independent and needs
its own line — and in the Anthropic convention it carries no /v1, where
GATEWAY_BASE_URL/PI_BASE_URL do:
GATEWAY_BASE_URL=http://headroom:8787/v1 # gateway + pi + aider
ANTHROPIC_BASE_URL=http://headroom:8787 # claude, no /v1
Caveman. caveman enable <agent> is a persistent, user-scoped enable — it
writes hooks and routing into that agent's own config, all of which lives under
/opt/data, and the agent is caveman-enabled from then on when invoked by its
bare name. No wrapper command in front of it, no PATH shim, nothing for the
profiles to change. entrypoint.sh runs it for everything in
HERMES_BOOTSTRAP_CAVEMAN:
HERMES_BOOTSTRAP_CAVEMAN=claude,pi,aider
Behind a gateway, claude and aider need one more thing. Caveman sends each
vendor lane to that vendor's public API by default, so a Claude Code request
goes to api.anthropic.com and an aider request to api.openai.com — both of
which reject a LiteLLM key with 401 API key is invalid. Pi is unaffected: it
rides an explicit custom provider (--provider litellm + PI_BASE_URL).
Two settings redirect them, and entrypoint.sh handles both:
providers.<vendor>.base_urlin~/.caveman/caveman.yaml. Generated on start fromANTHROPIC_BASE_URLandLITELLM_BASE_URL. Each takes an origin — caveman appends the inbound request path, so a/v1suffix here produces/v1/v1/…and a 404. Acaveman.yamlwithout the generated-by marker is treated as hand-written and left alone.CAVE_SSRF_ALLOWLIST. Caveman refuses private and loopback upstreams outright, and a compose service likeheadroomis a private address. This one must be set in.env, not derived at runtime: the gateway is re-executed under s6 supervision and the proxy is started by an agent hook below that, so both inherit the container environment rather than the entrypoint's. The entrypoint warns, with the value to use, when it is empty.
CAVE_SSRF_ALLOWLIST=headroom
Keep it to exact hosts — caveman's own docs warn that allowing a whole private range lets a prompt-driven request reach unrelated services on your network.
Coverage also differs in depth: pi and claude get the full native integration (lifecycle hooks, tool rewrite, recovery MCP), aider only a shallow one — its config file plus a static ruleset, no live tool interception and no recovery.
Listing hermes enables it for the gateway itself, and dropping it from the
list disables it again — entrypoint.sh rewrites config.yaml both ways. Three
things make that work in single-container mode, where config.yaml is a
single-file bind mount and caveman writes config by rename(2) (EBUSY):
- The enable runs against a staging copy on the volume, and the result is
written back with a truncate-and-write, which a bind mount permits.
sed -iis not usable on that file for the same reason — it renames too, and fails silently. - The staging path is stable, not
mktemp. Caveman's ledger records the config file it owns, and a path that disappears between boots leaves the integrationdegraded, refusing both enable and disable. The entrypoint recovers that state by clearing the ledger entry and retrying once. - Caveman writes the lane as
…/w/hermes, assuming the agent appends/v1. This Hermes build does not, so the entrypoint appends it — otherwise every turn getscave_route_not_found.
The enable swaps model.provider from litellm to a bare custom one, which
carries no key_env, so the gateway's request reaches caveman with no
credential. OPENAI_API_KEY (set from LITELLM_API_KEY in compose) is what
caveman then falls back to for the upstream call; without it the turn reaches
LiteLLM unauthenticated and 401s.
Note what the gateway integration actually buys: caveman status reports
MCP recovery missing — streaming turns pass through uncompressed. Hermes
streams, so on this path caveman mostly observes rather than compresses.
Ponytail. The Hermes plugin hooks the Hermes loop only; claude and pi need
ponytail's own adapters, which entrypoint.sh installs from
HERMES_BOOTSTRAP_PONYTAIL:
HERMES_BOOTSTRAP_PONYTAIL=claude,pi
For claude that is a marketplace add plus a plugin install (with --yes, since
stdin is never a TTY at boot); for pi it is pi install git:…. aider has no
upstream adapter and naming it logs a warning rather than failing quietly.
Both bootstraps run in the background, unlike HERMES_BOOTSTRAP_PLUGINS —
nothing here is needed to serve a turn, only by the time the agent shells out —
and both are idempotent and declarative: they land in /opt/data, so the work
happens once per volume, and anything that got disabled by hand is restored on
the next start.
One port note: with caveman enabled, agents inside the container route through
caveman's proxy on 127.0.0.1:8787. Headroom is headroom:8787 — a
different host, so the two do not collide, and the container's 127.0.0.1:8787
is unrelated to the host's published 127.0.0.1:8787.
Telemetry
DO_NOT_TRACK=1 is set in .env.example and defaulted to 1 in compose, so
both tools that phone home are silent out of the box:
- Headroom — the anonymous compression beacon is on by default upstream
(ratios, counters, model IDs, OS/arch — never prompt or file content). Both
the
headroomandheadroom-workservices setHEADROOM_BEACON=offandDO_NOT_TRACK=1, plusHEADROOM_UPDATE_CHECK=off, which is not telemetry but is another unprompted outbound call.HEADROOM_TELEMETRYis a different setting — local-only stats that power/dashboard, nothing leaves the container. - Headroom's subscription poller — the one unprompted outbound call left on
by default, and only on
headroom-work: it polls Anthropic'sGET /api/oauth/usagewith theworkseat's own credential everyHEADROOM_SUBSCRIPTION_POLL_INTERVALseconds (default 300) so/statscan show how much of the subscription is left. It goes to Anthropic, not to Headroom's vendor, and it only runs once that lane has carried traffic. SetHEADROOM_NO_SUBSCRIPTION_TRACKING=1to stop it. - Caveman — the CLI reports which commands ran and tokens saved by default.
DO_NOT_TRACKreaches it through the container environment;caveman telemetry offwrites the same opt-out to its config. - Ponytail — has none.
Set DO_NOT_TRACK= (empty) in .env to opt back into upstream defaults.
Agent profiles
Profiles are specialist agents — each with its own model config, toolset, and personality — that the kanban dispatcher can route tasks to automatically.
Directory structure
Profiles live in profiles/ at the root of this repo. Each subdirectory is one
agent:
profiles/
general-agent/
distribution.yaml # manifest: name, version, description
config.yaml # model, toolsets, disabled skills
SOUL.md # agent personality and instructions
code-agent/
distribution.yaml
config.yaml
SOUL.md
research-agent/
distribution.yaml
config.yaml
SOUL.md
code-review-agent/
distribution.yaml
config.yaml
SOUL.md
The profiles/ directory is mounted read-only into the container at startup,
and installed differently by each mode:
- Single-container mode runs
hermes profile installfor every profile with adistribution.yaml, landing each at$HERMES_HOME/profiles/<name>/— a complete Hermes home of its own (config.yaml,state.db,sessions/,memories/). That's the set the kanban dispatcher routes to. - Agents mode has one agent per container, so the container's own profile
(
HERMES_PROFILE_NAME) is installed as its home's default profile: the files are copied straight into$HERMES_HOME(/opt/data/agents/<name>). See Why this works for why it isn't a named profile there.
Either way the repo copy is the source of truth — config.yaml is replaced on
every container start, so edit the profile under profiles/, never the
installed copy.
Two names are unavailable: default is reserved for the root home
(hermes profile install rejects a distribution named it outright), and the
name must match [a-z0-9][a-z0-9_-]{0,63}.
Adding a new profile
-
Create a subdirectory under
profiles/:mkdir profiles/my-agent -
Add the three required files:
distribution.yaml— the manifest. Thedescriptionis what the kanban decomposer reads to decide which tasks to route here, so write it like a job description:name: my-agent version: 0.1.0 description: "Specialist for X. Routed tasks involving Y and Z." author: jesseconfig.yaml— copy from an existing profile and adjust the model andplatform_toolsetsto match what the agent needs.SOUL.md— the agent's personality and working style in plain text. Keep it short and directive. -
Rebuild to install:
just rebuildOr install into a running container without restarting:
just run profile install /opt/profiles-src/my-agent --force --yes -
Only if you want it as its own always-on container (agents mode), also wire it into
compose/agents/agents.yaml:- add
my-agentto thex-a2a-peer-mapanchor with a free API port and A2A port (every container needs the whole map — that's how each one builds its peer list and its inbound token allow-list); - copy one of the
hermes-<name>services, settingHERMES_PROFILE_NAME,HERMES_PROFILE,HERMES_HOME: /opt/data/agents/my-agent,A2A_AGENT_NAME, and the two ports; - add
hermes-my-agenttoagent_containersin.justfile; - add the port/token lines to
.env.example.
The service name must be
hermes-<profile>— peers resolve each other athttp://hermes-<profile>:<a2a-port>on the compose network.Nothing needs to change for kanban routing; that reads
profiles/directly. - add
Profile commands
just run profile list # list all installed profiles
just run profile show <name> # show a profile's model, skills, and status
# install or update a profile in a running container, no rebuild:
just run profile install /opt/profiles-src/<name> --force --yes
/opt/profiles-src is where this repo's profiles/ directory is mounted inside
the container (compose.yaml:79), which is why the install form takes a path
rather than a bare name. There is no just profile wrapper: just run forwards
any hermes subcommand, so a per-subcommand recipe bought nothing but a second
name to keep in sync.
What a profile does and does not constrain
A profile is three things: a model config, a SOUL.md, and a
platform_toolsets list. The first two always apply. The toolset list only
applies to the platforms named in it — api_server is the one this repo
configures, which covers the HTTP API and A2A.
The CLI is not one of those platforms. hermes -p <name> chat gives you the
profile's model and personality but hands it the CLI's toolset (the
hermes tools config), not platform_toolsets.api_server. So a CLI run can
reach tools the deployed agent cannot:
# research-agent grants no `terminal`. Over HTTP it says so:
curl -s localhost:8643/v1/chat/completions -H "Authorization: Bearer $API_SERVER_KEY" \
-d '{"model":"hermes-agent","messages":[{"role":"user","content":"run uname -r yourself"}]}'
# -> "NO TERMINAL TOOL"
# The same profile on the CLI happily shells out.
just chat --profile research-agent -Q -q "run uname -r"
Two consequences worth knowing:
- A CLI profile run is not a faithful rehearsal of the deployed agent. To
approximate one, pass the toolset explicitly:
hermes -p research-agent chat -t web,browser,file,vision,skills,memory,session_search,delegation ….-tis enforced. webhas to be listed explicitly. It is not in hermes' defaultapi_serverset, so without itweb_searchandweb_extractsimply do not exist for HTTP/A2A callers — which is invisible on the CLI, where they are on by default. Every profile here lists it.
Web search actually returning results
The searxng profile needs two things beyond starting the container:
SEARXNG_URL=http://searxng:8080in.env(the gateway reads it at startup;just up searxngprints the line if it is missing).- An engine that will talk to you. Upstream's default general engines are
DuckDuckGo and Google, and from a homelab egress IP both commonly refuse —
DuckDuckGo with a CAPTCHA, Qwant with
Access denied (suspended_time=86400). Measured from this stack for the query "command runner": bing 10 results, duckduckgo 0, google 0, brave 0, startpage 0, mojeek 0. Socompose/tools/searxng.settings.ymlenablesbingin the default set, which upstream ships disabled.
If web_search returns nothing, check the engine errors first —
docker logs hermes-searxng-1 — before suspecting the wiring. A direct probe
separates the two:
docker exec hermes-local curl -s -G --data-urlencode 'q=test' --data 'format=json' \
http://searxng:8080/search | jq '.results | length'
Using profiles with kanban
Once profiles are installed, hand them tasks via hermes kanban:
# Assign a task to a specific profile
just run kanban create "Refactor the auth module" --assign code-agent
# Let the decomposer route automatically based on profile descriptions
just run kanban decompose <task-id>
# Fan out a goal across multiple specialist profiles
just run kanban swarm "Research and implement feature X" \
--worker research-agent:"Research existing solutions" \
--worker code-agent:"Write the implementation" \
--verifier code-review-agent \
--synthesizer general-agent
The gateway dispatcher runs in the background and claims tasks as profiles become available. Monitor progress with:
just run kanban watch
Running multiple agents + cross-talk
Two ways to get multiple specialist profiles collaborating. Pick one.
Path 1: Multi-container (just up agents) — what this gives you
Each profile gets its own container. They run side-by-side, each with its own
gateway, API server port, and A2A port. Agents call each other with
a2a_call(agent, message) — a real network call into the peer's live session,
with the peer's own memory and toolset.
| profile | container | API port | A2A port | HERMES_HOME |
|---|---|---|---|---|
| general-agent | hermes-general-agent |
8645 | 9900 | /opt/data/agents/general-agent |
| code-agent | hermes-code-agent |
8642 | 9901 | /opt/data/agents/code-agent |
| research-agent | hermes-research-agent |
8643 | 9902 | /opt/data/agents/research-agent |
| code-review-agent | hermes-code-review-agent |
8644 | 9903 | /opt/data/agents/code-review-agent |
They all mount the same hermes-home volume, but each one's state is
namespaced under its own HERMES_HOME, so sessions, state.db, memories, and
logs do not overlap. Peers exchange information by calling each other, not
by reading each other's files. The shared volume is just persistence.
There is no
defaultprofile here.defaultis a reserved profile id in hermes — it resolves to the rootHERMES_HOME(/opt/data), andhermes profile installrefuses a distribution named it outright. The generic worker isgeneral-agent.
Start it:
just up agents # agents only
just up agents mem0 litellm # agents + sidecars
Stop it (goes back to single-container mode next time you run just up):
just down agents
Useful recipes while it's up:
just agents-status # table: containers, ports, and each one's HERMES_HOME
just peers # each agent's Agent Card + the peers it was wired with
just chat --profile research-agent # CLI into a specific profile
just shell --container hermes-code-agent
just logs hermes-code-agent hermes-research-agent
just health # per-container probe: /v1/models AND one real turn
just health --quick # skip the turn (liveness only)
Path 2: Single-container Kanban (just up)
One container, one profile, profiles share the same gateway. The Kanban
dispatcher spawns profile workers on demand inside the container — they share
state, can't be addressed by a2a_call, and run sequentially (queued). Use this
when you want one container throwing multi-step tasks at a team of specialists.
When to use which
just up (Kanban) |
just up agents (A2A) |
|
|---|---|---|
| Container count | 1 | 4 |
| All profiles listening at once | no — workers spawn per task | yes — every profile is always live |
| Cross-agent call scope | in-process only (delegate_task) |
in-process + cross-container (a2a_call) |
| Nested dockerd (build/run containers) | yes | yes, but only on hermes-code-agent |
| State isolation between profiles | partial — own state.db/sessions/ per profile under /opt/data/profiles/<name>/, one shared kanban.db |
full — separate HERMES_HOME per agent |
| Best for | throw a complex task at a team and let it figure out routing | always-on specialists that you chat with individually AND have call each other mid-task |
Container count + DinD
In agents mode the nested dockerd socket can't be shared, so only
hermes-code-agent runs the in-container dockerd. The other three drop
code_execution from their toolset — when they need to spin up a container for
testing or a quick reproduction, they a2a_call code-agent. Set
HERMES_RUN_DIND=1 in another container's env to opt it in (and accept that you
lose DinD on code-agent until that container stops).
Auth between peers
A bearer token is not optional here. The A2A plugin binds to 127.0.0.1
unless a token is configured and A2A_HOST is widened — with no token,
A2A_HOST=0.0.0.0 is ignored and no peer can reach the container. Compose sets
A2A_HOST=0.0.0.0; the token comes from a2a-wire.py, which entrypoint.sh
runs at container start (agents mode only).
By default each agent's token is derived, not configured:
sha256("<API_SERVER_KEY>:a2a:<profile>"). Every container computes the same
value for a given peer, so the four agents authenticate to each other with no
secrets to distribute, and rotating API_SERVER_KEY rotates all four. Each
agent gets a distinct token.
The tokens are wired both ways:
- Outbound — each peer entry in
a2a_agents:carries this agent's own token, so the callee can tell who is calling. - Inbound — each container gets
A2A_PEER_TOKENS="<peer>:<token>,…"for the other three, so an incoming call is attributed to a named peer instead of a bare IP (which is what a single shared token would give you). That name is what shows up in rate limiting anda2a_audit.jsonl.
Set HERMES_<NAME>_A2A_TOKEN in .env to pin one explicitly; it wins over the
derived value. API_SERVER_KEY must be set for the derivation to work — with
neither, startup logs a warning and inbound A2A stays on localhost.
Because the gateway is not a child of the entrypoint (hermes registers an s6
service that re-execs hermes gateway run under with-contenv, so shell
exports are lost), the derived secrets are written to $HERMES_HOME/.env, which
load_hermes_dotenv() loads with override=True at startup. Only the region
between the BEGIN/END markers in that file is managed; anything else you put
there is preserved verbatim.
Why this works
HERMES_HOME is the only thing that partitions a Hermes install:
$HERMES_HOME/gateway.lock is the gateway singleton guard (gateway/status.py,
enforced in gateway/run.py), and $HERMES_HOME/config.yaml is the config the
gateway reads. Two gateways under distinct HERMES_HOMEs coexist; a second
gateway on the same one loses the flock and exits. Note that HERMES_PROFILE
does not partition anything — it only affects kanban authorship and secret
scoping. Setting HERMES_HOME is what matters.
Each agent gets a plain root (/opt/data/agents/<name>), not
<root>/profiles/<name>, and entrypoint.sh installs the profile as that
home's default profile. The distinction is load-bearing. For a profiles/<name>
path, hermes derives the gateway's s6 service slot from the directory name
(gateway-<name>), while the image's cont-init reconciler independently
registers a gateway-default slot for $HERMES_HOME itself. Both slots then
run hermes gateway run --replace against the same home and replace each other
in a loop, so the A2A port flaps up and down. A plain root has exactly one
identity — default — so there is one slot and one gateway.
Two consequences of the s6 model worth knowing when debugging:
- The gateway is not a child of
entrypoint.sh.hermes gateway runstarts the supervised slot and returns, so the container's CMD exits while the gateway keeps running (the same thinghermes-localhas always done). Its output goes to$HERMES_HOME/logs/gateways/default/current, notdocker logs. - Because of that handoff, environment set by
entrypoint.shnever reaches the gateway — the s6 run script re-execs underwith-contenv. Anything the entrypoint derives (the A2A tokens) is written to$HERMES_HOME/.env, whichload_hermes_dotenv()loads withoverride=Trueat startup.
entrypoint.sh also resolves the ${VAR} placeholders in the profile's
config.yaml when it installs it, rather than leaving them for the gateway to
expand per-turn — the gateway was observed resolving ${HERMES_MODEL} to the
literal string, which the provider rejects with "Invalid model name passed in
model=${HERMES_MODEL}". Only model names and the LiteLLM base URL are
interpolated; credentials use key_env: (a variable name resolved at turn
time), so no secret is written into the file.
The two paths are kept mutually exclusive at the justfile layer: just up
refuses to start while any agent container is running, and just up agents
excludes hermes-local from its up (via --scale hermes-local=0, since
hermes-local has no compose profile and would otherwise be recreated and
collide on port 8642).
Cross-agent delegation (A2A)
The A2A plugin is built into the base image. With just up agents the
multi-container wiring is automatic (see the previous section). The plugin is
also available on its own — add a2a to a profile's platform_toolsets and
point a2a_agents: entries at any A2A-compliant peer (another Hermes,
LangChain, CrewAI, etc.). From inside the container the agent then has:
a2a_discover(url)— fetch a peer's Agent Carda2a_call(agent, message, context_id?)— send a task, get the replya2a_orchestrate(capability, message, mode?)— fan out to every peer advertising a capabilitya2a_list()/a2a_history(context_id)— introspection
Inbound (this agent being callable) requires
gateway.platforms.a2a.enabled: true plus an A2A_BEARER_TOKEN (or
A2A_PEER_TOKENS) and a widened A2A_HOST — otherwise the plugin refuses to
bind beyond 127.0.0.1. The multi-container layout already configures this for
you; for ad-hoc peer calls (just outbound, never being called) none of that is
needed.
Talking to Hermes instances outside this compose project
a2a_agents: accepts any URL, not just the ones compose wires up. To call the
cloud Hermes (or any other A2A-compliant agent reachable over the network), add
a peer entry to a profile's config.yaml:
a2a_agents:
cloud-worker:
url: "https://cloud-hermes.example.com"
auth: { type: bearer, token: "..." }
timeout: 300
Reachability from this network (behind home NAT, etc.) is on you — typical options are Cloudflare Tunnel or a Headscale tailnet.
Security notes (built into the plugin)
- No bearer token ⇒ inbound binds
127.0.0.1only, refuses to widen. - Per-peer tokens (
A2A_PEER_TOKENS="alice:tok1,bob:tok2") give each caller its own credential for rate limiting, trust, and audit — preferred over one shared token. - Inbound text runs through prompt-injection filters and can't invoke operator slash commands.
- Every exchange is logged to
$HERMES_HOME/a2a_audit.jsonl; conversations persist to$HERMES_HOME/a2a_conversations/and survive restarts. In agents mode that's/opt/data/agents/<name>/, so each agent has its own audit trail (just agents-statusprints the paths).
See /opt/hermes/plugins/platforms/a2a/README.md and DESIGN.md inside the
image for the full protocol/architecture reference.
Testing
Static test suite under tests/ — pytest + pyyaml, no Docker
required, runs in well under a second:
just test # static tier (default)
just test-watch # re-run on file changes
just test-integration # include @pytest.mark.integration tests
The first run needs to populate the venv:
uv venv .venv --python 3.13
uv pip install --python .venv/bin/python pytest pyyaml
What's covered (133 tests today):
| File | Catches |
|---|---|
tests/test_compose_config.py |
broken profiles: gating, port collisions, missing headroom services, missing DinD owner, Dockerfile build-arg drift |
tests/test_config_yaml.py |
${VAR} refs the gateway can't pin, broken caveman marker regions, model_name renamed out from under Neovim clients |
tests/test_consistency.py |
README ↔ .env.example ↔ compose/*.yaml drift, undocumented profile additions, missing agent containers in the justfile |
tests/test_justfile.py |
recipes deleted, default's file-position/--unsorted quirks, agent_containers out of sync with compose |
tests/test_scripts_bash.py |
real execution (not regex) of scripts/*.sh: reject_flags/require_profile_env/container_for, chat/agent's validation, up's --scale hermes-local=0 gating, volumes' project-prefix resolution |
tests/test_setup.py |
real execution of scripts/00-setup/setup.py: .env editing, browser-toolset sync, credential generation, headroom routing, fzf-abort handling |
tests/test_health.py |
real execution of scripts/02-lifecycle/03-health.py: finish_reason classification (a failed turn still returns HTTP 200) |
tests/test_account.py |
real execution of scripts/03-occasional/03-account.py: personal/work flip, .env persistence, headroom-lane attribution |
tests/test_profiles.py |
missing web / a2a toolsets, distribution.yaml name mismatch, humanizer disabled (breaks hermes kanban swarm), wrong model provider |
tests/test_a2a_wire.py |
peer wiring, bearer-token derivation, .env round-trip, dotenv escaping, capabilities from peer profiles |
tests/test_entrypoint_shell.py |
upstream_dns, caveman_hermes_lane_fix (idempotent + narrow), account_state (persistence + reset + coercion) |
A test that's wrong is worse than no test. The suite is intentionally narrow: each test asserts one behavioural claim, with the comment explaining what would break if the assertion were dropped. New tests should follow that pattern.
Integration tests (those needing a live stack — gateway turns, A2A
peer reachability, headroom counter checks, mem0 round-trips) belong
under tests/integration/ with @pytest.mark.integration. just test
ignores that folder; just test-integration runs only it.
Config files
Hermes default config lives in config.yaml and is mounted into the container:
| File | Purpose |
|---|---|
config.yaml |
Default profile config — gateway settings, provider config, memory, disabled skills |
profiles/<name>/config.yaml |
Per-profile overrides, incl. platform_toolsets — see Agent profiles |
$HERMES_MANAGED_DIR/config.yaml |
Generated. Resolved ${VAR} values for the gateway — see below |
$HERMES_HOME/config.yaml |
Agents mode only. Written by a2a-wire.py with every ${VAR} already baked in |
.env |
Everything else — see Environment variables |
Note that config.yaml is bind-mounted read-write on purpose and is
rewritten in place by hermes itself (hermes config set, the onboarding.seen
flags) and by caveman enable hermes. It is a tracked git file that the
container mutates, so expect it to show up in git status.
Why there is a generated managed/config.yaml
${VAR} refs in config.yaml are expanded by load_config() — which every
fresh CLI process uses — but not on the path the running gateway takes.
_resolve_gateway_model() reads _load_gateway_config(), which reads
read_raw_config(): the raw YAML, no expansion. So a model written as
${HERMES_MODEL} reaches the provider verbatim and comes back as:
HTTP 400: Invalid model name passed in model=${HERMES_MODEL}
The tell is that hermes chat works perfectly while every HTTP turn fails, and
just health used to report 200 throughout (it only probed /v1/models,
which never touches a provider — and the failing turn itself returns HTTP 200
with the error in choices[0].message.content and finish_reason: "error").
entrypoint.sh::pin_gateway_env_refs fixes this by writing the resolved values
to $HERMES_MANAGED_DIR/config.yaml. That file is hermes' managed scope, and
_load_gateway_config() overlays it — expanding ${VAR} as it does
(hermes_cli/managed_scope.py::apply_managed_overlay). config.yaml itself is
left untouched, which matters because it is tracked in git and bind-mounted for
host edits; baking values into it would dirty the working tree on every boot.
| File | Purpose |
|---|---|
$HERMES_MANAGED_DIR/config.yaml |
Generated each start. Resolved values for keys that are ${VAR} in config.yaml |
Worth knowing:
- It is deliberately narrow. Only keys whose value in
config.yamlis currently an unexpanded${VAR}get pinned —model.default,model.base_url,auxiliary.<lane>.model,providers.<name>.base_url. Put a literal inconfig.yaml(or runhermes model <name>) and that key is left alone, because managed scope wins per-leaf and would otherwise override you. - Agents mode already had this.
a2a-wire.pybakes the values into each agent's ownconfig.yaml, which is why agents mode was never affected. There the overlay finds nothing to pin and removes itself. - It is regenerated on every container start, so
.envstays the single place you edit model names.just restartis enough to pick up a change.
To add other Hermes config files (e.g. mem0.json for advanced memory
settings), mount them individually in compose.yaml and add a corresponding
volume entry pointing to /opt/data/<filename>.