Hermes Agent Deep Cuts: `hermes config set` Is a Router, Not a YAML Writer
Part of the Hermes Agent: Deep Cuts series

Hermes Agent Deep Cuts: `hermes config set` Is a Router, Not a YAML Writer

hermes config set terminal.timeout 30 wrote two files. I watched it happen in a throwaway HERMES_HOME:

✓ Set terminal.timeout = 30 in /tmp/hermes-config-demo/config.yaml

One command, one confirmation line, and yet the .env file also grew a new line:

TERMINAL_TIMEOUT=30

The config.yaml got terminal.timeout: 30. The .env got TERMINAL_TIMEOUT=30. The command said nothing about the second write. That is the whole story of this feature in miniature: hermes config set is not “append a line to YAML.” It is a router with three destinations, a coercion engine that rewrites your string before it hits disk, and a mirroring layer whose entire job is to hide the fact that Hermes has two config stores and a third one you can only see at runtime.

The router

The routing decision is one function, _is_env_config_key. It only fires on single-segment keys. If the key has a dot in it, it goes to config.yaml no matter what. If it has no dot, the key is checked against a hardcoded list plus three suffixes:

api_keys = [
    'OPENROUTER_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', ...
    'TELEGRAM_BOT_TOKEN', 'DISCORD_BOT_TOKEN', 'SLACK_BOT_TOKEN', ...
    'GITHUB_TOKEN', 'SUDO_PASSWORD', ...
]
return (
    key_upper in api_keys
    or key_upper.endswith(('_API_KEY', '_TOKEN', '_SECRET'))
    or key_upper.startswith('TERMINAL_SSH')
)

So OPENROUTER_API_KEY goes to .env. OPENROUTER_API_URL (no matching suffix, not in the list) goes to config.yaml. model.api_key goes to config.yaml too, because the dot disqualifies it before the suffix check ever runs. That last one matters: the lowercase, dotted model.api_key is a config.yaml resident, which is why a separate secret-masking pass has to catch it on echo (more on that at the end).

I ran the demo and the split is exact:

$ hermes config set OPENROUTER_API_KEY sk-or-demo123
✓ Set OPENROUTER_API_KEY in /tmp/hermes-config-demo/.env

$ hermes config set terminal.timeout 30
✓ Set terminal.timeout = 30 in /tmp/hermes-config-demo/config.yaml

The first prints no value at all. The second prints the value. One goes to .env, one to config.yaml, and the only signal you get about the destination is the file path in the confirmation line.

The coercion engine

Before the routed value is written, it passes through a coercion block that decides what type it actually is. This is where hermes config set approvals.mode off gets interesting:

$ hermes config set approvals.mode off
✓ Set approvals.mode = off in /tmp/hermes-config-demo/config.yaml

And on disk:

approvals:
  mode: 'off'

That is a quoted string, not YAML’s false. The engine would happily coerce a bare off into a boolean for most keys, but it checks the declared type of the key first. approvals.mode is string-typed, and the code comment says exactly why: an enum value of "off" must not become a YAML boolean, because then the reader’s == "off" check never matches and the feature silently fails. The coercion engine is thinking about the reader, not the writer.

It also parses structured literals. This:

$ hermes config set platform_toolsets.line '["file","web"]'
✓ Set platform_toolsets.line = ['file', 'web'] in /tmp/hermes-config-demo/config.yaml

lands as a real YAML list, not the string '["file","web"]'. The alternative, per the source comment, is a value that “looked saved but never took effect” because every isinstance(..., list) gate downstream ignored a string and fell back to its default. That is the failure mode this coercion exists to prevent: a setting that round-trips through get perfectly and does absolutely nothing at runtime.

