Files
nixos/docs/superpowers/plans/2026-09-08-hermes-agent-mibook.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

24 KiB
Raw Blame History

Hermes Agent on mibook Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Note on nature: Task 1 is repo-only work verifiable with nix build — no runtime tests exist for declarative config, matching this repo's existing convention (see the Immich plan). Tasks 2–5 are an operator-run runbook, not agent-automatable: Task 2 needs an interactive claude setup-token OAuth login, an Anthropic billing console check, and a Telegram BotFather chat; Task 3 requires typing real secret material into the $EDITOR sops opens; Task 4 runs nixos-rebuild on mibook, the operator's live daily-driver desktop. Never paste secret values (tokens, passwords) into an agent session or chat — Task 3 has the operator type them directly into the editor sops opens. Present Tasks 2–5 for the operator to run and confirm; do not attempt to execute them from an agent session.

Goal: Run Hermes Agent as a hardened NixOS systemd service on mibook, reachable via Telegram and Email, authenticated to Anthropic via a Claude subscription (claude setup-token) rather than a pay-per-token API key.

Architecture: A standard my.profiles.hermes-agent module wraps the upstream services.hermes-agent module (from NousResearch/hermes-agent's nixosModules.default), imported from inside the profile module itself so both machines can evaluate it — only mibook ever turns it on (see Task 1 Step 3). Credentials (Anthropic token, Telegram bot token, email creds, both *_ALLOWED_USERS access-control keys) live in one sops secret (hermes-env) consumed via environmentFiles. No network exposure — Telegram long-polling and Email IMAP/SMTP are both outbound-initiated from mibook.

Tech Stack: NixOS (flake-parts), sops-nix, upstream hermes-agent flake (Tier 2/best-effort Nix support), Anthropic Claude (subscription auth).

Spec: docs/superpowers/specs/2026-09-08-hermes-agent-nixos-design.md

Global Constraints

  • Machine: mibook only. Do not enable on jupiter.
  • Module source: official github:NousResearch/hermes-agent (nixosModules.default). Not the community 0xrsydn/nix-hermes-agent wrapper.
  • Auth: claude setup-token output goes into ANTHROPIC_TOKEN in the hermes-env secret — not ANTHROPIC_API_KEY. Confirmed supported by the locked revision's source (Task 4 Step 2); no longer conditional.
  • Billing dependency: the setup-token/OAuth path only draws from Claude Max "extra usage credits" — confirm this pool is funded before Task 2's token generation, or auth will fail even with a valid token.
  • Module wiring: the upstream hermes-agent.nixosModules.default import lives inside modules/environments/hermes-agent/default.nix (via the inputs module arg), never in machines/configuration.nix's per-machine module lists — wiring it there breaks jupiter's evaluation even with the profile disabled (see Task 1 Step 3's correction).
  • Access control: TELEGRAM_ALLOWED_USERS and EMAIL_ALLOWED_USERS are required in the hermes-env secret, not optional — see Task 2 Steps 3–4.
  • Secret persistence: credentials end up in plaintext at /var/lib/hermes/.hermes/.env (mode 0640), not sops-nix's /run/secrets tmpfs — rollback (Task 5 Step 3) accounts for this.
  • Secret: one sops secret named hermes-env, a full env-file blob (matches environmentFiles — list of paths, not a single-value secret).
  • No network exposure: do not touch modules/services/webserver or open any inbound port. Telegram + Email are both outbound-only.
  • Platforms: Telegram + Email only, first pass. No Discord/Slack/etc.
  • Rebuild command: sudo nixos-rebuild switch --flake '.#mibook' (use nixos-rebuild test first, per Task 4).
  • Build-check command: nix build '.#nixosConfigurations.mibook.config.system.build.toplevel'.
  • Format Nix with nixfmt-rfc-style before committing.
  • Secret material never enters an agent session, chat, or the git history in plaintext — only via sops secrets/mibook.yaml and $EDITOR.
  • Base branch: feat/sops-nix (already pushed; do not merge or rebase it as part of this work).

File Structure

  • Modify flake.nix — add the hermes-agent flake input.
  • Not modified machines/configuration.nix — Task 1 originally wired the upstream module import here (mibook-only); this broke jupiter's evaluation and was corrected to import from inside the profile module instead. See Task 1 Step 3.
  • Create modules/environments/hermes-agent/default.nix — the my.profiles.hermes-agent module (single responsibility: declare Hermes Agent).
  • Modify modules/environments/default.nix — add ./hermes-agent to the import list.
  • Modify machines/mibook/environments.nix — set hermes-agent.enable = true.
  • Modify machines/mibook/secrets.nix — replace the placeholder secret with hermes-env.
  • Modify (encrypted, operator-only) secrets/mibook.yaml — real credential values, never touched by an agent.

No other files change.


Task 1: Wire up the module (flake input, profile module, secret declaration)

Files:

  • Modify: flake.nix
  • Create: modules/environments/hermes-agent/default.nix
  • Modify: modules/environments/default.nix
  • Modify: machines/mibook/environments.nix
  • Modify: machines/mibook/secrets.nix
  • Not modified: machines/configuration.nix — see Step 3's correction.

Interfaces:

  • Produces: NixOS option my.profiles.hermes-agent.enable (bool). When true, configures services.hermes-agent with environmentFiles = [ config.sops.secrets."hermes-env".path ] and grants finn the hermes group. Declares sops.secrets.hermes-env (owner/group hermes, path resolves to /run/secrets/hermes-env at runtime — but note the service re-copies it to /var/lib/hermes/.hermes/.env, per the spec's persistence note).

  • Consumes: hermes-agent.nixosModules.default from the new flake input, imported directly inside the profile module via self.inputs (the self module arg, from specialArgs — see Step 3 and Step 5's note on why not the inputs module arg) — not through machines/configuration.nix; existing sops.secrets.<name> mechanism from machines/core/sops.nix (already applied to every machine, unaffected by this task).

  • Step 1: Add the flake input

In flake.nix, inside inputs = { ... }, add after the sops-nix block:

    hermes-agent = {
      url = "github:NousResearch/hermes-agent";
      inputs.nixpkgs.follows = "nixpkgs";
    };
  • Step 2: Lock the new input

Run: nix flake lock Expected: flake.lock gains a hermes-agent entry (and its transitive inputs). No other entries change.

  • Step 3: Do NOT wire the module import through machines/configuration.nix

Correction (found during final whole-plan review, 2026-09-08): an earlier version of this step added hermes-agent to the inherit (self.inputs) ... destructure and hermes-agent.nixosModules.default to mibook's modules list in machines/configuration.nix. That broke nixosConfigurations.jupiter evaluation — confirmed by direct nix eval. modules/environments/default.nix (Step 6) is machine-agnostic, so jupiter loads the profile module too; NixOS's module system errors on a definition for an undeclared option regardless of lib.mkIf's condition. Since jupiter never imported hermes-agent.nixosModules.default, it never declared services.hermes-agent as an option at all, so even the disabled (mkIf false) config definition in Step 5 crashed evaluation.

Skip this step — do not touch machines/configuration.nix at all. The fix lives in Step 5: the upstream module is imported from inside the profile module itself, which works correctly on both machines (declares the option everywhere, only ever turns it on where my.profiles.hermes-agent.enable = true).

  • Step 4: Read a reference module to match repo style

Read modules/environments/immich/default.nix (same shape: cfg, lib.mkIf, single-purpose options block). Match its formatting.

  • Step 5: Create the profile module

Create modules/environments/hermes-agent/default.nix:

# 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;
      # Deliberate pin, matches the approved design — bump on purpose, not by drift.
      settings.model.default = "anthropic/claude-sonnet-4-5";
      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" ];
  };
}

Use self, not inputs, in the imports line. inputs is supplied via _module.args.inputs = self.inputs;, set inside a config block — but NixOS resolves a module's imports before _module.args assignments merge, so anything referenced inside imports must come from specialArgs (fixed up front) instead. self is threaded via specialArgs = { inherit self; }; in machines/configuration.nix and is available at import-time; inputs is not. Using inputs here reproduces infinite recursion encountered — confirmed by testing it directly, not theoretical. self.inputs.hermes-agent is the same flake input either way.

  • Step 6: Register the module in the environments import list

In modules/environments/default.nix, add ./hermes-agent after ./immich:

    ./immich
    ./hermes-agent
  ];
}
  • Step 7: Enable it on mibook

