This is a local Apple Keychain secret source for Hermes Agent.
It lets Hermes resolve selected provider credentials from macOS Keychain
instead of storing them as plaintext in ~/.hermes/.env or config.yaml.
The default convention is:
service = hermes
account = ENV_VAR_NAME
For example, OPENROUTER_API_KEY is stored as a generic password with service
hermes and account OPENROUTER_API_KEY.
This is useful for public API providers such as OpenRouter, Z.AI / GLM, or any
local OpenAI-compatible inference server that expects bearer-token
authentication. In my setup, SPARK_885A_API_KEY is the API key for my local
SGLang server, spark-885a.
| File | What it is |
|---|---|
plugin.yaml |
Plugin manifest |
__init__.py |
Plugin source (AppleKeychainSource). Also copied into Hermes as a built-in. |
config.yaml |
Example ~/.hermes/config.yaml secrets block |
config-advanced.yaml |
Variant with per-secret service/account/keychain mappings |
provision-secrets.sh |
Helper to load secret values into Keychain (login or System) |
promote-to-builtin.sh |
Promote the plugin to a Hermes built-in source |
Drop the plugin files here:
~/.hermes/plugins/apple-keychain/
plugin.yaml
__init__.py
Provision the secret values into Keychain (see provision-secrets.sh), then
merge this into ~/.hermes/config.yaml:
secrets:
sources:
- apple_keychain
apple_keychain:
enabled: true
service: hermes
override_existing: true
timeout_seconds: 10
accounts:
- OPENROUTER_API_KEY
- GLM_API_KEY
- LOCAL_INFERENCE_API_KEY
- DISCORD_BOT_TOKENIn my own config, LOCAL_INFERENCE_API_KEY is named SPARK_885A_API_KEY
because it authenticates requests to my local SGLang inference server.
service = hermes
account = ENV_VAR_NAME
security add-generic-password -U \
-s hermes \
-a OPENROUTER_API_KEY \
-w "$OPENROUTER_API_KEY"
# read it back
security find-generic-password -w \
-s hermes \
-a OPENROUTER_API_KEY >/dev/nullThe macOS security CLI supports add-generic-password and
find-generic-password, which is the small surface this plugin uses.
(ss64.com)
The important part. The plugin stays where it is, but Hermes also needs to
load the same source as an early built-in, alongside Bitwarden and
1Password. The plugin alone loads too late to feed keys to provider models on
first use -- the promotion is what makes this behave like Hermes Vault during
first-process provider discovery. promote-to-builtin.sh does all three
steps:
- Locates the Hermes Python interpreter and the
agentpackage root. - Copies
~/.hermes/plugins/apple-keychain/__init__.pyintoagent/secret_sources/apple_keychain.py. - Patches
agent/secret_sources/registry.pyto registerAppleKeychainSource(), immediately after the existing 1Password registration block. It backs up the registry first and short-circuits if the patch is already present (idempotent).
bash promote-to-builtin.shNote: the "promote to built-in" step is a local workaround for early startup hydration. It patches your installed Hermes copy, so it may need to be re-applied after
hermes update. The standalone plugin remains the portable/official packaging shape; the promotion is what makes this behave like Hermes Vault during first-process provider discovery.
Fully restart Hermes -- not just the current chat/session:
hermes modelYou should see OpenRouter and Spark again.
If a subagent still 401s, check only whether the env var is present in the Hermes process (not the value):
HERMES_PY="$(head -n 1 "$(command -v hermes)" | sed 's/^#!//' | awk '{print $1}')"
"$HERMES_PY" - <<'PY'
import os
for k in ["OPENROUTER_API_KEY", "GLM_API_KEY", "LOCAL_INFERENCE_API_KEY", "DISCORD_BOT_TOKEN"]:
v = os.environ.get(k, "")
print(k, "len=", len(v))
PYKeep the keys in Keychain and reference them by env var in delegation -- do
not put the raw values back in .env unless you need an emergency
rollback:
delegation:
api_key: ${LOCAL_INFERENCE_API_KEY}A secret's keychain identity has three axes:
- service -- the generic-password service name (default
hermes) - account -- the keychain account name (defaults to the env var name)
- keychain -- the keychain file to read from (default: the macOS search list, typically the login keychain)
accounts: is the shorthand for the common case where every secret uses the
defaults. items: overrides any of the three per secret, and
default_keychain: sets a source-level fallback for items that don't specify
their own. See config-advanced.yaml:
secrets:
sources: [apple_keychain]
apple_keychain:
enabled: true
service: hermes
default_keychain: "" # empty = default search list
accounts:
- NVIDIA_API_KEY # uses all defaults
items:
- env: OPENROUTER_API_KEY
account: openrouter-main # account differs from env var
- env: LOCAL_INFERENCE_API_KEY
service: dgx-spark
account: spark-885a
- env: DISCORD_BOT_TOKEN
keychain: /Library/Keychains/System.keychain # read from a specific fileThat resolves as:
NVIDIA_API_KEY from keychain=login, service=hermes, account=NVIDIA_API_KEY
OPENROUTER_API_KEY from keychain=login, service=hermes, account=openrouter-main
LOCAL_INFERENCE_API_KEY from keychain=login, service=dgx-spark, account=spark-885a
DISCORD_BOT_TOKEN from keychain=System, service=hermes, account=DISCORD_BOT_TOKEN
(For a source-level default instead of per-item, set default_keychain: to a
path and drop the per-item keychain: lines.)
The keychain field exists because macOS ties keychain access to the caller's
security session:
- The login keychain (
~/Library/Keychains/login.keychain-db) is only readable inside your GUI login session. An interactivehermesCLI run from your terminal reads it fine. - The System keychain (
/Library/Keychains/System.keychain) is readable from any session, including a launchd daemon at boot before anyone logs in, or a cron job with no attached user.
A macOS launchd LaunchDaemon runs in the system domain, outside any GUI
login session, so it cannot read the login keychain -- not because it is
locked, but because of this session boundary. (Calling security unlock-keychain from the daemon reports success but reads still fail; the
unlock only takes effect within the GUI session.)
To serve a headless daemon, provision a copy of the secrets into the System
keychain and point the daemon's config at it. provision-secrets.sh takes a
KEYCHAIN env var for exactly this:
# 1. Provision a copy into the System keychain (writes there require root;
# the script re-execs under sudo for you):
export OPENROUTER_API_KEY=... GLM_API_KEY=... # etc.
KEYCHAIN=/Library/Keychains/System.keychain bash provision-secrets.sh
# 2. Give the daemon a config that reads from the System keychain:
# (config snippet)
# apple_keychain:
# default_keychain: /Library/Keychains/System.keychain
# accounts: [OPENROUTER_API_KEY, GLM_API_KEY, ...]Keep the login-keychain copy for interactive CLI use; the daemon reads the
System copy. The two deployments differ only on the default_keychain /
keychain field.
Rotation note: because there are now two copies, rotating a credential means refreshing both. Re-run
provision-secrets.shonce withoutKEYCHAIN(login) and once withKEYCHAIN=/Library/Keychains/System.keychain(System) after you rotate. A secret that exists in only one location will silently resolve to nothing in the deployment that points at the other.
Hermes' secret-source contract says the source should fetch values only,
return {ENV_VAR: value}, never write os.environ, never prompt, and let
Hermes handle ordering, precedence, provenance, and protected vars. bulk is
the right shape when the source injects a set of secrets implicitly rather
than binding each env var to a separate ref.
(Hermes Agent docs)
Design goals:
- Startup-only secret source.
- No model-callable secret reader.
- No writes to
os.environ. - No prompts.
- No
shell=True. - Same-name account convention by default.
itemsfor any per-secret keychain-identity variation (service, account, or keychain file) -- not just "weird account names."
Security note: this removes provider keys from plaintext
.env/config.yaml, but Hermes still materializes the resolved values into the process environment at startup. The source itself does not writeos.environ; the Hermes secret-source framework does. Keepaccounts:small and explicit.System keychain caveat: secrets in
/Library/Keychains/System.keychainare readable by any local process (root or any user), not just your account. That is the trade-off for boot-time access without a GUI login -- it is the System keychain's designed purpose, but it is broader exposure than the login keychain, which is encrypted at rest with your login password. Use the System keychain only for secrets a headless daemon genuinely needs.
This only hydrates secrets early. It does not define model providers.
If you use a custom Hermes model-provider plugin for a local OpenAI-compatible
server, keep provider-specific settings under that provider's own config
section, not under Hermes' mutable model: selection block.
For example, a local SGLang provider might keep base_url, key_env,
context_length, and extra_body under sglang:. Hermes may rewrite the
top-level model: section when changing the selected default model, so
provider definitions should not depend on model.key_env or model.base_url.
In my setup, spark-885a is just the hostname of my local SGLang inference
server.
Hermes' docs say plugin discovery happens after the first .env load in the
discovering process, so plugin secret sources may only affect subsequent
spawned Hermes processes (gateway children, cron sessions, subagents). This is
exactly why the built-in promotion in step 2 exists. A full restart is the
cleanest test.