My Hermes Bot Prompts Are Nix Modules, and the Build Fails If I Break One

By: on Jun 1, 2026
Rows of circuit boards beneath an automated assembly machine

Four Bots, One Prompt Problem

I run a small fleet of Hermes Agent bots on a NixOS box at home. They handle the admin overhead of a tiny business: triaging email, prepping meetings, pulling action items out of transcripts, surfacing threads where someone is waiting on a reply, and in one slightly ridiculous case, controlling the lights through Home Assistant.

None of them call a hosted model API. Everything runs on a local Ollama instance with a 27B model quantized to Q4 to fit the box. Each identity runs as a separate systemd service and Unix user, with its own Telegram presence, Hermes workspace, prompt, and scope of authority.

At that layer, a bot is mostly an AGENTS.md, a Hermes toolset allowlist, and an ID list. Hermes supplies the gateway, Telegram adapter, sessions, cron scheduler, and tool implementations. My Nix configuration supplies the identity and decides which of those capabilities each bot is allowed to see.

My first version let it sprawl. Every tool-specific instruction was inlined directly into each bot's file. Two problems showed up immediately:

  • The bot files became unreviewable. A diff touching one workflow was buried in a wall of unrelated prose.
  • Cross-bot reuse was impossible. Wanting the same inbox-triage behavior on a second bot meant copy-paste, and copy-paste means the two copies diverge by next month.

Prompts as Nix Expressions

Now every bot's prompt is a Nix expression that imports topic-specific modules:

let
  memory       = import ./bot-memory.nix       { inherit pkgs lib; };
  leads        = import ./bot-leads-prompt.nix { inherit lib; };
  inboxTriage  = import ./bot-inbox-triage-prompt.nix  { inherit lib; };
  emailHistory = import ./bot-email-history-prompt.nix { inherit lib; };
  meetingNotes = import ./bot-meeting-notes-prompt.nix { inherit lib; };
  prompts      = import ./bot-prompts.nix      { inherit lib; };
  tg           = import ./telegram-prompts.nix {};
in {
  environment.etc."basecamp/AGENTS.md".text = ''
    # Basecamp: Business Ops Agent
    ...
    ${meetingNotes.mkPromptBlock { taskSink = "beads add"; }}
    ${inboxTriage.mkPromptBlock  { userHint = "..."; }}
    ${emailHistory.promptBlock}
    ${leads.promptBlock}
    ...
  '';

  # Hermes discovers AGENTS.md from the service working directory.
  # Bind the generated artifact there instead of copying it at restart.
  systemd.services.basecamp-gateway.serviceConfig.BindReadOnlyPaths = [
    "/etc/basecamp/AGENTS.md:/var/lib/basecamp/workspace/AGENTS.md"
  ];
}

Each module exports a mkPromptBlock { ... } function with knobs for per-bot customization, plus a promptBlock default for the common case. A topic module is small:

{lib}: rec {
  mkPromptBlock = { staleDays ? 7, userHint ? "" }: ''
    ## Inbox triage: stale follow-ups
    A thread is stale when both are true:
    1. Older than ${toString staleDays} days, AND
    2. The last message in the thread was NOT from the primary user.
    ...
  '';
  promptBlock = mkPromptBlock {};
}

No clever metaprogramming. Just functions returning strings. That restraint is deliberate. The moment prompt assembly becomes hard to read, you've traded one unreviewable artifact for another.

The payoff is that putting leads tracking on a second bot tomorrow costs one import line and one parameter, and both bots stay in sync forever after.

Make the Prompt Match the Hermes Tool Boundary

A reusable prompt block is only half a capability. The text tells Hermes how to use something; the toolset configuration decides whether it can use it at all. I keep those changes beside each other in the bot definition.

Here is the real shape of adding Home Assistant to Quantance, my trading bot. The factory computes Hermes's disabled toolsets as all known toolsets - enabledToolsets, so a new toolset added upstream does not silently appear in every bot:

let
  homeAssistant = import ./bot-homeassistant-prompt.nix { inherit lib; };
in {
  enabledToolsets = [
    "terminal" "file" "cronjob" "memory" "todo"
    "skills" "session_search" "clarify"
    "homeassistant"
  ];

  environment.HASS_URL = "http://100.85.184.136:8123";
  environmentFiles = [
    config.age.secrets.quantance-telegram-env.path
    config.age.secrets.quantance-hass-env.path
  ];

  prompt = ''
    # Quantance: Quantitative Trading Analyst
    ${homeAssistant.promptBlock}
  '';
}

That one addition crosses four layers deliberately: the prompt explains the safety policy, the allowlist exposes Hermes's homeassistant tools, HASS_URL points them at the service, and an agenix environment file supplies the token without putting it in the prompt or workspace. The prompt says "never fabricate device state"; the tool boundary gives the model a real state query with which to obey.

The deployment check is just as concrete:

sudo -u quantance hermes tools --summary
# homeassistant must be permitted in the bot's Hermes profile
# Then ask the live bot for one harmless entity-state read.

That is the pattern I now use for any Hermes capability: prompt module, toolset entry, runtime dependency, smoke test. If I cannot name all four, the feature is not deployed.