In machines/mibook/environments.nix, inside the my.profiles = { ... } block, add:

    hermes-agent.enable = true;
  • Step 8: Replace the placeholder secret declaration

In machines/mibook/secrets.nix, replace the whole file body:

# Secrets decrypted on mibook. sopsFile defaults to secrets/mibook.yaml
# via machines/core/sops.nix.
{ ... }:
{
  sops.secrets.hermes-env = {
    owner = "hermes";
    group = "hermes";
    mode = "0400";
    restartUnits = [ "hermes-agent.service" ]; # confirmed against the locked revision's generated unit
  };
}

This removes the placeholder secret declared here — it was an explicit throwaway (comment: "Safe to delete once a real secret is in place"). Its entry in the encrypted secrets/mibook.yaml content itself is cleaned up by the operator in Task 3, not here.

  • Step 9: Format

Run: nixfmt-rfc-style modules/environments/hermes-agent/default.nix machines/mibook/secrets.nix machines/mibook/environments.nix flake.nix

  • Step 10: Build-check (this is the "test", with a known-expected failure until Task 3)

Run: nix build '.#nixosConfigurations.mibook.config.system.build.toplevel' Correction (found during Task 1 implementation, 2026-09-08): this does not build cleanly yet, and that's expected — sops.validateSopsFiles = true (set repo-wide in machines/core/sops.nix) makes secret-key existence a build-time dependency of config.system.build.toplevel (via a manifest.json.drv step), not an activation-time check as originally assumed here. Since secrets/mibook.yaml still only has the placeholder key at this point in the plan (Task 3 adds hermes-env), the build correctly fails with:

