Spaces:
Paused
Paused
| sidebar_position: 3 | |
| title: "Nix & NixOS Setup" | |
| description: "Install and deploy Hermes Agent with Nix β from quick `nix run` to fully declarative NixOS module with container mode" | |
| # Nix & NixOS Setup | |
| Hermes Agent ships a Nix flake with three levels of integration: | |
| | Level | Who it's for | What you get | | |
| |-------|-------------|--------------| | |
| | **`nix run` / `nix profile install`** | Any Nix user (macOS, Linux) | Pre-built binary with all deps β then use the standard CLI workflow | | |
| | **NixOS module (native)** | NixOS server deployments | Declarative config, hardened systemd service, managed secrets | | |
| | **NixOS module (container)** | Agents that need self-modification | Everything above, plus a persistent Ubuntu container where the agent can `apt`/`pip`/`npm install` | | |
| :::info What's different from the standard install | |
| The `curl | bash` installer manages Python, Node, and dependencies itself. The Nix flake replaces all of that β every Python dependency is a Nix derivation built by [uv2nix](https://github.com/pyproject-nix/uv2nix), and runtime tools (Node.js, git, ripgrep, ffmpeg) are wrapped into the binary's PATH. There is no runtime pip, no venv activation, no `npm install`. | |
| **For non-NixOS users**, this only changes the install step. Everything after (`hermes setup`, `hermes gateway install`, config editing) works identically to the standard install. | |
| **For NixOS module users**, the entire lifecycle is different: configuration lives in `configuration.nix`, secrets go through sops-nix/agenix, the service is a systemd unit, and CLI config commands are blocked. You manage hermes the same way you manage any other NixOS service. | |
| ::: | |
| ## Prerequisites | |
| - **Nix with flakes enabled** β [Determinate Nix](https://install.determinate.systems) recommended (enables flakes by default) | |
| - **API keys** for the services you want to use (at minimum: an OpenRouter or Anthropic key) | |
| --- | |
| ## Quick Start (Any Nix User) | |
| No clone needed. Nix fetches, builds, and runs everything: | |
| ```bash | |
| # Run directly (builds on first use, cached after) | |
| nix run github:NousResearch/hermes-agent -- setup | |
| nix run github:NousResearch/hermes-agent -- chat | |
| # Or install persistently | |
| nix profile install github:NousResearch/hermes-agent | |
| hermes setup | |
| hermes chat | |
| ``` | |
| After `nix profile install`, `hermes`, `hermes-agent`, and `hermes-acp` are on your PATH. From here, the workflow is identical to the [standard installation](./installation.md) β `hermes setup` walks you through provider selection, `hermes gateway install` sets up a launchd (macOS) or systemd user service, and config lives in `~/.hermes/`. | |
| <details> | |
| <summary><strong>Building from a local clone</strong></summary> | |
| ```bash | |
| git clone https://github.com/NousResearch/hermes-agent.git | |
| cd hermes-agent | |
| nix build | |
| ./result/bin/hermes setup | |
| ``` | |
| </details> | |
| --- | |
| ## NixOS Module | |
| The flake exports `nixosModules.default` β a full NixOS service module that declaratively manages user creation, directories, config generation, secrets, documents, and service lifecycle. | |
| :::note | |
| This module requires NixOS. For non-NixOS systems (macOS, other Linux distros), use `nix profile install` and the standard CLI workflow above. | |
| ::: | |
| ### Add the Flake Input | |
| ```nix | |
| # /etc/nixos/flake.nix (or your system flake) | |
| { | |
| inputs = { | |
| nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; | |
| hermes-agent.url = "github:NousResearch/hermes-agent"; | |
| }; | |
| outputs = { nixpkgs, hermes-agent, ... }: { | |
| nixosConfigurations.your-host = nixpkgs.lib.nixosSystem { | |
| system = "x86_64-linux"; | |
| modules = [ | |
| hermes-agent.nixosModules.default | |
| ./configuration.nix | |
| ]; | |
| }; | |
| }; | |
| } | |
| ``` | |
| ### Minimal Configuration | |
| ```nix | |
| # configuration.nix | |
| { config, ... }: { | |
| services.hermes-agent = { | |
| enable = true; | |
| settings.model.default = "anthropic/claude-sonnet-4"; | |
| environmentFiles = [ config.sops.secrets."hermes-env".path ]; | |
| addToSystemPackages = true; | |
| }; | |
| } | |
| ``` | |
| That's it. `nixos-rebuild switch` creates the `hermes` user, generates `config.yaml`, wires up secrets, and starts the gateway β a long-running service that connects the agent to messaging platforms (Telegram, Discord, etc.) and listens for incoming messages. | |
| :::warning Secrets are required | |
| The `environmentFiles` line above assumes you have [sops-nix](https://github.com/Mic92/sops-nix) or [agenix](https://github.com/ryantm/agenix) configured. The file should contain at least one LLM provider key (e.g., `OPENROUTER_API_KEY=sk-or-...`). See [Secrets Management](#secrets-management) for full setup. If you don't have a secrets manager yet, you can use a plain file as a starting point β just ensure it's not world-readable: | |
| ```bash | |
| echo "OPENROUTER_API_KEY=sk-or-your-key" | sudo install -m 0600 -o hermes /dev/stdin /var/lib/hermes/env | |
| ``` | |
| ```nix | |
| services.hermes-agent.environmentFiles = [ "/var/lib/hermes/env" ]; | |
| ``` | |
| ::: | |
| :::tip addToSystemPackages | |
| Setting `addToSystemPackages = true` does two things: puts the `hermes` CLI on your system PATH **and** sets `HERMES_HOME` system-wide so the interactive CLI shares state (sessions, skills, cron) with the gateway service. Without it, running `hermes` in your shell creates a separate `~/.hermes/` directory. | |
| ::: | |
| :::info Container-aware CLI | |
| When `container.enable = true` and `addToSystemPackages = true`, **every** `hermes` command on the host automatically routes into the managed container. This means your interactive CLI session runs inside the same environment as the gateway service β with access to all container-installed packages and tools. | |
| - The routing is transparent: `hermes chat`, `hermes sessions list`, `hermes version`, etc. all exec into the container under the hood | |
| - All CLI flags are forwarded as-is | |
| - If the container isn't running, the CLI retries briefly (5s with a spinner for interactive use, 10s silently for scripts) then fails with a clear error β no silent fallback | |
| - For developers working on the hermes codebase, set `HERMES_DEV=1` to bypass container routing and run the local checkout directly | |
| Set `container.hostUsers` to create a `~/.hermes` symlink to the service state directory, so the host CLI and the container share sessions, config, and memories: | |
| ```nix | |
| services.hermes-agent = { | |
| container.enable = true; | |
| container.hostUsers = [ "your-username" ]; | |
| addToSystemPackages = true; | |
| }; | |
| ``` | |
| Users listed in `hostUsers` are automatically added to the `hermes` group for file permission access. | |
| **Podman users:** The NixOS service runs the container as root. Docker users get access via the `docker` group socket, but Podman's rootful containers require sudo. Grant passwordless sudo for your container runtime: | |
| ```nix | |
| security.sudo.extraRules = [{ | |
| users = [ "your-username" ]; | |
| commands = [{ | |
| command = "/run/current-system/sw/bin/podman"; | |
| options = [ "NOPASSWD" ]; | |
| }]; | |
| }]; | |
| ``` | |
| The CLI auto-detects when sudo is needed and uses it transparently. Without this, you'll need to run `sudo hermes chat` manually. | |
| ::: | |
| ### Verify It Works | |
| After `nixos-rebuild switch`, check that the service is running: | |
| ```bash | |
| # Check service status | |
| systemctl status hermes-agent | |
| # Watch logs (Ctrl+C to stop) | |
| journalctl -u hermes-agent -f | |
| # If addToSystemPackages is true, test the CLI | |
| hermes version | |
| hermes config # shows the generated config | |
| ``` | |
| ### Choosing a Deployment Mode | |
| The module supports two modes, controlled by `container.enable`: | |
| | | **Native** (default) | **Container** | | |
| |---|---|---| | |
| | How it runs | Hardened systemd service on the host | Persistent Ubuntu container with `/nix/store` bind-mounted | | |
| | Security | `NoNewPrivileges`, `ProtectSystem=strict`, `PrivateTmp` | Container isolation, runs as unprivileged user inside | | |
| | Agent can self-install packages | No β only tools on the Nix-provided PATH | Yes β `apt`, `pip`, `npm` installs persist across restarts | | |
| | Config surface | Same | Same | | |
| | When to choose | Standard deployments, maximum security, reproducibility | Agent needs runtime package installation, mutable environment, experimental tools | | |
| To enable container mode, add one line: | |
| ```nix | |
| { | |
| services.hermes-agent = { | |
| enable = true; | |
| container.enable = true; | |
| # ... rest of config is identical | |
| }; | |
| } | |
| ``` | |
| :::info | |
| Container mode auto-enables `virtualisation.docker.enable` via `mkDefault`. If you use Podman instead, set `container.backend = "podman"` and `virtualisation.docker.enable = false`. | |
| ::: | |
| --- | |
| ## Configuration | |
| ### Declarative Settings | |
| The `settings` option accepts an arbitrary attrset that is rendered as `config.yaml`. It supports deep merging across multiple module definitions (via `lib.recursiveUpdate`), so you can split config across files: | |
| ```nix | |
| # base.nix | |
| services.hermes-agent.settings = { | |
| model.default = "anthropic/claude-sonnet-4"; | |
| toolsets = [ "all" ]; | |
| terminal = { backend = "local"; timeout = 180; }; | |
| }; | |
| # personality.nix | |
| services.hermes-agent.settings = { | |
| display = { compact = false; personality = "kawaii"; }; | |
| memory = { memory_enabled = true; user_profile_enabled = true; }; | |
| }; | |
| ``` | |
| Both are deep-merged at evaluation time. Nix-declared keys always win over keys in an existing `config.yaml` on disk, but **user-added keys that Nix doesn't touch are preserved**. This means if the agent or a manual edit adds keys like `skills.disabled` or `streaming.enabled`, they survive `nixos-rebuild switch`. | |
| :::note Model naming | |
| `settings.model.default` uses the model identifier your provider expects. With [OpenRouter](https://openrouter.ai) (the default), these look like `"anthropic/claude-sonnet-4"` or `"google/gemini-3-flash"`. If you're using a provider directly (Anthropic, OpenAI), set `settings.model.base_url` to point at their API and use their native model IDs (e.g., `"claude-sonnet-4-20250514"`). When no `base_url` is set, Hermes defaults to OpenRouter. | |
| ::: | |
| :::tip Discovering available config keys | |
| Run `nix build .#configKeys && cat result` to see every leaf config key extracted from Python's `DEFAULT_CONFIG`. You can paste your existing `config.yaml` into the `settings` attrset β the structure maps 1:1. | |
| ::: | |
| <details> | |
| <summary><strong>Full example: all commonly customized settings</strong></summary> | |
| ```nix | |
| { config, ... }: { | |
| services.hermes-agent = { | |
| enable = true; | |
| container.enable = true; | |
| # ββ Model ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| settings = { | |
| model = { | |
| base_url = "https://openrouter.ai/api/v1"; | |
| default = "anthropic/claude-opus-4.6"; | |
| }; | |
| toolsets = [ "all" ]; | |
| max_turns = 100; | |
| terminal = { backend = "local"; cwd = "."; timeout = 180; }; | |
| compression = { | |
| enabled = true; | |
| threshold = 0.85; | |
| summary_model = "google/gemini-3-flash-preview"; | |
| }; | |
| memory = { memory_enabled = true; user_profile_enabled = true; }; | |
| display = { compact = false; personality = "kawaii"; }; | |
| agent = { max_turns = 60; verbose = false; }; | |
| }; | |
| # ββ Secrets ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| environmentFiles = [ config.sops.secrets."hermes-env".path ]; | |
| # ββ Documents ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| documents = { | |
| "SOUL.md" = builtins.readFile /home/user/.hermes/SOUL.md; | |
| "USER.md" = ./documents/USER.md; | |
| }; | |
| # ββ MCP Servers ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| mcpServers.filesystem = { | |
| command = "npx"; | |
| args = [ "-y" "@modelcontextprotocol/server-filesystem" "/data/workspace" ]; | |
| }; | |
| # ββ Container options ββββββββββββββββββββββββββββββββββββββββββββββ | |
| container = { | |
| image = "ubuntu:24.04"; | |
| backend = "docker"; | |
| hostUsers = [ "your-username" ]; | |
| extraVolumes = [ "/home/user/projects:/projects:rw" ]; | |
| extraOptions = [ "--gpus" "all" ]; | |
| }; | |
| # ββ Service tuning βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| addToSystemPackages = true; | |
| extraArgs = [ "--verbose" ]; | |
| restart = "always"; | |
| restartSec = 5; | |
| }; | |
| } | |
| ``` | |
| </details> | |
| ### Escape Hatch: Bring Your Own Config | |
| If you'd rather manage `config.yaml` entirely outside Nix, use `configFile`: | |
| ```nix | |
| services.hermes-agent.configFile = /etc/hermes/config.yaml; | |
| ``` | |
| This bypasses `settings` entirely β no merge, no generation. The file is copied as-is to `$HERMES_HOME/config.yaml` on each activation. | |
| ### Customization Cheatsheet | |
| Quick reference for the most common things Nix users want to customize: | |
| | I want to... | Option | Example | | |
| |---|---|---| | |
| | Change the LLM model | `settings.model.default` | `"anthropic/claude-sonnet-4"` | | |
| | Use a different provider endpoint | `settings.model.base_url` | `"https://openrouter.ai/api/v1"` | | |
| | Add API keys | `environmentFiles` | `[ config.sops.secrets."hermes-env".path ]` | | |
| | Give the agent a personality | `documents."SOUL.md"` | `builtins.readFile ./my-soul.md` | | |
| | Add MCP tool servers | `mcpServers.<name>` | See [MCP Servers](#mcp-servers) | | |
| | Mount host directories into container | `container.extraVolumes` | `[ "/data:/data:rw" ]` | | |
| | Pass GPU access to container | `container.extraOptions` | `[ "--gpus" "all" ]` | | |
| | Use Podman instead of Docker | `container.backend` | `"podman"` | | |
| | Share state between host CLI and container | `container.hostUsers` | `[ "sidbin" ]` | | |
| | Add tools to the service PATH (native only) | `extraPackages` | `[ pkgs.pandoc pkgs.imagemagick ]` | | |
| | Use a custom base image | `container.image` | `"ubuntu:24.04"` | | |
| | Override the hermes package | `package` | `inputs.hermes-agent.packages.${system}.default.override { ... }` | | |
| | Change state directory | `stateDir` | `"/opt/hermes"` | | |
| | Set the agent's working directory | `workingDirectory` | `"/home/user/projects"` | | |
| --- | |
| ## Secrets Management | |
| :::danger Never put API keys in `settings` or `environment` | |
| Values in Nix expressions end up in `/nix/store`, which is world-readable. Always use `environmentFiles` with a secrets manager. | |
| ::: | |
| Both `environment` (non-secret vars) and `environmentFiles` (secret files) are merged into `$HERMES_HOME/.env` at activation time (`nixos-rebuild switch`). Hermes reads this file on every startup, so changes take effect with a `systemctl restart hermes-agent` β no container recreation needed. | |
| ### sops-nix | |
| ```nix | |
| { | |
| sops = { | |
| defaultSopsFile = ./secrets/hermes.yaml; | |
| age.keyFile = "/home/user/.config/sops/age/keys.txt"; | |
| secrets."hermes-env" = { format = "yaml"; }; | |
| }; | |
| services.hermes-agent.environmentFiles = [ | |
| config.sops.secrets."hermes-env".path | |
| ]; | |
| } | |
| ``` | |
| The secrets file contains key-value pairs: | |
| ```yaml | |
| # secrets/hermes.yaml (encrypted with sops) | |
| hermes-env: | | |
| OPENROUTER_API_KEY=sk-or-... | |
| TELEGRAM_BOT_TOKEN=123456:ABC... | |
| ANTHROPIC_API_KEY=sk-ant-... | |
| ``` | |
| ### agenix | |
| ```nix | |
| { | |
| age.secrets.hermes-env.file = ./secrets/hermes-env.age; | |
| services.hermes-agent.environmentFiles = [ | |
| config.age.secrets.hermes-env.path | |
| ]; | |
| } | |
| ``` | |
| ### OAuth / Auth Seeding | |
| For platforms requiring OAuth (e.g., Discord), use `authFile` to seed credentials on first deploy: | |
| ```nix | |
| { | |
| services.hermes-agent = { | |
| authFile = config.sops.secrets."hermes/auth.json".path; | |
| # authFileForceOverwrite = true; # overwrite on every activation | |
| }; | |
| } | |
| ``` | |
| The file is only copied if `auth.json` doesn't already exist (unless `authFileForceOverwrite = true`). Runtime OAuth token refreshes are written to the state directory and preserved across rebuilds. | |
| --- | |
| ## Documents | |
| The `documents` option installs files into the agent's working directory (the `workingDirectory`, which the agent reads as its workspace). Hermes looks for specific filenames by convention: | |
| - **`SOUL.md`** β the agent's system prompt / personality. Hermes reads this on startup and uses it as persistent instructions that shape its behavior across all conversations. | |
| - **`USER.md`** β context about the user the agent is interacting with. | |
| - Any other files you place here are visible to the agent as workspace files. | |
| ```nix | |
| { | |
| services.hermes-agent.documents = { | |
| "SOUL.md" = '' | |
| You are a helpful research assistant specializing in NixOS packaging. | |
| Always cite sources and prefer reproducible solutions. | |
| ''; | |
| "USER.md" = ./documents/USER.md; # path reference, copied from Nix store | |
| }; | |
| } | |
| ``` | |
| Values can be inline strings or path references. Files are installed on every `nixos-rebuild switch`. | |
| --- | |
| ## MCP Servers | |
| The `mcpServers` option declaratively configures [MCP (Model Context Protocol)](https://modelcontextprotocol.io) servers. Each server uses either **stdio** (local command) or **HTTP** (remote URL) transport. | |
| ### Stdio Transport (Local Servers) | |
| ```nix | |
| { | |
| services.hermes-agent.mcpServers = { | |
| filesystem = { | |
| command = "npx"; | |
| args = [ "-y" "@modelcontextprotocol/server-filesystem" "/data/workspace" ]; | |
| }; | |
| github = { | |
| command = "npx"; | |
| args = [ "-y" "@modelcontextprotocol/server-github" ]; | |
| env.GITHUB_PERSONAL_ACCESS_TOKEN = "\${GITHUB_TOKEN}"; # resolved from .env | |
| }; | |
| }; | |
| } | |
| ``` | |
| :::tip | |
| Environment variables in `env` values are resolved from `$HERMES_HOME/.env` at runtime. Use `environmentFiles` to inject secrets β never put tokens directly in Nix config. | |
| ::: | |
| ### HTTP Transport (Remote Servers) | |
| ```nix | |
| { | |
| services.hermes-agent.mcpServers.remote-api = { | |
| url = "https://mcp.example.com/v1/mcp"; | |
| headers.Authorization = "Bearer \${MCP_REMOTE_API_KEY}"; | |
| timeout = 180; | |
| }; | |
| } | |
| ``` | |
| ### HTTP Transport with OAuth | |
| Set `auth = "oauth"` for servers using OAuth 2.1. Hermes implements the full PKCE flow β metadata discovery, dynamic client registration, token exchange, and automatic refresh. | |
| ```nix | |
| { | |
| services.hermes-agent.mcpServers.my-oauth-server = { | |
| url = "https://mcp.example.com/mcp"; | |
| auth = "oauth"; | |
| }; | |
| } | |
| ``` | |
| Tokens are stored in `$HERMES_HOME/mcp-tokens/<server-name>.json` and persist across restarts and rebuilds. | |
| <details> | |
| <summary><strong>Initial OAuth authorization on headless servers</strong></summary> | |
| The first OAuth authorization requires a browser-based consent flow. In a headless deployment, Hermes prints the authorization URL to stdout/logs instead of opening a browser. | |
| **Option A: Interactive bootstrap** β run the flow once via `docker exec` (container) or `sudo -u hermes` (native): | |
| ```bash | |
| # Container mode | |
| docker exec -it hermes-agent \ | |
| hermes mcp add my-oauth-server --url https://mcp.example.com/mcp --auth oauth | |
| # Native mode | |
| sudo -u hermes HERMES_HOME=/var/lib/hermes/.hermes \ | |
| hermes mcp add my-oauth-server --url https://mcp.example.com/mcp --auth oauth | |
| ``` | |
| The container uses `--network=host`, so the OAuth callback listener on `127.0.0.1` is reachable from the host browser. | |
| **Option B: Pre-seed tokens** β complete the flow on a workstation, then copy tokens: | |
| ```bash | |
| hermes mcp add my-oauth-server --url https://mcp.example.com/mcp --auth oauth | |
| scp ~/.hermes/mcp-tokens/my-oauth-server{,.client}.json \ | |
| server:/var/lib/hermes/.hermes/mcp-tokens/ | |
| # Ensure: chown hermes:hermes, chmod 0600 | |
| ``` | |
| </details> | |
| ### Sampling (Server-Initiated LLM Requests) | |
| Some MCP servers can request LLM completions from the agent: | |
| ```nix | |
| { | |
| services.hermes-agent.mcpServers.analysis = { | |
| command = "npx"; | |
| args = [ "-y" "analysis-server" ]; | |
| sampling = { | |
| enabled = true; | |
| model = "google/gemini-3-flash"; | |
| max_tokens_cap = 4096; | |
| timeout = 30; | |
| max_rpm = 10; | |
| }; | |
| }; | |
| } | |
| ``` | |
| --- | |
| ## Managed Mode | |
| When hermes runs via the NixOS module, the following CLI commands are **blocked** with a descriptive error pointing you to `configuration.nix`: | |
| | Blocked command | Why | | |
| |---|---| | |
| | `hermes setup` | Config is declarative β edit `settings` in your Nix config | | |
| | `hermes config edit` | Config is generated from `settings` | | |
| | `hermes config set <key> <value>` | Config is generated from `settings` | | |
| | `hermes gateway install` | The systemd service is managed by NixOS | | |
| | `hermes gateway uninstall` | The systemd service is managed by NixOS | | |
| This prevents drift between what Nix declares and what's on disk. Detection uses two signals: | |
| 1. **`HERMES_MANAGED=true`** environment variable β set by the systemd service, visible to the gateway process | |
| 2. **`.managed` marker file** in `HERMES_HOME` β set by the activation script, visible to interactive shells (e.g., `docker exec -it hermes-agent hermes config set ...` is also blocked) | |
| To change configuration, edit your Nix config and run `sudo nixos-rebuild switch`. | |
| --- | |
| ## Container Architecture | |
| :::info | |
| This section is only relevant if you're using `container.enable = true`. Skip it for native mode deployments. | |
| ::: | |
| When container mode is enabled, hermes runs inside a persistent Ubuntu container with the Nix-built binary bind-mounted read-only from the host: | |
| ``` | |
| Host Container | |
| ββββ βββββββββ | |
| /nix/store/...-hermes-agent-0.1.0 βββΊ /nix/store/... (ro) | |
| ~/.hermes -> /var/lib/hermes/.hermes (symlink bridge, per hostUsers) | |
| /var/lib/hermes/ βββΊ /data/ (rw) | |
| βββ current-package -> /nix/store/... (symlink, updated each rebuild) | |
| βββ .gc-root -> /nix/store/... (prevents nix-collect-garbage) | |
| βββ .container-identity (sha256 hash, triggers recreation) | |
| βββ .hermes/ (HERMES_HOME) | |
| β βββ .env (merged from environment + environmentFiles) | |
| β βββ config.yaml (Nix-generated, deep-merged by activation) | |
| β βββ .managed (marker file) | |
| β βββ .container-mode (routing metadata: backend, exec_user, etc.) | |
| β βββ state.db, sessions/, memories/ (runtime state) | |
| β βββ mcp-tokens/ (OAuth tokens for MCP servers) | |
| βββ home/ βββΊ /home/hermes (rw) | |
| βββ workspace/ (MESSAGING_CWD) | |
| βββ SOUL.md (from documents option) | |
| βββ (agent-created files) | |
| Container writable layer (apt/pip/npm): /usr, /usr/local, /tmp | |
| ``` | |
| The Nix-built binary works inside the Ubuntu container because `/nix/store` is bind-mounted β it brings its own interpreter and all dependencies, so there's no reliance on the container's system libraries. The container entrypoint resolves through a `current-package` symlink: `/data/current-package/bin/hermes gateway run --replace`. On `nixos-rebuild switch`, only the symlink is updated β the container keeps running. | |
| ### What Persists Across What | |
| | Event | Container recreated? | `/data` (state) | `/home/hermes` | Writable layer (`apt`/`pip`/`npm`) | | |
| |---|---|---|---|---| | |
| | `systemctl restart hermes-agent` | No | Persists | Persists | Persists | | |
| | `nixos-rebuild switch` (code change) | No (symlink updated) | Persists | Persists | Persists | | |
| | Host reboot | No | Persists | Persists | Persists | | |
| | `nix-collect-garbage` | No (GC root) | Persists | Persists | Persists | | |
| | Image change (`container.image`) | **Yes** | Persists | Persists | **Lost** | | |
| | Volume/options change | **Yes** | Persists | Persists | **Lost** | | |
| | `environment`/`environmentFiles` change | No | Persists | Persists | Persists | | |
| The container is only recreated when its **identity hash** changes. The hash covers: schema version, image, `extraVolumes`, `extraOptions`, and the entrypoint script. Changes to environment variables, settings, documents, or the hermes package itself do **not** trigger recreation. | |
| :::warning Writable layer loss | |
| When the identity hash changes (image upgrade, new volumes, new container options), the container is destroyed and recreated from a fresh pull of `container.image`. Any `apt install`, `pip install`, or `npm install` packages in the writable layer are lost. State in `/data` and `/home/hermes` is preserved (these are bind mounts). | |
| If the agent relies on specific packages, consider baking them into a custom image (`container.image = "my-registry/hermes-base:latest"`) or scripting their installation in the agent's SOUL.md. | |
| ::: | |
| ### GC Root Protection | |
| The `preStart` script creates a GC root at `${stateDir}/.gc-root` pointing to the current hermes package. This prevents `nix-collect-garbage` from removing the running binary. If the GC root somehow breaks, restarting the service recreates it. | |
| --- | |
| ## Development | |
| ### Dev Shell | |
| The flake provides a development shell with Python 3.11, uv, Node.js, and all runtime tools: | |
| ```bash | |
| cd hermes-agent | |
| nix develop | |
| # Shell provides: | |
| # - Python 3.11 + uv (deps installed into .venv on first entry) | |
| # - Node.js 20, ripgrep, git, openssh, ffmpeg on PATH | |
| # - Stamp-file optimization: re-entry is near-instant if deps haven't changed | |
| hermes setup | |
| hermes chat | |
| ``` | |
| ### direnv (Recommended) | |
| The included `.envrc` activates the dev shell automatically: | |
| ```bash | |
| cd hermes-agent | |
| direnv allow # one-time | |
| # Subsequent entries are near-instant (stamp file skips dep install) | |
| ``` | |
| ### Flake Checks | |
| The flake includes build-time verification that runs in CI and locally: | |
| ```bash | |
| # Run all checks | |
| nix flake check | |
| # Individual checks | |
| nix build .#checks.x86_64-linux.package-contents # binaries exist + version | |
| nix build .#checks.x86_64-linux.entry-points-sync # pyproject.toml β Nix package sync | |
| nix build .#checks.x86_64-linux.cli-commands # gateway/config subcommands | |
| nix build .#checks.x86_64-linux.managed-guard # HERMES_MANAGED blocks mutation | |
| nix build .#checks.x86_64-linux.bundled-skills # skills present in package | |
| nix build .#checks.x86_64-linux.config-roundtrip # merge script preserves user keys | |
| ``` | |
| <details> | |
| <summary><strong>What each check verifies</strong></summary> | |
| | Check | What it tests | | |
| |---|---| | |
| | `package-contents` | `hermes` and `hermes-agent` binaries exist and `hermes version` runs | | |
| | `entry-points-sync` | Every `[project.scripts]` entry in `pyproject.toml` has a wrapped binary in the Nix package | | |
| | `cli-commands` | `hermes --help` exposes `gateway` and `config` subcommands | | |
| | `managed-guard` | `HERMES_MANAGED=true hermes config set ...` prints the NixOS error | | |
| | `bundled-skills` | Skills directory exists, contains SKILL.md files, `HERMES_BUNDLED_SKILLS` is set in wrapper | | |
| | `config-roundtrip` | 7 merge scenarios: fresh install, Nix override, user key preservation, mixed merge, MCP additive merge, nested deep merge, idempotency | | |
| </details> | |
| --- | |
| ## Options Reference | |
| ### Core | |
| | Option | Type | Default | Description | | |
| |---|---|---|---| | |
| | `enable` | `bool` | `false` | Enable the hermes-agent service | | |
| | `package` | `package` | `hermes-agent` | The hermes-agent package to use | | |
| | `user` | `str` | `"hermes"` | System user | | |
| | `group` | `str` | `"hermes"` | System group | | |
| | `createUser` | `bool` | `true` | Auto-create user/group | | |
| | `stateDir` | `str` | `"/var/lib/hermes"` | State directory (`HERMES_HOME` parent) | | |
| | `workingDirectory` | `str` | `"${stateDir}/workspace"` | Agent working directory (`MESSAGING_CWD`) | | |
| | `addToSystemPackages` | `bool` | `false` | Add `hermes` CLI to system PATH and set `HERMES_HOME` system-wide | | |
| ### Configuration | |
| | Option | Type | Default | Description | | |
| |---|---|---|---| | |
| | `settings` | `attrs` (deep-merged) | `{}` | Declarative config rendered as `config.yaml`. Supports arbitrary nesting; multiple definitions are merged via `lib.recursiveUpdate` | | |
| | `configFile` | `null` or `path` | `null` | Path to an existing `config.yaml`. Overrides `settings` entirely if set | | |
| ### Secrets & Environment | |
| | Option | Type | Default | Description | | |
| |---|---|---|---| | |
| | `environmentFiles` | `listOf str` | `[]` | Paths to env files with secrets. Merged into `$HERMES_HOME/.env` at activation time | | |
| | `environment` | `attrsOf str` | `{}` | Non-secret env vars. **Visible in Nix store** β do not put secrets here | | |
| | `authFile` | `null` or `path` | `null` | OAuth credentials seed. Only copied on first deploy | | |
| | `authFileForceOverwrite` | `bool` | `false` | Always overwrite `auth.json` from `authFile` on activation | | |
| ### Documents | |
| | Option | Type | Default | Description | | |
| |---|---|---|---| | |
| | `documents` | `attrsOf (either str path)` | `{}` | Workspace files. Keys are filenames, values are inline strings or paths. Installed into `workingDirectory` on activation | | |
| ### MCP Servers | |
| | Option | Type | Default | Description | | |
| |---|---|---|---| | |
| | `mcpServers` | `attrsOf submodule` | `{}` | MCP server definitions, merged into `settings.mcp_servers` | | |
| | `mcpServers.<name>.command` | `null` or `str` | `null` | Server command (stdio transport) | | |
| | `mcpServers.<name>.args` | `listOf str` | `[]` | Command arguments | | |
| | `mcpServers.<name>.env` | `attrsOf str` | `{}` | Environment variables for the server process | | |
| | `mcpServers.<name>.url` | `null` or `str` | `null` | Server endpoint URL (HTTP/StreamableHTTP transport) | | |
| | `mcpServers.<name>.headers` | `attrsOf str` | `{}` | HTTP headers, e.g. `Authorization` | | |
| | `mcpServers.<name>.auth` | `null` or `"oauth"` | `null` | Authentication method. `"oauth"` enables OAuth 2.1 PKCE | | |
| | `mcpServers.<name>.enabled` | `bool` | `true` | Enable or disable this server | | |
| | `mcpServers.<name>.timeout` | `null` or `int` | `null` | Tool call timeout in seconds (default: 120) | | |
| | `mcpServers.<name>.connect_timeout` | `null` or `int` | `null` | Connection timeout in seconds (default: 60) | | |
| | `mcpServers.<name>.tools` | `null` or `submodule` | `null` | Tool filtering (`include`/`exclude` lists) | | |
| | `mcpServers.<name>.sampling` | `null` or `submodule` | `null` | Sampling config for server-initiated LLM requests | | |
| ### Service Behavior | |
| | Option | Type | Default | Description | | |
| |---|---|---|---| | |
| | `extraArgs` | `listOf str` | `[]` | Extra args for `hermes gateway` | | |
| | `extraPackages` | `listOf package` | `[]` | Extra packages on service PATH (native mode only) | | |
| | `restart` | `str` | `"always"` | systemd `Restart=` policy | | |
| | `restartSec` | `int` | `5` | systemd `RestartSec=` value | | |
| ### Container | |
| | Option | Type | Default | Description | | |
| |---|---|---|---| | |
| | `container.enable` | `bool` | `false` | Enable OCI container mode | | |
| | `container.backend` | `enum ["docker" "podman"]` | `"docker"` | Container runtime | | |
| | `container.image` | `str` | `"ubuntu:24.04"` | Base image (pulled at runtime) | | |
| | `container.extraVolumes` | `listOf str` | `[]` | Extra volume mounts (`host:container:mode`) | | |
| | `container.extraOptions` | `listOf str` | `[]` | Extra args passed to `docker create` | | |
| | `container.hostUsers` | `listOf str` | `[]` | Interactive users who get a `~/.hermes` symlink to the service stateDir and are auto-added to the `hermes` group | | |
| --- | |
| ## Directory Layout | |
| ### Native Mode | |
| ``` | |
| /var/lib/hermes/ # stateDir (owned by hermes:hermes, 0750) | |
| βββ .hermes/ # HERMES_HOME | |
| β βββ config.yaml # Nix-generated (deep-merged each rebuild) | |
| β βββ .managed # Marker: CLI config mutation blocked | |
| β βββ .env # Merged from environment + environmentFiles | |
| β βββ auth.json # OAuth credentials (seeded, then self-managed) | |
| β βββ gateway.pid | |
| β βββ state.db | |
| β βββ mcp-tokens/ # OAuth tokens for MCP servers | |
| β βββ sessions/ | |
| β βββ memories/ | |
| β βββ skills/ | |
| β βββ cron/ | |
| β βββ logs/ | |
| βββ home/ # Agent HOME | |
| βββ workspace/ # MESSAGING_CWD | |
| βββ SOUL.md # From documents option | |
| βββ (agent-created files) | |
| ``` | |
| ### Container Mode | |
| Same layout, mounted into the container: | |
| | Container path | Host path | Mode | Notes | | |
| |---|---|---|---| | |
| | `/nix/store` | `/nix/store` | `ro` | Hermes binary + all Nix deps | | |
| | `/data` | `/var/lib/hermes` | `rw` | All state, config, workspace | | |
| | `/home/hermes` | `${stateDir}/home` | `rw` | Persistent agent home β `pip install --user`, tool caches | | |
| | `/usr`, `/usr/local`, `/tmp` | (writable layer) | `rw` | `apt`/`pip`/`npm` installs β persists across restarts, lost on recreation | | |
| --- | |
| ## Updating | |
| ```bash | |
| # Update the flake input | |
| nix flake update hermes-agent --flake /etc/nixos | |
| # Rebuild | |
| sudo nixos-rebuild switch | |
| ``` | |
| In container mode, the `current-package` symlink is updated and the agent picks up the new binary on restart. No container recreation, no loss of installed packages. | |
| --- | |
| ## Troubleshooting | |
| :::tip Podman users | |
| All `docker` commands below work the same with `podman`. Substitute accordingly if you set `container.backend = "podman"`. | |
| ::: | |
| ### Service Logs | |
| ```bash | |
| # Both modes use the same systemd unit | |
| journalctl -u hermes-agent -f | |
| # Container mode: also available directly | |
| docker logs -f hermes-agent | |
| ``` | |
| ### Container Inspection | |
| ```bash | |
| systemctl status hermes-agent | |
| docker ps -a --filter name=hermes-agent | |
| docker inspect hermes-agent --format='{{.State.Status}}' | |
| docker exec -it hermes-agent bash | |
| docker exec hermes-agent readlink /data/current-package | |
| docker exec hermes-agent cat /data/.container-identity | |
| ``` | |
| ### Force Container Recreation | |
| If you need to reset the writable layer (fresh Ubuntu): | |
| ```bash | |
| sudo systemctl stop hermes-agent | |
| docker rm -f hermes-agent | |
| sudo rm /var/lib/hermes/.container-identity | |
| sudo systemctl start hermes-agent | |
| ``` | |
| ### Verify Secrets Are Loaded | |
| If the agent starts but can't authenticate with the LLM provider, check that the `.env` file was merged correctly: | |
| ```bash | |
| # Native mode | |
| sudo -u hermes cat /var/lib/hermes/.hermes/.env | |
| # Container mode | |
| docker exec hermes-agent cat /data/.hermes/.env | |
| ``` | |
| ### GC Root Verification | |
| ```bash | |
| nix-store --query --roots $(docker exec hermes-agent readlink /data/current-package) | |
| ``` | |
| ### Common Issues | |
| | Symptom | Cause | Fix | | |
| |---|---|---| | |
| | `Cannot save configuration: managed by NixOS` | CLI guards active | Edit `configuration.nix` and `nixos-rebuild switch` | | |
| | Container recreated unexpectedly | `extraVolumes`, `extraOptions`, or `image` changed | Expected β writable layer resets. Reinstall packages or use a custom image | | |
| | `hermes version` shows old version | Container not restarted | `systemctl restart hermes-agent` | | |
| | Permission denied on `/var/lib/hermes` | State dir is `0750 hermes:hermes` | Use `docker exec` or `sudo -u hermes` | | |
| | `nix-collect-garbage` removed hermes | GC root missing | Restart the service (preStart recreates the GC root) | | |
| | `no container with name or ID "hermes-agent"` (Podman) | Podman rootful container not visible to regular user | Add passwordless sudo for podman (see [Container-aware CLI](#container-aware-cli) section) | | |
| | `unable to find user hermes` | Container still starting (entrypoint hasn't created user yet) | Wait a few seconds and retry β the CLI retries automatically | | |