Final review (opus) found a Critical bug (jupiter's nixosConfigurations stopped evaluating because hermes-agent.nixosModules.default was wired through machines/configuration.nix's mibook-only module list, while the profile module itself is loaded on both machines) plus three Important gaps: secrets persist to /var/lib/hermes/.hermes/.env, not /run/secrets; Telegram/Email need explicit ALLOWED_USERS or they're wide open / silently drop messages; addToSystemPackages's CLI needs group membership finn didn't have. All four corrected in the docs here; the actual code fix is a separate dispatched task. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vbvwg3Qt2u9nbkEA3wL17S
11 KiB
Hermes Agent on mibook
Date: 2026-09-08
Machine: mibook (laptop, KDE desktop, current daily driver)
Status: Design approved, pending implementation plan
Base branch: feat/sops-nix (pushed to origin, not yet merged to main)
Goal
Deploy NousResearch/hermes-agent
as a NixOS-native systemd service on mibook, reachable via Telegram and Email,
using an Anthropic Claude subscription (claude setup-token) rather than a
pay-per-token API key. Design + implementation plan only — no execution in
this round; a later session applies the plan.
Decisions
| Topic | Decision |
|---|---|
| Host | mibook, not jupiter. Availability gap (laptop sleeps/lid-closes) is accepted, not engineered around |
| Use case | Messaging gateway (Telegram + Email), not CLI-only |
| Deployment mode | NixOS native module (services.hermes-agent.* from upstream nixosModules.default), wrapped in my.profiles.hermes-agent. Home Manager was considered and rejected — not wired into this flake anywhere; adding it for one service is a new framework dependency, not proportionate |
| Module source | Official github:NousResearch/hermes-agent, not the community 0xrsydn/nix-hermes-agent wrapper |
| Nix support tier | Docs explicitly call Nix/NixOS "Tier 2, best-effort" — Docker/FHS is upstream's primary supported path. Accepted tradeoff, not a blocker |
| LLM provider auth | Anthropic via claude setup-token (long-lived, headless-friendly token) → ANTHROPIC_TOKEN, not ANTHROPIC_API_KEY |
| Billing caveat | Setup-token/OAuth path only draws from a Claude Max "extra usage credits" pool, never the base Max allowance. Claude Pro cannot use this path at all. User intends to use claude setup-token; not independently re-verified that extra credits are purchased — confirm in Anthropic console before/during implementation |
| Secrets | One sops secret, hermes-env, containing a full env-file blob (matches the module's environmentFiles list-of-paths interface — not a single-value secret) |
| sops prerequisite | None. mibook is already a registered sops recipient (host_mibook in .sops.yaml, secrets/mibook.yaml exists, proven via a live nixos-rebuild switch in the sops-nix work). No "Phase 9"-style bootstrapping needed — that deferred work is about jupiter, irrelevant now that mibook is the target |
| Network exposure | None. Telegram (long-polling) and Email (IMAP/SMTP) are both outbound-initiated from mibook. No Caddy/webserver module changes, no public endpoint |
| Messaging platforms | Telegram + Email only, first pass. Hermes Agent supports 27+ platforms total (Discord, Slack, WhatsApp, Matrix, etc.) — others deliberately out of scope, same pattern applies later if wanted |
Deliberately deferred (YAGNI)
- Any workaround for mibook's availability gap (cloud relay, wake scheduling, HA failover to jupiter)
- Discord, Slack, or any other messaging platform beyond Telegram + Email
- Public/internet exposure of any kind
- Merging or rebasing
feat/sops-nix— this design builds on top of it as-is
Part 1 — The module
New flake input in flake.nix, following the exact shape sops-nix already
uses:
hermes-agent = {
url = "github:NousResearch/hermes-agent";
inputs.nixpkgs.follows = "nixpkgs";
};
Import the upstream module from inside the profile module itself, via
imports = [ inputs.hermes-agent.nixosModules.default ]; — using the
inputs module arg already provided repo-wide by
_module.args.inputs = self.inputs; in machines/configuration.nix.
Correction (found during implementation, 2026-09-08): this is
deliberately not wired through machines/configuration.nix's per-machine
module lists. NixOS's module system errors on a definition for an
undeclared option regardless of lib.mkIf's condition — so declaring
services.hermes-agent only on mibook while jupiter still loads the
profile module (because modules/environments/default.nix is
machine-agnostic) breaks jupiter's evaluation outright, even with the
profile disabled there. Importing the upstream module unconditionally, and
gating only config behind lib.mkIf cfg.enable, is what actually
respects "mibook only, not jupiter": the option exists everywhere
(harmless), but only ever turns on where enable = true.
New file modules/environments/hermes-agent/default.nix following the
repo's standard profile pattern:
# Hermes Agent — Telegram + Email messaging gateway (Claude subscription auth)
{
config,
lib,
pkgs,
inputs,
...
}:
let
cfg = config.my.profiles.hermes-agent;
in
{
imports = [ inputs.hermes-agent.nixosModules.default ];
options.my.profiles.hermes-agent.enable =
lib.mkEnableOption "Hermes Agent (Telegram + Email gateway)";
config = lib.mkIf cfg.enable {
services.hermes-agent = {
enable = true;
settings.model.default = "anthropic/claude-sonnet-4-5"; # deliberate pin, see Open risks
environmentFiles = [ config.sops.secrets."hermes-env".path ];
addToSystemPackages = true;
};
# addToSystemPackages only puts `hermes` on $PATH; using it needs group
# access to /var/lib/hermes/.hermes (mode 2770 hermes:hermes). finn is
# this machine's one human user and already knows hermes-env's contents
# (typed them into sops directly), so this is not new exposure.
users.users.finn.extraGroups = [ "hermes" ];
};
}
Add ./environments/hermes-agent to modules/environments/default.nix;
enable my.profiles.hermes-agent.enable = true in
machines/mibook/environments.nix. machines/configuration.nix is not
touched by this design.
Provided for free by the upstream module: hermes system user/group
(createUser default true), hardened systemd unit (NoNewPrivileges,
ProtectSystem=strict, PrivateTmp — not independently user-configurable
per the docs), hermes CLI on $PATH via addToSystemPackages (usable by
finn once group membership is granted, above).
Part 2 — Secret
Add to machines/mibook/secrets.nix (existing file, per docs/secrets.md):
sops.secrets.hermes-env = {
owner = "hermes";
group = "hermes";
mode = "0400";
restartUnits = [ "hermes-agent.service" ]; # confirm exact unit name at implementation
};
Populate via nix develop -c sops secrets/mibook.yaml, adding a
multi-line hermes-env block:
ANTHROPIC_TOKEN=<output of `claude setup-token`>
TELEGRAM_BOT_TOKEN=<from @BotFather>
TELEGRAM_ALLOWED_USERS=<your numeric Telegram user id>
EMAIL_ADDRESS=<mailbox address>
EMAIL_PASSWORD=<password or app-password>
EMAIL_IMAP_HOST=<imap host>
EMAIL_SMTP_HOST=<smtp host>
EMAIL_ALLOWED_USERS=<your own sending address>
(EMAIL_IMAP_PORT/EMAIL_SMTP_PORT default to 993/587 — only set if the
provider differs. See website/docs/reference/environment-variables.md for
the full key list.)
Both *_ALLOWED_USERS keys are access control, not optional: Telegram
defaults to open DM pairing (any stranger who messages the bot gets a
pairing code and can request approval), and email with no
EMAIL_ALLOWED_USERS set silently ignores messages from unrecognized
senders — no log line, no error. For an agent with terminal access on the
daily driver, treat both as required.
Resolved (confirmed against the locked hermes-agent revision's source —
agent/anthropic_credentials.py, hermes_cli/providers.py): the module
reads ANTHROPIC_TOKEN for the setup-token path. No longer an open
question; see Open risks below for what replaced it.
Where this actually lands at runtime: the upstream module does not use
systemd EnvironmentFile=. An activation script concatenates every
environmentFiles entry into /var/lib/hermes/.hermes/.env, mode
0640, owned hermes:hermes — plaintext on mibook's plain ext4 root (no
LUKS, per machines/mibook/disks.nix), not sops-nix's /run/secrets
tmpfs. This is upstream behavior, not something this design controls; see
Rollback below for why it matters.
Must git add secrets/mibook.yaml before nix build — untracked
files are invisible to flake evaluation (existing repo rule, docs/secrets.md).
Part 3 — Rollout runbook (on mibook)
- Confirm Claude Max extra-usage-credits are actually purchased (Anthropic console) — the whole auth design depends on this pool being funded.
- Run
claude setup-tokenon mibook; capture the token. - Create/gather Telegram bot token (
@BotFather) and email credentials. - Add the
hermes-agentflake input; add the module wrapper file; wire it intomodules/environments/default.nixandmachines/mibook/environments.nix. - Add
sops.secrets.hermes-envtomachines/mibook/secrets.nix. nix develop -c sops secrets/mibook.yaml, populatehermes-env,git add.nix build '.#nixosConfigurations.mibook.config.system.build.toplevel'— CI-style check before touching the running system.sudo nixos-rebuild test --flake '.#mibook'— verify before persisting across reboot.journalctl -u hermes-agent.service -f— confirm it starts, decrypts the secret, connects to Telegram (and doesn't loop-crash on theANTHROPIC_TOKENquestion from Part 2).- Send a test Telegram message; send a test email; confirm both round-trip.
sudo nixos-rebuild switch --flake '.#mibook'once verified.
Rollback
my.profiles.hermes-agent.enable = false, nixos-rebuild switch stops the
service, but does not clean up /var/lib/hermes — the plaintext
.env (Anthropic token, Telegram token, email password) stays on disk
indefinitely, per the persistence note in Part 2. A real rollback is:
sudo rm -rf /var/lib/hermes, plus revoking the Telegram bot token
(@BotFather → /revoke) since it can't be un-leaked once exposed. No
data migration is involved, so this is still cheap — just not one command.
Open risks (carried forward, not resolved by design)
— Resolved: confirmed supported by reading the lockedANTHROPIC_TOKENvsANTHROPIC_API_KEYhermes-agentrevision's source (Part 2).- Extra-credits billing pool funding — not independently verified, user-asserted intent (Part 3, step 1).
- Tier 2 Nix support — expect some manual troubleshooting; upstream doesn't guarantee the flake stays working across their commits.
- Availability gap — accepted by design, not a bug to fix later unless priorities change.
ANTHROPIC_TOKENcannot refresh unattended. The setup-token is a terminal value to the module's credential resolver; the refresh path needs a~/.claude/.credentials.jsonwith a refresh token that thehermesservice user won't have. When the token expires, the gateway silently stops authenticating and the operator has to re-runclaude setup-tokenand re-populate the secret by hand — watch for this during the confidence window, not something this design solves.