Files
nixos/docs/superpowers/specs/2026-09-08-hermes-agent-nixos-design.md
T
finn.markwitzandClaude Sonnet 5 0756eb94cd docs: correct spec+plan for self-vs-inputs in imports
The fix subagent found that inputs.hermes-agent.nixosModules.default
inside `imports` causes infinite recursion in this repo's wiring
(inputs comes from _module.args, which merges after imports resolve;
self comes from specialArgs, which is available at import-time).
Tested both ways. Update docs to show self.inputs, matching commit
31e1910.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vbvwg3Qt2u9nbkEA3wL17S
2026-09-08 17:51:31 +02:00

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 = [ self.inputs.hermes-agent.nixosModules.default ]; — using self (available via specialArgs = { inherit self; }; in machines/configuration.nix), not the inputs module arg. Correction (found during implementation, 2026-09-08): inputs is supplied via _module.args.inputs = self.inputs;, set inside a config block — but NixOS resolves a module's imports list before _module.args assignments merge, so anything referenced inside imports must come from specialArgs (fixed up front) instead. Using inputs here reproduces infinite recursion encountered; self.inputs.hermes-agent is the same flake input, reached the way that actually works. Separately, 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,
  self,
  ...
}:
let
  cfg = config.my.profiles.hermes-agent;
in
{
  imports = [ self.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)

  1. Confirm Claude Max extra-usage-credits are actually purchased (Anthropic console) — the whole auth design depends on this pool being funded.
  2. Run claude setup-token on mibook; capture the token.
  3. Create/gather Telegram bot token (@BotFather) and email credentials.
  4. Add the hermes-agent flake input; add the module wrapper file; wire it into modules/environments/default.nix and machines/mibook/environments.nix.
  5. Add sops.secrets.hermes-env to machines/mibook/secrets.nix.
  6. nix develop -c sops secrets/mibook.yaml, populate hermes-env, git add.
  7. nix build '.#nixosConfigurations.mibook.config.system.build.toplevel' — CI-style check before touching the running system.
  8. sudo nixos-rebuild test --flake '.#mibook' — verify before persisting across reboot.
  9. journalctl -u hermes-agent.service -f — confirm it starts, decrypts the secret, connects to Telegram (and doesn't loop-crash on the ANTHROPIC_TOKEN question from Part 2).
  10. Send a test Telegram message; send a test email; confirm both round-trip.
  11. 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)

  1. ANTHROPIC_TOKEN vs ANTHROPIC_API_KEY — Resolved: confirmed supported by reading the locked hermes-agent revision's source (Part 2).
  2. Extra-credits billing pool funding — not independently verified, user-asserted intent (Part 3, step 1).
  3. Tier 2 Nix support — expect some manual troubleshooting; upstream doesn't guarantee the flake stays working across their commits.
  4. Availability gap — accepted by design, not a bug to fix later unless priorities change.
  5. ANTHROPIC_TOKEN cannot refresh unattended. The setup-token is a terminal value to the module's credential resolver; the refresh path needs a ~/.claude/.credentials.json with a refresh token that the hermes service user won't have. When the token expires, the gateway silently stops authenticating and the operator has to re-run claude setup-token and re-populate the secret by hand — watch for this during the confidence window, not something this design solves.