> .../sops-install-secrets: manifest is not valid: secret hermes-env in .../mibook.yaml is not valid: the key 'hermes-env' cannot be found

Do not treat this as a wiring defect, and do not weaken sops.validateSopsFiles to work around it — that's a repo-wide safety invariant, not this task's to change. Instead, confirm the wiring is correct by two other means: nix build ... --dry-run (should complete with no eval errors and the full derivation list) and Step 11 below. A fully green (non-dry-run) nix build of the toplevel is only reachable after Task 3 populates the real secret — treat that as the actual end-to-end checkpoint, revisited in Task 4 Step 1. If --dry-run itself fails on an unknown services.hermes-agent.* option, reconcile against the actual module: the error will name the bad option; cross-check against website/docs/getting-started/nix-setup.md in the NousResearch/hermes-agent repo at the locked revision.

  • Step 11: Build-check jupiter too — this is the regression gate

Run: nix eval '.#nixosConfigurations.jupiter.config.system.build.toplevel.drvPath' Expected: succeeds, printing a .drv path. Any change under modules/ is a two-machine change even when it's "for one host" — modules/environments/default.nix (Step 6) is loaded by every machine, so a mistake here can break jupiter's eval even though jupiter never enables this profile. This is exactly the failure Step 3's correction fixes; this step is what catches it if it ever regresses.

  • Step 12: Confirm the option evaluates on mibook

Run: nix eval '.#nixosConfigurations.mibook.config.services.hermes-agent.enable' Expected: true

  • Step 13: Commit
git add flake.nix flake.lock modules/environments/hermes-agent/default.nix modules/environments/default.nix machines/mibook/environments.nix machines/mibook/secrets.nix
git commit -m "feat(mibook): add Hermes Agent profile module"

Post-review note, 2026-09-08: Task 1 was originally committed as 731678e with the jupiter-breaking bug Step 3 describes and fixes — the upstream module was wired through machines/configuration.nix's mibook-only module list instead of imported inside the profile module. The final whole-plan review caught it (jupiter's nixosConfigurations stopped evaluating, breaking the repo's sole CI job even though jupiter never enables this profile). It was corrected in a follow-up commit on the same branch — run git log --oneline -- modules/environments/hermes-agent/ machines/configuration.nix if you need both commit hashes. The steps above already reflect the corrected, final approach — executing this plan from scratch would never hit the bug.


Task 2: Gather credentials (operator-run, not agent-automatable)

Files: none. All values are held by the operator for Task 3 — do not write them into any file an agent can read, and do not paste them into a chat session.

Interfaces:

  • Produces: an Anthropic setup-token, a Telegram bot token, and email (IMAP/SMTP) credentials — ready to type into $EDITOR in Task 3.

  • Step 1: Confirm the billing pool is funded

