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
206 lines
11 KiB
Markdown
206 lines
11 KiB
Markdown
# 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](https://github.com/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:
|
|
|
|
```nix
|
|
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:
|
|
|
|
```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;
|
|
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`):
|
|
|
|
```nix
|
|
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.
|