The Free Win: Deploy-Time Validation

Because the prompt is a Nix expression, nh os switch validates the whole thing at build time. It also evaluates the Hermes toolset list, service environment, secret paths, and systemd unit that travel with it.

Missing module? Build fails. Wrong parameter name? Build fails. Typo in an interpolation? Build fails. Referenced agenix file not tracked by the flake? Build fails. And critically, when the build fails, the bot keeps its previous prompt and tool configuration. There is no state where a broken edit produces a half-configured agent talking to my partner.

The resulting file is not a private convention Hermes needs a plugin to understand. Hermes discovers AGENTS.md from its working directory and injects it into the system prompt; scheduled jobs can do the same when they are given a --workdir. Nix is simply the compiler that produces that ordinary Hermes input.

The Prompt Also Runs Without Me

The most useful Basecamp workflow is not a Telegram question. It is a Hermes cron job that starts a fresh agent session every morning, reads the same modular instructions, and delivers the answer to Telegram:

hermes cron create "0 7 * * *" \
  "Build the morning itinerary: calendar today, open beads,
   overdue follow-ups, and meeting prep from recent transcripts." \
  --workdir /var/lib/basecamp/workspace \
  --deliver telegram \
  --name daily-itinerary

--workdir is doing two jobs: it gives file and terminal tools the correct working directory, and it makes Hermes load that directory's AGENTS.md. The job runs in an isolated session, so yesterday's chat does not contaminate today's brief. Delivery is scheduler configuration rather than another prompt instruction the model might forget to follow.

This is why the module matters beyond interactive chat. Inbox triage, lead rules, and meeting-note handling become one shared behavior layer used by the live Telegram conversation and by clean-room scheduled runs. I can change the stale-thread threshold once, rebuild, and have both paths pick it up.

This is the same property that makes declarative deploys worth the setup cost. If it builds, the shape is right. You get a whole category of "oops, I deleted a section" errors caught before anything runs.

Live-Config Parity, and Where It Stops Working

The same instinct extends to test configuration. If your tests use different sampling parameters than production, you are testing a different system, so the harness reads them from the live source rather than hardcoding.

Temperature, top_k, and top_p come from the Modelfile via Ollama's /api/show. num_ctx is read from the deployed bot's JSON config via nix eval. The harness queries both at startup and uses the live values unless explicitly overridden.

That works. For sampling parity, it's exactly right.

It does not work for prompt content parity, and I found that out the expensive way.

This failure happened on the fleet's previous runtime. The test harness read the prompt from /etc/<bot>/AGENT_INSTRUCTIONS.md, where Nix wrote it. The live gateway read its prompt from its workspace directory. A helper was supposed to sync those on every service restart, and wrote to the wrong filename.

For six weeks, every deploy updated /etc/ correctly, the harness validated against /etc/ correctly, and every test went green. Meanwhile the workspace copy stayed frozen at whatever the original bootstrap put there. Production was running on a prompt missing most of the year's work while the tests celebrated each addition.

The lesson survived the migration to Hermes: "live-config parity" is a property of the entire path from Nix expression to model. Parity between your harness and a file in /etc/ is not parity with the AGENTS.md Hermes discovered from its actual working directory. Those are different claims and only one of them is the one you care about. The read-only systemd bind above removes the copy step entirely, but I still test the process boundary.

What Build-Time Validation Can and Can't Do

It's worth being precise about the boundary, because I over-trusted it.

Nix guarantees the artifact is well-formed. Every module resolves, every parameter exists, and the file gets written with the right content and permissions.

Nix guarantees nothing about whether the running process reads that file. That's a runtime property, on the other side of the build/run boundary, and no amount of declarative configuration crosses it for you.

Which is why the pre-deploy checklist now ends at the process boundary: compare the generated file with the workspace AGENTS.md, run hermes tools --summary as the bot user, then trigger one harmless real tool call. The first catches prompt drift, the second catches capability drift, and the third proves the credentials and network path exist.

diff -u /etc/basecamp/AGENTS.md \
  /var/lib/basecamp/workspace/AGENTS.md
sudo -u basecamp hermes tools --summary
# Then ask for a calendar read, never a write, as the smoke test.

Why This Shape Holds Up

A year in, the modular structure is the part I'd keep without hesitation. Prompt changes arrive as reviewable diffs scoped to one workflow. Behavior gets shared across Hermes bots by importing it rather than duplicating it. Parameters make the differences between bots explicit instead of buried in near-identical prose.

And prompts get treated as what they actually are: the highest-leverage code in the system, deserving the same review, versioning, and validation as anything else that ships.

The module that has earned this structure most is the lead-tracking one. It got rewritten twice as I discovered that the funnel I'd designed for wasn't the funnel that existed. Because it's one parameterized file, each rewrite was a scoped diff rather than surgery on four bots. That story is in the sales-ops bot post.

Header photo by Louis Reed on Unsplash.

Content on this blog was created using human and AI-assisted workflows described in my standards and workflow posts. Original ideas and editorial decisions by Justin Quaintance.