In the Anthropic console, confirm the Claude Max plan has extra usage credits purchased. The claude setup-token auth path bills against this pool only — it does not draw from the base Max allowance, and Claude Pro cannot use this path at all. If this isn't funded, stop here and decide whether to fund it or fall back to a plain ANTHROPIC_API_KEY (see Global Constraints).

  • Step 2: Generate the setup-token

On mibook, run: claude setup-token This opens an interactive OAuth flow. On completion it prints a long-lived token. Copy it somewhere only you control (e.g. your password manager) — you'll type it into sops in Task 3, not into this session.

  • Step 3: Create the Telegram bot, and get your own user ID

In Telegram, message @BotFather, run /newbot, follow the prompts. Copy the resulting bot token (format 123456:ABC-...). Also get your own numeric Telegram user ID (e.g. via @userinfobot) — Task 3 needs it for TELEGRAM_ALLOWED_USERS. Telegram defaults to open DM pairing (any stranger who messages the bot gets a pairing code and can request approval); without this allowlist, that's the default posture, not a hardened one.

  • Step 4: Gather email credentials

Decide which mailbox Hermes Agent should use, and get its IMAP/SMTP host, username, and password (or app-specific password if the provider requires one, e.g. Gmail). The exact env var names (confirmed against the locked hermes-agent revision, not guessed) are EMAIL_ADDRESS, EMAIL_PASSWORD, EMAIL_IMAP_HOST, EMAIL_SMTP_HOST, plus optional EMAIL_IMAP_PORT (default 993) / EMAIL_SMTP_PORT (default 587) if your provider differs — see website/docs/user-guide/messaging/email.md and website/docs/reference/environment-variables.md in the NousResearch/hermes-agent repo for more detail. Also decide EMAIL_ALLOWED_USERS (your own sending address) — without it, mail from unrecognized senders is silently ignored with no log line, which Task 4 Step 4 depends on knowing.


Task 3: Populate the secret (operator-run, secret material)

Files:

  • Modify (encrypted): secrets/mibook.yaml

Interfaces:

  • Consumes: the three credentials gathered in Task 2, and the hermes-env declaration from Task 1.

  • Produces: a decryptable hermes-env entry that Task 1's environmentFiles reference resolves at activation.

  • Step 1: Open the encrypted file

Run: nix develop -c sops secrets/mibook.yaml This decrypts and opens $EDITOR on the plaintext.

  • Step 2: Remove the placeholder key, add hermes-env

Delete the placeholder: ... entry (Task 1 already removed its Nix declaration; this removes the matching content). Add:

hermes-env: |
    ANTHROPIC_TOKEN=<paste the claude setup-token output>
    TELEGRAM_BOT_TOKEN=<paste the BotFather token>
    TELEGRAM_ALLOWED_USERS=<your numeric Telegram user id, from Task 2 Step 3>
    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, from Task 2 Step 4>