The full coercion order, from set_config_value: true/yes/on → True, false/no/off → False, null/none/~ → None, then integer, then float, then a conservative YAML literal parse for things that look like lists or maps. A genuinely string-typed key whose value merely starts with [ or { is left alone. The order matters: off on a boolean key and off on a string key produce different things, and the only way to know which you got is to read the file.

The mirror, and why config get lies to you

Back to terminal.timeout. This is the part that will bite you in production.

When you set a terminal.* key, set_config_value does two writes. First it writes terminal.timeout: 30 to config.yaml. Then it consults a static table, TERMINAL_CONFIG_ENV_MAP, finds that timeout maps to TERMINAL_TIMEOUT, and writes TERMINAL_TIMEOUT=30 into .env. The source comment is blunt about the reason: “config.yaml is authoritative, but terminal_tool only reads TERMINAL_ENV etc.”

That comment is the load-bearing fact. The terminal execution backend does not load config.yaml for its timeout. It calls os.getenv("TERMINAL_TIMEOUT"). I checked two independent call sites: tools/process_registry.py (os.getenv("TERMINAL_TIMEOUT", "180")) and cli.py (os.getenv("TERMINAL_TIMEOUT", "60")). Neither reads the YAML. So the flow is: config set writes the YAML for the benefit of readers that go through load_config(), then mirrors the same value into the environment for the benefit of the terminal backend, which reads the environment.

The illusion breaks when the two get out of sync. I forced it:

$ hermes config set terminal.timeout 777
✓ Set terminal.timeout = 777 in /tmp/hermes-config-demo2/config.yaml

$ printf 'TERMINAL_TIMEOUT=1\n' >> /tmp/hermes-config-demo2/.env

$ hermes config get terminal.timeout
777

config get says 777. But config get is one of the readers that goes through load_config(), so it reads config.yaml and reports 777. The terminal backend is a different reader, one that reads the environment, and the environment now contains TERMINAL_TIMEOUT=777 followed by TERMINAL_TIMEOUT=1. load_env() parses that file with plain key = value assignment, so on a duplicate key the last one wins. The environment’s answer is 1, not 777.

This is the core gotcha, and it generalizes. The documented precedence order is CLI args, then config.yaml, then .env, then built-in defaults. That order is correct for every reader that resolves through load_config(). The terminal backend is not one of those readers. It reads process environment variables that were exported once at startup, so a live gateway session keeps running commands under whatever timeout was current when it booted, regardless of what you just config set. And if you hand-edited .env to add a conflicting TERMINAL_TIMEOUT, the terminal backend sees that, not your YAML.

The same mirror exists for the whole terminal.* tree. backend, cwd, docker_image, docker_forward_env, ssh_host, ssh_key, and the rest all have TERMINAL_* env-var twins, all for the same reason: the terminal tool reads env, not YAML. A gateway/run.py startup block exports the config.yaml terminal.* values into the process environment, which is what keeps them in sync on a fresh boot. But “in sync on a fresh boot” is not the same as “in sync after you edit.”

Environment substitution runs at read time

There is a second mechanism that makes “what’s on disk” differ from “what the program sees,” and it is the opposite direction from the coercion engine. _expand_env_vars walks the loaded config and expands ${VAR} and ${env:VAR} references recursively. Only string values are touched. I set a value that referenced an env var and watched the disk stay raw while the read expanded:

$ export DEMO_HOST=10.9.8.7
$ hermes config set model.base_url 'http://${DEMO_HOST}:8080/v1'
✓ Set model.base_url = http://${DEMO_HOST}:8080/v1 in /tmp/hermes-config-demo2/config.yaml

On disk:

base_url: http://${DEMO_HOST}:8080/v1

On read:

$ hermes config get model.base_url
http://10.9.8.7:8080/v1

Two details matter here. First, an unresolved reference is not an error. hermes config set model.base_url 'http://${NO_SUCH_VAR}:8080/v1' then config get returns the literal string http://${NO_SUCH_VAR}:8080/v1, verbatim, with a warning logged. Bare $VAR is not expanded at all. Second, _preserve_env_ref_templates exists to keep the raw template on disk when some unrelated setting gets modified and the config is re-saved, so an expanded secret never gets written back to config.yaml as plaintext. There is also a _env_ref_snapshot that gets stored alongside the cached config load, so a cache hit can detect that the expansion was computed against a different environment than the one currently live.

This is the mechanism that lets a Cursor or Claude MCP snippet with ${env:GOOGLE_API_KEY} drop into config.yaml unchanged. The env: prefix is stripped, so ${env:VAR} resolves exactly like ${VAR}. ${file:...}, ${vault:...}, and ${bitwarden:...} are not resolved inline; external secret backends are supposed to inject their values into the environment at startup via a secrets: block, and you reference them as ${env:NAME}. Unknown prefixes warn once and stay verbatim.

The guards, because set is a footgun with a friendly face

The function is full of small behaviors that exist because someone already made the mistake. Four are worth knowing.

Bare model redirects. hermes config set model gpt-5.6-sol does not blindly overwrite the model: section. If model is already a mapping with siblings like provider and context_length, the write is redirected to model.default and the siblings survive. Other mapping sections refuse a scalar write entirely unless you pass --force. On a fresh config with no existing model dict, a bare model write stores a scalar model: gpt-5.6-sol, which is legal shorthand. The behavior is conditional on what is already there, which is exactly the kind of thing that makes a scripted config set behave differently on a clean box than on an existing one.

Managed scope is a hard wall. If an administrator has pinned a key through a managed directory, set_config_value refuses before it does anything, names the source file, and tells you to contact your administrator. There is no --force escape hatch for managed keys. The same guard exists on unset.

Unknown keys warn but still write. hermes config set platform_toolsets.line '["file","web"]' printed:

⚠ 'platform_toolsets.line' is not a recognized config key — it was saved anyway,
  but Hermes may not read it.
  Did you mean: platform_hints.line

The write succeeds, because custom top-level keys are supported and bridged into the environment for skills and external apps. But the “did you mean” hint is doing real work: a plausible-but-wrong dotted path used to report bare success and leave the user debugging behavior that never changed.

Credential-shaped echoes get masked. hermes config set model.api_key 'sk-live-demosecret' printed sk-l...cret, not the value. A frozenset of exact-match leaf keys (api_key, token, secret, password, private_key, bearer, and friends) triggers masking on the confirmation echo. Exact match only, so token_count and secret_santa are not masked. The masking is on the echo path; the actual value is written to disk unredacted, which is why the value length and prefix are still there when you read the file directly.

One thing I will flag rather than oversell: hermes config get model prints the nested model block through a formatting function that has no redaction in it, so whether the api_key inside shows depends entirely on the surrounding terminal redaction layer, not on the config command itself. I have seen that path print a live key in full on a real profile. Do not treat config get on a section as redacting anything. If you want a masked view, use bare hermes config, which runs the dict through redact_config_value before it displays.

How to verify it is actually doing what you think

The cheap test is to make config set prove its own routing:

$ hermes config set FOO_API_KEY testvalue && grep -c FOO_API_KEY ~/.hermes/.env
1
$ hermes config set foo.somekey testvalue && grep -c 'somekey' ~/.hermes/config.yaml
1

The _API_KEY suffix lands in .env; the dotted key lands in config.yaml. That single contrast confirms the router is routing.

Then confirm the mirror, because it is the silent one. Set a terminal.* key and check both files:

$ hermes config set terminal.timeout 123
$ grep -A1 'terminal:' ~/.hermes/config.yaml   # timeout: 123
$ grep TERMINAL_TIMEOUT ~/.hermes/.env          # TERMINAL_TIMEOUT=123

If the second grep comes back empty while the first shows the value, the mirror didn’t fire, which means either you hand-edited .env and the mirror overwrote only on the set path, or you’re on a version where that key isn’t in TERMINAL_CONFIG_ENV_MAP. Either way you now know the terminal backend is reading a stale or absent env var.

For the substitution path, the test is the disk-versus-read divergence itself:

$ export PROBE=1.2.3.4
$ hermes config set model.base_url 'http://${PROBE}:9'
$ grep base_url ~/.hermes/config.yaml   # raw ${PROBE}
$ hermes config get model.base_url      # http://1.2.3.4:9

If disk shows the expanded value instead of the template, _preserve_env_ref_templates did not hold, and you have just persisted a plaintext expansion into config.yaml. If config get shows ${PROBE} literal, the variable was not in os.environ at load time and the placeholder is being kept verbatim by design.

And the one that actually matters for ops: if you changed a terminal.* setting and the change “isn’t taking effect,” stop reading hermes config get. It is lying to you by construction. Check what the terminal backend actually sees:

$ grep -c TERMINAL_TIMEOUT ~/.hermes/.env   # duplicate key? last wins
$ ps eww -p <gateway_pid> | tr ' ' '\n' | grep '^TERMINAL_'   # live process env

The process environment is the third config store, the one no config get will ever show you, and it is the one the terminal backend obeys.

Facts, inference, and open questions

Observed (installed v0.20.5 source plus live runs in a throwaway HERMES_HOME on 2026-08-23): _is_env_config_key routes single-segment keys matching an explicit list, the suffixes _API_KEY/_TOKEN/_SECRET, or the prefix TERMINAL_SSH to .env and everything else to config.yaml; set_config_value coerces through bool/int/float/null then a conservative YAML list/map parse, preserving string-typed enum values like approvals.mode as quoted strings; terminal.* keys are mirrored to TERMINAL_* env vars in .env via TERMINAL_CONFIG_ENV_MAP; the terminal execution path reads os.getenv("TERMINAL_TIMEOUT") at tools/process_registry.py and cli.py, not config.yaml; _expand_env_vars expands ${VAR}/${env:VAR} at load time and leaves unresolved refs verbatim; _preserve_env_ref_templates keeps raw templates on disk; load_env parses .env with export -prefix stripping and last-wins duplicate handling, memoized on mtime and size; bare model set redirects to model.default when model is already a mapping; managed-scope keys are hard-rejected on set and unset; unknown keys warn with a “did you mean” hint but still write; credential-shaped leaf keys are masked on the set echo via _SECRET_CONFIG_KEYS.

Inference: the terminal env mirror exists because the terminal backend was written to read process environment variables, and mirroring on every set is cheaper than teaching the backend to read load_config(). That is a design debt, not a feature, and it is the reason the documented precedence order is incomplete: it describes the load_config() readers, not the env readers. The coercion engine is the second-biggest silent-failure source, because a setting that parses to the wrong type stores successfully and reports successfully while the runtime reader ignores it.

Open questions: I did not trace every TERMINAL_* key to confirm each one has a live os.getenv read in the terminal backend; I confirmed the timeout key specifically and inferred the rest from the shared TERMINAL_CONFIG_ENV_MAP and the maintainer comment. I did not test whether a live gateway session actually picks up a config set change without restart; the source structure (startup export + env reads) strongly implies it does not, but that specific behavior was not exercised against a running gateway in this run. Whether config get on a nested section was ever intended to redact is unclear from the source alone, since the redaction lives in a separate redact_config_value helper that the get path does not call.

The uncomfortable part is that the most confidence-inspiring command in the whole config surface, hermes config get, is a faithful reporter of exactly one reader. It reports the config.yaml view with env substitution applied, and it will tell you 777 all day while your commands run under a 1-second timeout that lives in a file you forgot you had a second copy of. Two config stores, three readers, one confirmation line. The rest is just keeping them from disagreeing.

Sources

Keep reading