(Exact YAML block-scalar syntax per whatever your $EDITOR shows — sops re-encrypts on save regardless of formatting, as long as it's valid YAML. Both *_ALLOWED_USERS keys are access control, not optional — see Task 2 Steps 3–4 for why.)

  • Step 3: Save and let sops re-encrypt

Save and quit $EDITOR. Run: git diff --stat secrets/mibook.yaml Expected: the file shows as changed (still fully encrypted — confirm by git diff secrets/mibook.yaml | head and seeing sops's ENC[...] ciphertext, never plaintext).

  • Step 4: Stage it

Run: git add secrets/mibook.yaml Required before the next nix build — untracked sops files are invisible to flake evaluation (repo rule, docs/secrets.md).

  • Step 5: Commit
git commit -m "feat(mibook): populate Hermes Agent credentials"

Task 4: Deploy and verify (operator-run, on mibook)

Files: none in repo. Run all commands on mibook.

Interfaces:

  • Consumes: the built config from Task 1, the secret from Task 3.

  • Produces: a running, verified hermes-agent.service reachable via Telegram and Email.

  • Step 1: Test-activate without persisting

Run: sudo nixos-rebuild test --flake '.#mibook' Expected: activates without reboot-persisting. If it fails at secret decryption ("hermes-env not found in sops file"), Task 3 wasn't saved/staged correctly — redo Task 3.

  • Step 2: Watch the service start

hermes-agent.service is the confirmed unit name (verified against the locked revision's generated unit, not a guess). Run: journalctl -u hermes-agent.service -f Expected: starts, reads the env file (lands at /var/lib/hermes/.hermes/.env — see spec Part 2), and does not crash-loop on auth. ANTHROPIC_TOKEN is confirmed supported by the module's credential resolver, so an auth failure here points at the token itself (expired, or the extra-credits pool from Task 2 Step 1 isn't actually funded) rather than the module rejecting the variable name.

  • Step 3: Verify Telegram connects

Send your bot a message from Telegram. Expected: it responds (confirms both the bot token and the LLM auth work end-to-end).

  • Step 4: Verify Email connects

Send a test email to the configured mailbox. Expected: Hermes Agent picks it up (check journalctl -u hermes-agent.service for an inbound-message log line) and can send a reply.

  • Step 5: Persist across reboot

Run: sudo nixos-rebuild switch --flake '.#mibook' Expected: same running state as Step 1–4, now persisted.


Task 5: Sign-off (operator-run)

Files: none in repo beyond what Tasks 1 and 3 already committed.

Interfaces:

  • Consumes: a verified running instance (Task 4).

  • Step 1: Open a PR

feat/sops-nix is already pushed to origin but not merged; this work sits on top of it. Push this branch (or continue on feat/sops-nix directly, per your preference) and open a PR per the repo's usual Gitea workflow. Note in the PR description that it depends on feat/sops-nix landing first (or is stacked on it).

  • Step 2: Confidence window

Use it for a few days — send yourself Telegram/Email messages periodically, including after mibook has slept and woken up, to see how the accepted availability gap actually behaves in practice.

  • Step 3: Rollback (if needed)

my.profiles.hermes-agent.enable = false in machines/mibook/environments.nix, sudo nixos-rebuild switch --flake '.#mibook' stops the service — but the plaintext credential file survives on disk (see spec's Rollback section for why). Also run sudo rm -rf /var/lib/hermes and revoke the Telegram bot token via @BotFather → /revoke. No data migration is involved, so this is still cheap, just not a single command.


Self-Review

Spec coverage:

  • Host mibook, not jupiter → Global Constraints + File Structure. ✓
  • NixOS native module, official upstream source → Task 1. ✓
  • Home Manager rejected → not present anywhere (correctly absent, matches "deliberately deferred" reasoning — no task references it). ✓
  • claude setup-token → ANTHROPIC_TOKEN, Max extra-credits caveat → Task 2 Steps 1–2, Task 4 Step 2, Global Constraints. ✓
  • hermes-env single env-file secret → Task 1 Step 8, Task 3. ✓
  • No sops prerequisite (mibook already registered) → confirmed by reusing existing machines/mibook/secrets.nix rather than any .sops.yaml/Phase-9-style bootstrapping. ✓
  • No network exposure → Global Constraints; no task touches modules/services/webserver. ✓
  • Telegram + Email only → Task 2 Steps 3–4, Task 4 Steps 3–4. ✓
  • Rollback path → Task 5 Step 3. ✓
  • Deferred (other platforms, availability-gap workarounds, public exposure) → correctly absent. ✓

Placeholder scan: No bare TBD/TODO. The bracketed values (<paste the claude setup-token output>, etc.) are operator-supplied secret material by design — Task 2 explicitly produces them, Task 3 explicitly consumes them; they're never meant to be concrete in this document since they're credentials, not code.

Type consistency: my.profiles.hermes-agent.enable, services.hermes-agent.*, and the hermes-env secret name are used identically across Tasks 1, 3, and 4. The hermes-agent.service unit name and ANTHROPIC_TOKEN var are confirmed (not guessed) consistently in both Task 1's declaration and Task 4's verification.

Post-final-review addendum (2026-09-08): the final whole-plan review found the module-wiring bug described in Task 1 Step 3, plus three access/persistence gaps now folded into Global Constraints, Task 2 Steps 3–4, Task 3's template, and Task 5 Step 3's rollback (TELEGRAM_ALLOWED_USERS/EMAIL_ALLOWED_USERS required; secrets persist to /var/lib/hermes/.hermes/.env, not /run/secrets; hermes CLI needs finn in the hermes group to be usable). All four are corrected in place above, not left as separate addenda, so the plan reads as a single coherent document rather than a stack of patches.