diff --git a/.envrc b/.envrc
new file mode 100644
index 00000000..3550a30f
--- /dev/null
+++ b/.envrc
@@ -0,0 +1 @@
+use flake
diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml
new file mode 100644
index 00000000..004f8236
--- /dev/null
+++ b/.github/workflows/nix.yml
@@ -0,0 +1,40 @@
+name: Nix
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ paths:
+ - 'flake.nix'
+ - 'flake.lock'
+ - 'nix/**'
+ - 'pyproject.toml'
+ - 'uv.lock'
+ - 'hermes_cli/**'
+ - 'run_agent.py'
+ - 'acp_adapter/**'
+
+concurrency:
+ group: nix-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ nix:
+ strategy:
+ matrix:
+ os: [ubuntu-latest, macos-latest]
+ runs-on: ${{ matrix.os }}
+ timeout-minutes: 30
+ steps:
+ - uses: actions/checkout@v4
+ - uses: DeterminateSystems/nix-installer-action@main
+ - uses: DeterminateSystems/magic-nix-cache-action@main
+ - name: Check flake
+ if: runner.os == 'Linux'
+ run: nix flake check --print-build-logs
+ - name: Build package
+ if: runner.os == 'Linux'
+ run: nix build --print-build-logs
+ - name: Evaluate flake (macOS)
+ if: runner.os == 'macOS'
+ run: nix flake show --json > /dev/null
diff --git a/.gitignore b/.gitignore
index 77ca54f3..baa31a54 100644
--- a/.gitignore
+++ b/.gitignore
@@ -54,3 +54,7 @@ environments/benchmarks/evals/
# Release script temp files
.release_notes.md
mini-swe-agent/
+
+# Nix
+.direnv/
+result
diff --git a/flake.lock b/flake.lock
new file mode 100644
index 00000000..628e492f
--- /dev/null
+++ b/flake.lock
@@ -0,0 +1,181 @@
+{
+ "nodes": {
+ "flake-parts": {
+ "inputs": {
+ "nixpkgs-lib": [
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1772408722,
+ "narHash": "sha256-rHuJtdcOjK7rAHpHphUb1iCvgkU3GpfvicLMwwnfMT0=",
+ "owner": "hercules-ci",
+ "repo": "flake-parts",
+ "rev": "f20dc5d9b8027381c474144ecabc9034d6a839a3",
+ "type": "github"
+ },
+ "original": {
+ "owner": "hercules-ci",
+ "repo": "flake-parts",
+ "type": "github"
+ }
+ },
+ "nixpkgs": {
+ "locked": {
+ "lastModified": 1751274312,
+ "narHash": "sha256-/bVBlRpECLVzjV19t5KMdMFWSwKLtb5RyXdjz3LJT+g=",
+ "owner": "NixOS",
+ "repo": "nixpkgs",
+ "rev": "50ab793786d9de88ee30ec4e4c24fb4236fc2674",
+ "type": "github"
+ },
+ "original": {
+ "owner": "NixOS",
+ "ref": "nixos-24.11",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "pyproject-build-systems": {
+ "inputs": {
+ "nixpkgs": [
+ "nixpkgs"
+ ],
+ "pyproject-nix": "pyproject-nix",
+ "uv2nix": "uv2nix"
+ },
+ "locked": {
+ "lastModified": 1772555609,
+ "narHash": "sha256-3BA3HnUvJSbHJAlJj6XSy0Jmu7RyP2gyB/0fL7XuEDo=",
+ "owner": "pyproject-nix",
+ "repo": "build-system-pkgs",
+ "rev": "c37f66a953535c394244888598947679af231863",
+ "type": "github"
+ },
+ "original": {
+ "owner": "pyproject-nix",
+ "repo": "build-system-pkgs",
+ "type": "github"
+ }
+ },
+ "pyproject-nix": {
+ "inputs": {
+ "nixpkgs": [
+ "pyproject-build-systems",
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1769936401,
+ "narHash": "sha256-kwCOegKLZJM9v/e/7cqwg1p/YjjTAukKPqmxKnAZRgA=",
+ "owner": "nix-community",
+ "repo": "pyproject.nix",
+ "rev": "b0d513eeeebed6d45b4f2e874f9afba2021f7812",
+ "type": "github"
+ },
+ "original": {
+ "owner": "nix-community",
+ "repo": "pyproject.nix",
+ "type": "github"
+ }
+ },
+ "pyproject-nix_2": {
+ "inputs": {
+ "nixpkgs": [
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1772865871,
+ "narHash": "sha256-/ZTSg97aouL0SlPHaokA4r3iuH9QzHVuWPACD2CUCFY=",
+ "owner": "pyproject-nix",
+ "repo": "pyproject.nix",
+ "rev": "e537db02e72d553cea470976b9733581bcf5b3ed",
+ "type": "github"
+ },
+ "original": {
+ "owner": "pyproject-nix",
+ "repo": "pyproject.nix",
+ "type": "github"
+ }
+ },
+ "pyproject-nix_3": {
+ "inputs": {
+ "nixpkgs": [
+ "uv2nix",
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1771518446,
+ "narHash": "sha256-nFJSfD89vWTu92KyuJWDoTQJuoDuddkJV3TlOl1cOic=",
+ "owner": "pyproject-nix",
+ "repo": "pyproject.nix",
+ "rev": "eb204c6b3335698dec6c7fc1da0ebc3c6df05937",
+ "type": "github"
+ },
+ "original": {
+ "owner": "pyproject-nix",
+ "repo": "pyproject.nix",
+ "type": "github"
+ }
+ },
+ "root": {
+ "inputs": {
+ "flake-parts": "flake-parts",
+ "nixpkgs": "nixpkgs",
+ "pyproject-build-systems": "pyproject-build-systems",
+ "pyproject-nix": "pyproject-nix_2",
+ "uv2nix": "uv2nix_2"
+ }
+ },
+ "uv2nix": {
+ "inputs": {
+ "nixpkgs": [
+ "pyproject-build-systems",
+ "nixpkgs"
+ ],
+ "pyproject-nix": [
+ "pyproject-build-systems",
+ "pyproject-nix"
+ ]
+ },
+ "locked": {
+ "lastModified": 1770770348,
+ "narHash": "sha256-A2GzkmzdYvdgmMEu5yxW+xhossP+txrYb7RuzRaqhlg=",
+ "owner": "pyproject-nix",
+ "repo": "uv2nix",
+ "rev": "5d1b2cb4fe3158043fbafbbe2e46238abbc954b0",
+ "type": "github"
+ },
+ "original": {
+ "owner": "pyproject-nix",
+ "repo": "uv2nix",
+ "type": "github"
+ }
+ },
+ "uv2nix_2": {
+ "inputs": {
+ "nixpkgs": [
+ "nixpkgs"
+ ],
+ "pyproject-nix": "pyproject-nix_3"
+ },
+ "locked": {
+ "lastModified": 1773039484,
+ "narHash": "sha256-+boo33KYkJDw9KItpeEXXv8+65f7hHv/earxpcyzQ0I=",
+ "owner": "pyproject-nix",
+ "repo": "uv2nix",
+ "rev": "b68be7cfeacbed9a3fa38a2b5adc0cfb81d9bb1f",
+ "type": "github"
+ },
+ "original": {
+ "owner": "pyproject-nix",
+ "repo": "uv2nix",
+ "type": "github"
+ }
+ }
+ },
+ "root": "root",
+ "version": 7
+}
diff --git a/flake.nix b/flake.nix
new file mode 100644
index 00000000..87be89c8
--- /dev/null
+++ b/flake.nix
@@ -0,0 +1,35 @@
+{
+ description = "Hermes Agent - AI agent framework by Nous Research";
+
+ inputs = {
+ nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
+ flake-parts = {
+ url = "github:hercules-ci/flake-parts";
+ inputs.nixpkgs-lib.follows = "nixpkgs";
+ };
+ pyproject-nix = {
+ url = "github:pyproject-nix/pyproject.nix";
+ inputs.nixpkgs.follows = "nixpkgs";
+ };
+ uv2nix = {
+ url = "github:pyproject-nix/uv2nix";
+ inputs.nixpkgs.follows = "nixpkgs";
+ };
+ pyproject-build-systems = {
+ url = "github:pyproject-nix/build-system-pkgs";
+ inputs.nixpkgs.follows = "nixpkgs";
+ };
+ };
+
+ outputs = inputs:
+ inputs.flake-parts.lib.mkFlake { inherit inputs; } {
+ systems = [ "x86_64-linux" "aarch64-linux" "aarch64-darwin" ];
+
+ imports = [
+ ./nix/packages.nix
+ ./nix/nixosModules.nix
+ ./nix/checks.nix
+ ./nix/devShell.nix
+ ];
+ };
+}
diff --git a/hermes_cli/config.py b/hermes_cli/config.py
index f3c6073a..d73edc6a 100644
--- a/hermes_cli/config.py
+++ b/hermes_cli/config.py
@@ -46,6 +46,32 @@ from hermes_cli.colors import Colors, color
from hermes_cli.default_soul import DEFAULT_SOUL_MD
+# =============================================================================
+# Managed mode (NixOS declarative config)
+# =============================================================================
+
+def is_managed() -> bool:
+ """Check if hermes is running in Nix-managed mode.
+
+ Two signals: the HERMES_MANAGED env var (set by the systemd service),
+ or a .managed marker file in HERMES_HOME (set by the NixOS activation
+ script, so interactive shells also see it).
+ """
+ if os.getenv("HERMES_MANAGED", "").lower() in ("true", "1", "yes"):
+ return True
+ managed_marker = Path(os.getenv("HERMES_HOME", str(Path.home() / ".hermes"))) / ".managed"
+ return managed_marker.exists()
+
+def managed_error(action: str = "modify configuration"):
+ """Print user-friendly error for managed mode."""
+ print(
+ f"Cannot {action}: configuration is managed by NixOS (HERMES_MANAGED=true).\n"
+ "Edit services.hermes-agent.settings in your configuration.nix and run:\n"
+ " sudo nixos-rebuild switch",
+ file=sys.stderr,
+ )
+
+
# =============================================================================
# Config paths
# =============================================================================
@@ -1342,6 +1368,9 @@ _COMMENTED_SECTIONS = """
def save_config(config: Dict[str, Any]):
"""Save configuration to ~/.hermes/config.yaml."""
+ if is_managed():
+ managed_error("save configuration")
+ return
from utils import atomic_yaml_write
ensure_hermes_home()
@@ -1483,6 +1512,9 @@ def sanitize_env_file() -> int:
def save_env_value(key: str, value: str):
"""Save or update a value in ~/.hermes/.env."""
+ if is_managed():
+ managed_error(f"set {key}")
+ return
if not _ENV_VAR_NAME_RE.match(key):
raise ValueError(f"Invalid environment variable name: {key!r}")
value = value.replace("\n", "").replace("\r", "")
@@ -1739,6 +1771,9 @@ def show_config():
def edit_config():
"""Open config file in user's editor."""
+ if is_managed():
+ managed_error("edit configuration")
+ return
config_path = get_config_path()
# Ensure config exists
@@ -1768,6 +1803,9 @@ def edit_config():
def set_config_value(key: str, value: str):
"""Set a configuration value."""
+ if is_managed():
+ managed_error("set configuration values")
+ return
# Check if it's an API key (goes to .env)
api_keys = [
'OPENROUTER_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'VOICE_TOOLS_OPENAI_KEY',
diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py
index b156c75e..d1d79867 100644
--- a/hermes_cli/gateway.py
+++ b/hermes_cli/gateway.py
@@ -14,7 +14,7 @@ from pathlib import Path
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
-from hermes_cli.config import get_env_value, get_hermes_home, save_env_value
+from hermes_cli.config import get_env_value, get_hermes_home, save_env_value, is_managed, managed_error
from hermes_cli.setup import (
print_header, print_info, print_success, print_warning, print_error,
prompt, prompt_choice, prompt_yes_no,
@@ -1562,6 +1562,9 @@ def _setup_signal():
def gateway_setup():
"""Interactive setup for messaging platforms + gateway service."""
+ if is_managed():
+ managed_error("run gateway setup")
+ return
print()
print(color("┌─────────────────────────────────────────────────────────┐", Colors.MAGENTA))
@@ -1716,6 +1719,9 @@ def gateway_command(args):
# Service management commands
if subcmd == "install":
+ if is_managed():
+ managed_error("install gateway service (managed by NixOS)")
+ return
force = getattr(args, 'force', False)
system = getattr(args, 'system', False)
run_as_user = getattr(args, 'run_as_user', None)
@@ -1729,6 +1735,9 @@ def gateway_command(args):
sys.exit(1)
elif subcmd == "uninstall":
+ if is_managed():
+ managed_error("uninstall gateway service (managed by NixOS)")
+ return
system = getattr(args, 'system', False)
if is_linux():
systemd_uninstall(system=system)
diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py
index ff460ebc..ddb86529 100644
--- a/hermes_cli/setup.py
+++ b/hermes_cli/setup.py
@@ -3106,6 +3106,10 @@ def run_setup_wizard(args):
hermes setup tools — just tool configuration
hermes setup agent — just agent settings
"""
+ from hermes_cli.config import is_managed, managed_error
+ if is_managed():
+ managed_error("run setup wizard")
+ return
ensure_hermes_home()
config = load_config()
diff --git a/nix/checks.nix b/nix/checks.nix
new file mode 100644
index 00000000..4af5d552
--- /dev/null
+++ b/nix/checks.nix
@@ -0,0 +1,376 @@
+# nix/checks.nix — Build-time verification tests
+#
+# Checks are Linux-only: the full Python venv (via uv2nix) includes
+# transitive deps like onnxruntime that lack compatible wheels on
+# aarch64-darwin. The package and devShell still work on macOS.
+{ inputs, ... }: {
+ perSystem = { pkgs, system, lib, ... }:
+ let
+ hermes-agent = inputs.self.packages.${system}.default;
+ hermesVenv = pkgs.callPackage ./python.nix {
+ inherit (inputs) uv2nix pyproject-nix pyproject-build-systems;
+ };
+
+ configMergeScript = pkgs.callPackage ./configMergeScript.nix { };
+ in {
+ checks = lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux {
+ # Verify binaries exist and are executable
+ package-contents = pkgs.runCommand "hermes-package-contents" { } ''
+ set -e
+ echo "=== Checking binaries ==="
+ test -x ${hermes-agent}/bin/hermes || (echo "FAIL: hermes binary missing"; exit 1)
+ test -x ${hermes-agent}/bin/hermes-agent || (echo "FAIL: hermes-agent binary missing"; exit 1)
+ echo "PASS: All binaries present"
+
+ echo "=== Checking version ==="
+ ${hermes-agent}/bin/hermes version 2>&1 | grep -qi "hermes" || (echo "FAIL: version check"; exit 1)
+ echo "PASS: Version check"
+
+ echo "=== All checks passed ==="
+ mkdir -p $out
+ echo "ok" > $out/result
+ '';
+
+ # Verify every pyproject.toml [project.scripts] entry has a wrapped binary
+ entry-points-sync = pkgs.runCommand "hermes-entry-points-sync" { } ''
+ set -e
+ echo "=== Checking entry points match pyproject.toml [project.scripts] ==="
+ for bin in hermes hermes-agent hermes-acp; do
+ test -x ${hermes-agent}/bin/$bin || (echo "FAIL: $bin binary missing from Nix package"; exit 1)
+ echo "PASS: $bin present"
+ done
+
+ mkdir -p $out
+ echo "ok" > $out/result
+ '';
+
+ # Verify CLI subcommands are accessible
+ cli-commands = pkgs.runCommand "hermes-cli-commands" { } ''
+ set -e
+ export HOME=$(mktemp -d)
+
+ echo "=== Checking hermes --help ==="
+ ${hermes-agent}/bin/hermes --help 2>&1 | grep -q "gateway" || (echo "FAIL: gateway subcommand missing"; exit 1)
+ ${hermes-agent}/bin/hermes --help 2>&1 | grep -q "config" || (echo "FAIL: config subcommand missing"; exit 1)
+ echo "PASS: All subcommands accessible"
+
+ echo "=== All CLI checks passed ==="
+ mkdir -p $out
+ echo "ok" > $out/result
+ '';
+
+ # Verify bundled skills are present in the package
+ bundled-skills = pkgs.runCommand "hermes-bundled-skills" { } ''
+ set -e
+ echo "=== Checking bundled skills ==="
+ test -d ${hermes-agent}/share/hermes-agent/skills || (echo "FAIL: skills directory missing"; exit 1)
+ echo "PASS: skills directory exists"
+
+ SKILL_COUNT=$(find ${hermes-agent}/share/hermes-agent/skills -name "SKILL.md" | wc -l)
+ test "$SKILL_COUNT" -gt 0 || (echo "FAIL: no SKILL.md files found in skills directory"; exit 1)
+ echo "PASS: $SKILL_COUNT bundled skills found"
+
+ grep -q "HERMES_BUNDLED_SKILLS" ${hermes-agent}/bin/hermes || \
+ (echo "FAIL: HERMES_BUNDLED_SKILLS not in wrapper"; exit 1)
+ echo "PASS: HERMES_BUNDLED_SKILLS set in wrapper"
+
+ echo "=== All bundled skills checks passed ==="
+ mkdir -p $out
+ echo "ok" > $out/result
+ '';
+
+ # Verify HERMES_MANAGED guard works on all mutation commands
+ managed-guard = pkgs.runCommand "hermes-managed-guard" { } ''
+ set -e
+ export HOME=$(mktemp -d)
+
+ check_blocked() {
+ local label="$1"
+ shift
+ OUTPUT=$(HERMES_MANAGED=true "$@" 2>&1 || true)
+ echo "$OUTPUT" | grep -q "managed by NixOS" || (echo "FAIL: $label not guarded"; echo "$OUTPUT"; exit 1)
+ echo "PASS: $label blocked in managed mode"
+ }
+
+ echo "=== Checking HERMES_MANAGED guards ==="
+ check_blocked "config set" ${hermes-agent}/bin/hermes config set model foo
+ check_blocked "config edit" ${hermes-agent}/bin/hermes config edit
+
+ echo "=== All guard checks passed ==="
+ mkdir -p $out
+ echo "ok" > $out/result
+ '';
+
+ # ── Config drift detection ────────────────────────────────────────
+ # Extracts leaf key paths from Python's DEFAULT_CONFIG and compares
+ # against the committed reference in nix/config-keys.json.
+ config-drift = pkgs.runCommand "hermes-config-drift" {
+ nativeBuildInputs = [ pkgs.jq ];
+ referenceKeys = ./config-keys.json;
+ } ''
+ set -e
+ export HOME=$(mktemp -d)
+
+ echo "=== Extracting DEFAULT_CONFIG leaf keys from Python ==="
+ ${hermesVenv}/bin/python3 -c '
+import json, sys
+from hermes_cli.config import DEFAULT_CONFIG
+
+def leaf_paths(d, prefix=""):
+ paths = []
+ for k, v in sorted(d.items()):
+ path = f"{prefix}.{k}" if prefix else k
+ if isinstance(v, dict) and v:
+ paths.extend(leaf_paths(v, path))
+ else:
+ paths.append(path)
+ return paths
+
+json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout)
+' > /tmp/actual-keys.json
+
+ echo "=== Comparing against reference ==="
+ jq -r '.[]' $referenceKeys | sort > /tmp/reference.txt
+ jq -r '.[]' /tmp/actual-keys.json | sort > /tmp/actual.txt
+
+ ADDED=$(comm -23 /tmp/actual.txt /tmp/reference.txt || true)
+ REMOVED=$(comm -13 /tmp/actual.txt /tmp/reference.txt || true)
+ FAILED=false
+
+ if [ -n "$ADDED" ]; then
+ echo "FAIL: New keys in DEFAULT_CONFIG not in nix/config-keys.json:"
+ echo "$ADDED" | sed 's/^/ + /'
+ FAILED=true
+ fi
+ if [ -n "$REMOVED" ]; then
+ echo "FAIL: Keys in nix/config-keys.json missing from DEFAULT_CONFIG:"
+ echo "$REMOVED" | sed 's/^/ - /'
+ FAILED=true
+ fi
+
+ if [ "$FAILED" = "true" ]; then
+ exit 1
+ fi
+
+ ACTUAL_COUNT=$(wc -l < /tmp/actual.txt)
+ echo "PASS: All $ACTUAL_COUNT config keys match reference"
+ mkdir -p $out
+ echo "ok" > $out/result
+ '';
+
+ # ── Config merge + round-trip test ────────────────────────────────
+ # Tests the merge script (Nix activation behavior) across 7
+ # scenarios, then verifies Python's load_config() reads correctly.
+ config-roundtrip = let
+ # Nix settings used across scenarios
+ nixSettings = pkgs.writeText "nix-settings.json" (builtins.toJSON {
+ model = "test/nix-model";
+ toolsets = ["nix-toolset"];
+ terminal = { backend = "docker"; timeout = 999; };
+ mcp_servers = {
+ nix-server = { command = "echo"; args = ["nix"]; };
+ };
+ });
+
+ # Pre-built YAML fixtures for each scenario
+ fixtureB = pkgs.writeText "fixture-b.yaml" ''
+ model: "old-model"
+ mcp_servers:
+ old-server:
+ url: "http://old"
+ '';
+ fixtureC = pkgs.writeText "fixture-c.yaml" ''
+ skills:
+ disabled:
+ - skill-a
+ - skill-b
+ session_reset:
+ mode: idle
+ idle_minutes: 30
+ streaming:
+ enabled: true
+ fallback_model:
+ provider: openrouter
+ model: test-fallback
+ '';
+ fixtureD = pkgs.writeText "fixture-d.yaml" ''
+ model: "user-model"
+ skills:
+ disabled:
+ - skill-x
+ streaming:
+ enabled: true
+ transport: edit
+ '';
+ fixtureE = pkgs.writeText "fixture-e.yaml" ''
+ mcp_servers:
+ user-server:
+ url: "http://user-mcp"
+ nix-server:
+ command: "old-cmd"
+ args: ["old"]
+ '';
+ fixtureF = pkgs.writeText "fixture-f.yaml" ''
+ terminal:
+ cwd: "/user/path"
+ custom_key: "preserved"
+ env_passthrough:
+ - USER_VAR
+ '';
+
+ in pkgs.runCommand "hermes-config-roundtrip" {
+ nativeBuildInputs = [ pkgs.jq ];
+ } ''
+ set -e
+ export HOME=$(mktemp -d)
+ ERRORS=""
+
+ fail() { ERRORS="$ERRORS\nFAIL: $1"; }
+
+ # Helper: run merge then load with Python, output merged JSON
+ merge_and_load() {
+ local hermes_home="$1"
+ export HERMES_HOME="$hermes_home"
+ ${configMergeScript} ${nixSettings} "$hermes_home/config.yaml"
+ ${hermesVenv}/bin/python3 -c '
+import json, sys
+from hermes_cli.config import load_config
+json.dump(load_config(), sys.stdout, default=str)
+'
+ }
+
+ # ═══════════════════════════════════════════════════════════════
+ # Scenario A: Fresh install — no existing config.yaml
+ # ═══════════════════════════════════════════════════════════════
+ echo "=== Scenario A: Fresh install ==="
+ A_HOME=$(mktemp -d)
+ A_CONFIG=$(merge_and_load "$A_HOME")
+
+ echo "$A_CONFIG" | jq -e '.model == "test/nix-model"' > /dev/null \
+ || fail "A: model not set from Nix"
+ echo "$A_CONFIG" | jq -e '.mcp_servers."nix-server".command == "echo"' > /dev/null \
+ || fail "A: MCP nix-server missing"
+ echo "PASS: Scenario A"
+
+ # ═══════════════════════════════════════════════════════════════
+ # Scenario B: Nix keys override existing values
+ # ═══════════════════════════════════════════════════════════════
+ echo "=== Scenario B: Nix overrides ==="
+ B_HOME=$(mktemp -d)
+ install -m 0644 ${fixtureB} "$B_HOME/config.yaml"
+ B_CONFIG=$(merge_and_load "$B_HOME")
+
+ echo "$B_CONFIG" | jq -e '.model == "test/nix-model"' > /dev/null \
+ || fail "B: Nix model did not override"
+ echo "PASS: Scenario B"
+
+ # ═══════════════════════════════════════════════════════════════
+ # Scenario C: User-only keys preserved
+ # ═══════════════════════════════════════════════════════════════
+ echo "=== Scenario C: User keys preserved ==="
+ C_HOME=$(mktemp -d)
+ install -m 0644 ${fixtureC} "$C_HOME/config.yaml"
+ C_CONFIG=$(merge_and_load "$C_HOME")
+
+ echo "$C_CONFIG" | jq -e '.skills.disabled == ["skill-a", "skill-b"]' > /dev/null \
+ || fail "C: skills.disabled not preserved"
+ echo "$C_CONFIG" | jq -e '.session_reset.mode == "idle"' > /dev/null \
+ || fail "C: session_reset.mode not preserved"
+ echo "$C_CONFIG" | jq -e '.session_reset.idle_minutes == 30' > /dev/null \
+ || fail "C: session_reset.idle_minutes not preserved"
+ echo "$C_CONFIG" | jq -e '.streaming.enabled == true' > /dev/null \
+ || fail "C: streaming.enabled not preserved"
+ echo "$C_CONFIG" | jq -e '.fallback_model.provider == "openrouter"' > /dev/null \
+ || fail "C: fallback_model not preserved"
+ echo "PASS: Scenario C"
+
+ # ═══════════════════════════════════════════════════════════════
+ # Scenario D: Mixed — Nix wins for its keys, user keys preserved
+ # ═══════════════════════════════════════════════════════════════
+ echo "=== Scenario D: Mixed merge ==="
+ D_HOME=$(mktemp -d)
+ install -m 0644 ${fixtureD} "$D_HOME/config.yaml"
+ D_CONFIG=$(merge_and_load "$D_HOME")
+
+ echo "$D_CONFIG" | jq -e '.model == "test/nix-model"' > /dev/null \
+ || fail "D: Nix model did not override user model"
+ echo "$D_CONFIG" | jq -e '.skills.disabled == ["skill-x"]' > /dev/null \
+ || fail "D: user skills not preserved"
+ echo "$D_CONFIG" | jq -e '.streaming.enabled == true' > /dev/null \
+ || fail "D: user streaming not preserved"
+ echo "PASS: Scenario D"
+
+ # ═══════════════════════════════════════════════════════════════
+ # Scenario E: MCP additive merge
+ # ═══════════════════════════════════════════════════════════════
+ echo "=== Scenario E: MCP additive merge ==="
+ E_HOME=$(mktemp -d)
+ install -m 0644 ${fixtureE} "$E_HOME/config.yaml"
+ E_CONFIG=$(merge_and_load "$E_HOME")
+
+ echo "$E_CONFIG" | jq -e '.mcp_servers."user-server".url == "http://user-mcp"' > /dev/null \
+ || fail "E: user MCP server not preserved"
+ echo "$E_CONFIG" | jq -e '.mcp_servers."nix-server".command == "echo"' > /dev/null \
+ || fail "E: Nix MCP server did not override same-name user server"
+ echo "$E_CONFIG" | jq -e '.mcp_servers."nix-server".args == ["nix"]' > /dev/null \
+ || fail "E: Nix MCP server args wrong"
+ echo "PASS: Scenario E"
+
+ # ═══════════════════════════════════════════════════════════════
+ # Scenario F: Nested deep merge
+ # ═══════════════════════════════════════════════════════════════
+ echo "=== Scenario F: Nested deep merge ==="
+ F_HOME=$(mktemp -d)
+ install -m 0644 ${fixtureF} "$F_HOME/config.yaml"
+ F_CONFIG=$(merge_and_load "$F_HOME")
+
+ echo "$F_CONFIG" | jq -e '.terminal.backend == "docker"' > /dev/null \
+ || fail "F: Nix terminal.backend did not override"
+ echo "$F_CONFIG" | jq -e '.terminal.timeout == 999' > /dev/null \
+ || fail "F: Nix terminal.timeout did not override"
+ echo "$F_CONFIG" | jq -e '.terminal.custom_key == "preserved"' > /dev/null \
+ || fail "F: terminal.custom_key not preserved"
+ echo "$F_CONFIG" | jq -e '.terminal.cwd == "/user/path"' > /dev/null \
+ || fail "F: user terminal.cwd not preserved when Nix does not set it"
+ echo "$F_CONFIG" | jq -e '.terminal.env_passthrough == ["USER_VAR"]' > /dev/null \
+ || fail "F: user terminal.env_passthrough not preserved"
+ echo "PASS: Scenario F"
+
+ # ═══════════════════════════════════════════════════════════════
+ # Scenario G: Idempotency — merging twice yields the same result
+ # ═══════════════════════════════════════════════════════════════
+ echo "=== Scenario G: Idempotency ==="
+ G_HOME=$(mktemp -d)
+ install -m 0644 ${fixtureD} "$G_HOME/config.yaml"
+ ${configMergeScript} ${nixSettings} "$G_HOME/config.yaml"
+ FIRST=$(cat "$G_HOME/config.yaml")
+ ${configMergeScript} ${nixSettings} "$G_HOME/config.yaml"
+ SECOND=$(cat "$G_HOME/config.yaml")
+
+ if [ "$FIRST" != "$SECOND" ]; then
+ fail "G: second merge produced different output"
+ echo "--- first ---"
+ echo "$FIRST"
+ echo "--- second ---"
+ echo "$SECOND"
+ fi
+ echo "PASS: Scenario G"
+
+ # ═══════════════════════════════════════════════════════════════
+ # Report
+ # ═══════════════════════════════════════════════════════════════
+ if [ -n "$ERRORS" ]; then
+ echo ""
+ echo "FAILURES:"
+ echo -e "$ERRORS"
+ exit 1
+ fi
+
+ echo ""
+ echo "=== All 7 merge scenarios passed ==="
+ mkdir -p $out
+ echo "ok" > $out/result
+ '';
+ };
+ };
+}
diff --git a/nix/config-keys.json b/nix/config-keys.json
new file mode 100644
index 00000000..327958fb
--- /dev/null
+++ b/nix/config-keys.json
@@ -0,0 +1,129 @@
+[
+ "_config_version",
+ "agent.max_turns",
+ "approvals.mode",
+ "auxiliary.approval.api_key",
+ "auxiliary.approval.base_url",
+ "auxiliary.approval.model",
+ "auxiliary.approval.provider",
+ "auxiliary.compression.api_key",
+ "auxiliary.compression.base_url",
+ "auxiliary.compression.model",
+ "auxiliary.compression.provider",
+ "auxiliary.flush_memories.api_key",
+ "auxiliary.flush_memories.base_url",
+ "auxiliary.flush_memories.model",
+ "auxiliary.flush_memories.provider",
+ "auxiliary.mcp.api_key",
+ "auxiliary.mcp.base_url",
+ "auxiliary.mcp.model",
+ "auxiliary.mcp.provider",
+ "auxiliary.session_search.api_key",
+ "auxiliary.session_search.base_url",
+ "auxiliary.session_search.model",
+ "auxiliary.session_search.provider",
+ "auxiliary.skills_hub.api_key",
+ "auxiliary.skills_hub.base_url",
+ "auxiliary.skills_hub.model",
+ "auxiliary.skills_hub.provider",
+ "auxiliary.vision.api_key",
+ "auxiliary.vision.base_url",
+ "auxiliary.vision.model",
+ "auxiliary.vision.provider",
+ "auxiliary.vision.timeout",
+ "auxiliary.web_extract.api_key",
+ "auxiliary.web_extract.base_url",
+ "auxiliary.web_extract.model",
+ "auxiliary.web_extract.provider",
+ "browser.command_timeout",
+ "browser.inactivity_timeout",
+ "browser.record_sessions",
+ "checkpoints.enabled",
+ "checkpoints.max_snapshots",
+ "command_allowlist",
+ "compression.enabled",
+ "compression.protect_last_n",
+ "compression.summary_base_url",
+ "compression.summary_model",
+ "compression.summary_provider",
+ "compression.target_ratio",
+ "compression.threshold",
+ "delegation.api_key",
+ "delegation.base_url",
+ "delegation.model",
+ "delegation.provider",
+ "discord.auto_thread",
+ "discord.free_response_channels",
+ "discord.require_mention",
+ "display.bell_on_complete",
+ "display.compact",
+ "display.personality",
+ "display.resume_display",
+ "display.show_cost",
+ "display.show_reasoning",
+ "display.skin",
+ "display.streaming",
+ "honcho",
+ "human_delay.max_ms",
+ "human_delay.min_ms",
+ "human_delay.mode",
+ "memory.memory_char_limit",
+ "memory.memory_enabled",
+ "memory.user_char_limit",
+ "memory.user_profile_enabled",
+ "model",
+ "personalities",
+ "prefill_messages_file",
+ "privacy.redact_pii",
+ "quick_commands",
+ "security.redact_secrets",
+ "security.tirith_enabled",
+ "security.tirith_fail_open",
+ "security.tirith_path",
+ "security.tirith_timeout",
+ "security.website_blocklist.domains",
+ "security.website_blocklist.enabled",
+ "security.website_blocklist.shared_files",
+ "smart_model_routing.cheap_model",
+ "smart_model_routing.enabled",
+ "smart_model_routing.max_simple_chars",
+ "smart_model_routing.max_simple_words",
+ "stt.enabled",
+ "stt.local.model",
+ "stt.openai.model",
+ "stt.provider",
+ "terminal.backend",
+ "terminal.container_cpu",
+ "terminal.container_disk",
+ "terminal.container_memory",
+ "terminal.container_persistent",
+ "terminal.cwd",
+ "terminal.daytona_image",
+ "terminal.docker_forward_env",
+ "terminal.docker_image",
+ "terminal.docker_mount_cwd_to_workspace",
+ "terminal.docker_volumes",
+ "terminal.env_passthrough",
+ "terminal.modal_image",
+ "terminal.persistent_shell",
+ "terminal.singularity_image",
+ "terminal.timeout",
+ "timezone",
+ "toolsets",
+ "tts.edge.voice",
+ "tts.elevenlabs.model_id",
+ "tts.elevenlabs.voice_id",
+ "tts.neutts.device",
+ "tts.neutts.model",
+ "tts.neutts.ref_audio",
+ "tts.neutts.ref_text",
+ "tts.openai.model",
+ "tts.openai.voice",
+ "tts.provider",
+ "voice.auto_tts",
+ "voice.max_recording_seconds",
+ "voice.record_key",
+ "voice.silence_duration",
+ "voice.silence_threshold",
+ "whatsapp"
+]
diff --git a/nix/configMergeScript.nix b/nix/configMergeScript.nix
new file mode 100644
index 00000000..bea2d616
--- /dev/null
+++ b/nix/configMergeScript.nix
@@ -0,0 +1,33 @@
+# nix/configMergeScript.nix — Deep-merge Nix settings into existing config.yaml
+#
+# Used by the NixOS module activation script and by checks.nix tests.
+# Nix keys override; user-added keys (skills, streaming, etc.) are preserved.
+{ pkgs }:
+pkgs.writeScript "hermes-config-merge" ''
+ #!${pkgs.python3.withPackages (ps: [ ps.pyyaml ])}/bin/python3
+ import json, yaml, sys
+ from pathlib import Path
+
+ nix_json, config_path = sys.argv[1], Path(sys.argv[2])
+
+ with open(nix_json) as f:
+ nix = json.load(f)
+
+ existing = {}
+ if config_path.exists():
+ with open(config_path) as f:
+ existing = yaml.safe_load(f) or {}
+
+ def deep_merge(base, override):
+ result = dict(base)
+ for k, v in override.items():
+ if k in result and isinstance(result[k], dict) and isinstance(v, dict):
+ result[k] = deep_merge(result[k], v)
+ else:
+ result[k] = v
+ return result
+
+ merged = deep_merge(existing, nix)
+ with open(config_path, "w") as f:
+ yaml.dump(merged, f, default_flow_style=False, sort_keys=False)
+''
diff --git a/nix/devShell.nix b/nix/devShell.nix
new file mode 100644
index 00000000..7f8b5a1b
--- /dev/null
+++ b/nix/devShell.nix
@@ -0,0 +1,51 @@
+# nix/devShell.nix — Fast dev shell with stamp-file optimization
+{ inputs, ... }: {
+ perSystem = { pkgs, ... }:
+ let
+ python = pkgs.python311;
+ in {
+ devShells.default = pkgs.mkShell {
+ packages = with pkgs; [
+ python uv nodejs_20 ripgrep git openssh ffmpeg
+ ];
+
+ shellHook = ''
+ echo "Hermes Agent dev shell"
+
+ # Composite stamp: changes when nix python or uv change
+ STAMP_VALUE="${python}:${pkgs.uv}"
+ STAMP_FILE=".venv/.nix-stamp"
+
+ # Create venv if missing
+ if [ ! -d .venv ]; then
+ echo "Creating Python 3.11 venv..."
+ uv venv .venv --python ${python}/bin/python3
+ fi
+
+ source .venv/bin/activate
+
+ # Only install if stamp is stale or missing
+ if [ ! -f "$STAMP_FILE" ] || [ "$(cat "$STAMP_FILE")" != "$STAMP_VALUE" ]; then
+ echo "Installing Python dependencies..."
+ uv pip install -e ".[all]"
+ if [ -d mini-swe-agent ]; then
+ uv pip install -e ./mini-swe-agent 2>/dev/null || true
+ fi
+ if [ -d tinker-atropos ]; then
+ uv pip install -e ./tinker-atropos 2>/dev/null || true
+ fi
+
+ # Install npm deps
+ if [ -f package.json ] && [ ! -d node_modules ]; then
+ echo "Installing npm dependencies..."
+ npm install
+ fi
+
+ echo "$STAMP_VALUE" > "$STAMP_FILE"
+ fi
+
+ echo "Ready. Run 'hermes' to start."
+ '';
+ };
+ };
+}
diff --git a/nix/nixosModules.nix b/nix/nixosModules.nix
new file mode 100644
index 00000000..178305a2
--- /dev/null
+++ b/nix/nixosModules.nix
@@ -0,0 +1,716 @@
+# nix/nixosModules.nix — NixOS module for hermes-agent
+#
+# Two modes:
+# container.enable = false (default) → native systemd service
+# container.enable = true → OCI container (persistent writable layer)
+#
+# Container mode: hermes runs from /nix/store bind-mounted read-only into a
+# plain Ubuntu container. The writable layer (apt/pip/npm installs) persists
+# across restarts and agent updates. Only image/volume/options changes trigger
+# container recreation. Environment variables are written to $HERMES_HOME/.env
+# and read by hermes at startup — no container recreation needed for env changes.
+#
+# Usage:
+# services.hermes-agent = {
+# enable = true;
+# settings.model = "anthropic/claude-sonnet-4";
+# environmentFiles = [ config.sops.secrets."hermes/env".path ];
+# };
+#
+{ inputs, ... }: {
+ flake.nixosModules.default = { config, lib, pkgs, ... }:
+
+ let
+ cfg = config.services.hermes-agent;
+ hermes-agent = inputs.self.packages.${pkgs.system}.default;
+
+ # Deep-merge config type (from 0xrsydn/nix-hermes-agent)
+ deepConfigType = lib.types.mkOptionType {
+ name = "hermes-config-attrs";
+ description = "Hermes YAML config (attrset), merged deeply via lib.recursiveUpdate.";
+ check = builtins.isAttrs;
+ merge = _loc: defs: lib.foldl' lib.recursiveUpdate { } (map (d: d.value) defs);
+ };
+
+ # Generate config.yaml from Nix attrset (YAML is a superset of JSON)
+ configJson = builtins.toJSON cfg.settings;
+ generatedConfigFile = pkgs.writeText "hermes-config.yaml" configJson;
+ configFile = if cfg.configFile != null then cfg.configFile else generatedConfigFile;
+
+ configMergeScript = pkgs.callPackage ./configMergeScript.nix { };
+
+ # Generate .env from non-secret environment attrset
+ envFileContent = lib.concatStringsSep "\n" (
+ lib.mapAttrsToList (k: v: "${k}=${v}") cfg.environment
+ );
+ # Build documents derivation (from 0xrsydn)
+ documentDerivation = pkgs.runCommand "hermes-documents" { } (
+ ''
+ mkdir -p $out
+ '' + lib.concatStringsSep "\n" (
+ lib.mapAttrsToList (name: value:
+ if builtins.isPath value || lib.isStorePath value
+ then "cp ${value} $out/${name}"
+ else "cat > $out/${name} <<'HERMES_DOC_EOF'\n${value}\nHERMES_DOC_EOF"
+ ) cfg.documents
+ )
+ );
+
+ containerName = "hermes-agent";
+ containerDataDir = "/data"; # stateDir mount point inside container
+ containerHomeDir = "/home/hermes";
+
+ # ── Container mode helpers ──────────────────────────────────────────
+ containerBin = if cfg.container.backend == "docker"
+ then "${pkgs.docker}/bin/docker"
+ else "${pkgs.podman}/bin/podman";
+
+ # Runs as root inside the container on every start. Provisions the
+ # hermes user + sudo on first boot (writable layer persists), then
+ # drops privileges. Supports arbitrary base images (Debian, Alpine, etc).
+ containerEntrypoint = pkgs.writeShellScript "hermes-container-entrypoint" ''
+ set -eu
+
+ HERMES_UID="''${HERMES_UID:?HERMES_UID must be set}"
+ HERMES_GID="''${HERMES_GID:?HERMES_GID must be set}"
+
+ # ── Group: ensure a group with GID=$HERMES_GID exists ──
+ # Check by GID (not name) to avoid collisions with pre-existing groups
+ # (e.g. GID 100 = "users" on Ubuntu)
+ EXISTING_GROUP=$(getent group "$HERMES_GID" 2>/dev/null | cut -d: -f1 || true)
+ if [ -n "$EXISTING_GROUP" ]; then
+ GROUP_NAME="$EXISTING_GROUP"
+ else
+ GROUP_NAME="hermes"
+ if command -v groupadd >/dev/null 2>&1; then
+ groupadd -g "$HERMES_GID" "$GROUP_NAME"
+ elif command -v addgroup >/dev/null 2>&1; then
+ addgroup -g "$HERMES_GID" "$GROUP_NAME" 2>/dev/null || true
+ fi
+ fi
+
+ # ── User: ensure a user with UID=$HERMES_UID exists ──
+ PASSWD_ENTRY=$(getent passwd "$HERMES_UID" 2>/dev/null || true)
+ if [ -n "$PASSWD_ENTRY" ]; then
+ TARGET_USER=$(echo "$PASSWD_ENTRY" | cut -d: -f1)
+ TARGET_HOME=$(echo "$PASSWD_ENTRY" | cut -d: -f6)
+ else
+ TARGET_USER="hermes"
+ TARGET_HOME="/home/hermes"
+ if command -v useradd >/dev/null 2>&1; then
+ useradd -u "$HERMES_UID" -g "$HERMES_GID" -m -d "$TARGET_HOME" -s /bin/bash "$TARGET_USER"
+ elif command -v adduser >/dev/null 2>&1; then
+ adduser -u "$HERMES_UID" -D -h "$TARGET_HOME" -s /bin/sh -G "$GROUP_NAME" "$TARGET_USER" 2>/dev/null || true
+ fi
+ fi
+ mkdir -p "$TARGET_HOME"
+ chown "$HERMES_UID:$HERMES_GID" "$TARGET_HOME"
+
+ # Ensure HERMES_HOME is owned by the target user
+ if [ -n "''${HERMES_HOME:-}" ] && [ -d "$HERMES_HOME" ]; then
+ chown -R "$HERMES_UID:$HERMES_GID" "$HERMES_HOME"
+ fi
+
+ # Install sudo on Debian/Ubuntu if missing (first boot only, cached in writable layer)
+ if command -v apt-get >/dev/null 2>&1 && ! command -v sudo >/dev/null 2>&1; then
+ apt-get update -qq >/dev/null 2>&1 && apt-get install -y -qq sudo >/dev/null 2>&1 || true
+ fi
+ if command -v sudo >/dev/null 2>&1 && [ ! -f /etc/sudoers.d/hermes ]; then
+ mkdir -p /etc/sudoers.d
+ echo "$TARGET_USER ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/hermes
+ chmod 0440 /etc/sudoers.d/hermes
+ fi
+
+ if command -v setpriv >/dev/null 2>&1; then
+ exec setpriv --reuid="$HERMES_UID" --regid="$HERMES_GID" --init-groups "$@"
+ elif command -v su >/dev/null 2>&1; then
+ exec su -s /bin/sh "$TARGET_USER" -c 'exec "$0" "$@"' -- "$@"
+ else
+ echo "WARNING: no privilege-drop tool (setpriv/su), running as root" >&2
+ exec "$@"
+ fi
+ '';
+
+ # Identity hash — only recreate container when structural config changes.
+ # Package and entrypoint use stable symlinks (current-package, current-entrypoint)
+ # so they can update without recreation. Env vars go through $HERMES_HOME/.env.
+ containerIdentity = builtins.hashString "sha256" (builtins.toJSON {
+ schema = 3; # bump when identity inputs change
+ image = cfg.container.image;
+ extraVolumes = cfg.container.extraVolumes;
+ extraOptions = cfg.container.extraOptions;
+ });
+
+ identityFile = "${cfg.stateDir}/.container-identity";
+
+ # Default: /var/lib/hermes/workspace → /data/workspace.
+ # Custom paths outside stateDir pass through unchanged (user must add extraVolumes).
+ containerWorkDir =
+ if lib.hasPrefix "${cfg.stateDir}/" cfg.workingDirectory
+ then "${containerDataDir}/${lib.removePrefix "${cfg.stateDir}/" cfg.workingDirectory}"
+ else cfg.workingDirectory;
+
+ in {
+ options.services.hermes-agent = with lib; {
+ enable = mkEnableOption "Hermes Agent gateway service";
+
+ # ── Package ──────────────────────────────────────────────────────────
+ package = mkOption {
+ type = types.package;
+ default = hermes-agent;
+ description = "The hermes-agent package to use.";
+ };
+
+ # ── Service identity ─────────────────────────────────────────────────
+ user = mkOption {
+ type = types.str;
+ default = "hermes";
+ description = "System user running the gateway.";
+ };
+
+ group = mkOption {
+ type = types.str;
+ default = "hermes";
+ description = "System group running the gateway.";
+ };
+
+ createUser = mkOption {
+ type = types.bool;
+ default = true;
+ description = "Create the user/group automatically.";
+ };
+
+ # ── Directories ──────────────────────────────────────────────────────
+ stateDir = mkOption {
+ type = types.str;
+ default = "/var/lib/hermes";
+ description = "State directory. Contains .hermes/ subdir (HERMES_HOME).";
+ };
+
+ workingDirectory = mkOption {
+ type = types.str;
+ default = "${cfg.stateDir}/workspace";
+ defaultText = literalExpression ''"''${cfg.stateDir}/workspace"'';
+ description = "Working directory for the agent (MESSAGING_CWD).";
+ };
+
+ # ── Declarative config ───────────────────────────────────────────────
+ configFile = mkOption {
+ type = types.nullOr types.path;
+ default = null;
+ description = ''
+ Path to an existing config.yaml. If set, takes precedence over
+ the declarative `settings` option.
+ '';
+ };
+
+ settings = mkOption {
+ type = deepConfigType;
+ default = { };
+ description = ''
+ Declarative Hermes config (attrset). Deep-merged across module
+ definitions and rendered as config.yaml.
+ '';
+ example = literalExpression ''
+ {
+ model = "anthropic/claude-sonnet-4";
+ terminal.backend = "local";
+ compression = { enabled = true; threshold = 0.85; };
+ toolsets = [ "all" ];
+ }
+ '';
+ };
+
+ # ── Secrets / environment ────────────────────────────────────────────
+ environmentFiles = mkOption {
+ type = types.listOf types.str;
+ default = [ ];
+ description = ''
+ Paths to environment files containing secrets (API keys, tokens).
+ Contents are merged into $HERMES_HOME/.env at activation time.
+ Hermes reads this file on every startup via load_hermes_dotenv().
+ '';
+ };
+
+ environment = mkOption {
+ type = types.attrsOf types.str;
+ default = { };
+ description = ''
+ Non-secret environment variables. Merged into $HERMES_HOME/.env
+ at activation time. Do NOT put secrets here — use environmentFiles.
+ '';
+ };
+
+ authFile = mkOption {
+ type = types.nullOr types.path;
+ default = null;
+ description = ''
+ Path to an auth.json seed file (OAuth credentials).
+ Only copied on first deploy — existing auth.json is preserved.
+ '';
+ };
+
+ authFileForceOverwrite = mkOption {
+ type = types.bool;
+ default = false;
+ description = "Always overwrite auth.json from authFile on activation.";
+ };
+
+ # ── Documents ────────────────────────────────────────────────────────
+ documents = mkOption {
+ type = types.attrsOf (types.either types.str types.path);
+ default = { };
+ description = ''
+ Workspace files (SOUL.md, USER.md, etc.). Keys are filenames,
+ values are inline strings or paths. Installed into workingDirectory.
+ '';
+ example = literalExpression ''
+ {
+ "SOUL.md" = "You are a helpful AI assistant.";
+ "USER.md" = ./documents/USER.md;
+ }
+ '';
+ };
+
+ # ── MCP Servers ──────────────────────────────────────────────────────
+ mcpServers = mkOption {
+ type = types.attrsOf (types.submodule {
+ options = {
+ # Stdio transport
+ command = mkOption {
+ type = types.nullOr types.str;
+ default = null;
+ description = "MCP server command (stdio transport).";
+ };
+ args = mkOption {
+ type = types.listOf types.str;
+ default = [ ];
+ description = "Command-line arguments (stdio transport).";
+ };
+ env = mkOption {
+ type = types.attrsOf types.str;
+ default = { };
+ description = "Environment variables for the server process (stdio transport).";
+ };
+
+ # HTTP/StreamableHTTP transport
+ url = mkOption {
+ type = types.nullOr types.str;
+ default = null;
+ description = "MCP server endpoint URL (HTTP/StreamableHTTP transport).";
+ };
+ headers = mkOption {
+ type = types.attrsOf types.str;
+ default = { };
+ description = "HTTP headers, e.g. for authentication (HTTP transport).";
+ };
+
+ # Authentication
+ auth = mkOption {
+ type = types.nullOr (types.enum [ "oauth" ]);
+ default = null;
+ description = ''
+ Authentication method. Set to "oauth" for OAuth 2.1 PKCE flow
+ (remote MCP servers). Tokens are stored in $HERMES_HOME/mcp-tokens/.
+ '';
+ };
+
+ # Enable/disable
+ enabled = mkOption {
+ type = types.bool;
+ default = true;
+ description = "Enable or disable this MCP server.";
+ };
+
+ # Common options
+ timeout = mkOption {
+ type = types.nullOr types.int;
+ default = null;
+ description = "Tool call timeout in seconds (default: 120).";
+ };
+ connect_timeout = mkOption {
+ type = types.nullOr types.int;
+ default = null;
+ description = "Initial connection timeout in seconds (default: 60).";
+ };
+
+ # Tool filtering
+ tools = mkOption {
+ type = types.nullOr (types.submodule {
+ options = {
+ include = mkOption {
+ type = types.listOf types.str;
+ default = [ ];
+ description = "Tool allowlist — only these tools are registered.";
+ };
+ exclude = mkOption {
+ type = types.listOf types.str;
+ default = [ ];
+ description = "Tool blocklist — these tools are hidden.";
+ };
+ };
+ });
+ default = null;
+ description = "Filter which tools are exposed by this server.";
+ };
+
+ # Sampling (server-initiated LLM requests)
+ sampling = mkOption {
+ type = types.nullOr (types.submodule {
+ options = {
+ enabled = mkOption { type = types.bool; default = true; description = "Enable sampling."; };
+ model = mkOption { type = types.nullOr types.str; default = null; description = "Override model for sampling requests."; };
+ max_tokens_cap = mkOption { type = types.nullOr types.int; default = null; description = "Max tokens per request."; };
+ timeout = mkOption { type = types.nullOr types.int; default = null; description = "LLM call timeout in seconds."; };
+ max_rpm = mkOption { type = types.nullOr types.int; default = null; description = "Max requests per minute."; };
+ max_tool_rounds = mkOption { type = types.nullOr types.int; default = null; description = "Max tool-use rounds per sampling request."; };
+ allowed_models = mkOption { type = types.listOf types.str; default = [ ]; description = "Models the server is allowed to request."; };
+ log_level = mkOption {
+ type = types.nullOr (types.enum [ "debug" "info" "warning" ]);
+ default = null;
+ description = "Audit log level for sampling requests.";
+ };
+ };
+ });
+ default = null;
+ description = "Sampling configuration for server-initiated LLM requests.";
+ };
+ };
+ });
+ default = { };
+ description = ''
+ MCP server configurations (merged into settings.mcp_servers).
+ Each server uses either stdio (command/args) or HTTP (url) transport.
+ '';
+ example = literalExpression ''
+ {
+ filesystem = {
+ command = "npx";
+ args = [ "-y" "@modelcontextprotocol/server-filesystem" "/home/user" ];
+ };
+ remote-api = {
+ url = "http://my-server:8080/v0/mcp";
+ headers = { Authorization = "Bearer ..."; };
+ };
+ remote-oauth = {
+ url = "https://mcp.example.com/mcp";
+ auth = "oauth";
+ };
+ }
+ '';
+ };
+
+ # ── Service behavior ─────────────────────────────────────────────────
+ extraArgs = mkOption {
+ type = types.listOf types.str;
+ default = [ ];
+ description = "Extra command-line arguments for `hermes gateway`.";
+ };
+
+ extraPackages = mkOption {
+ type = types.listOf types.package;
+ default = [ ];
+ description = "Extra packages available on PATH.";
+ };
+
+ restart = mkOption {
+ type = types.str;
+ default = "always";
+ description = "systemd Restart= policy.";
+ };
+
+ restartSec = mkOption {
+ type = types.int;
+ default = 5;
+ description = "systemd RestartSec= value.";
+ };
+
+ addToSystemPackages = mkOption {
+ type = types.bool;
+ default = false;
+ description = "Add hermes CLI to environment.systemPackages.";
+ };
+
+ # ── OCI Container (opt-in) ──────────────────────────────────────────
+ container = {
+ enable = mkEnableOption "OCI container mode (Ubuntu base, full self-modification support)";
+
+ backend = mkOption {
+ type = types.enum [ "docker" "podman" ];
+ default = "docker";
+ description = "Container runtime.";
+ };
+
+ extraVolumes = mkOption {
+ type = types.listOf types.str;
+ default = [ ];
+ description = "Extra volume mounts (host:container:mode format).";
+ example = [ "/home/user/projects:/projects:rw" ];
+ };
+
+ extraOptions = mkOption {
+ type = types.listOf types.str;
+ default = [ ];
+ description = "Extra arguments passed to docker/podman run.";
+ };
+
+ image = mkOption {
+ type = types.str;
+ default = "ubuntu:24.04";
+ description = "OCI container image. The container pulls this at runtime via Docker/Podman.";
+ };
+ };
+ };
+
+ config = lib.mkIf cfg.enable (lib.mkMerge [
+
+ # ── Merge MCP servers into settings ────────────────────────────────
+ (lib.mkIf (cfg.mcpServers != { }) {
+ services.hermes-agent.settings.mcp_servers = lib.mapAttrs (_name: srv:
+ # Stdio transport
+ lib.optionalAttrs (srv.command != null) { inherit (srv) command args; }
+ // lib.optionalAttrs (srv.env != { }) { inherit (srv) env; }
+ # HTTP transport
+ // lib.optionalAttrs (srv.url != null) { inherit (srv) url; }
+ // lib.optionalAttrs (srv.headers != { }) { inherit (srv) headers; }
+ # Auth
+ // lib.optionalAttrs (srv.auth != null) { inherit (srv) auth; }
+ # Enable/disable
+ // { inherit (srv) enabled; }
+ # Common options
+ // lib.optionalAttrs (srv.timeout != null) { inherit (srv) timeout; }
+ // lib.optionalAttrs (srv.connect_timeout != null) { inherit (srv) connect_timeout; }
+ # Tool filtering
+ // lib.optionalAttrs (srv.tools != null) {
+ tools = lib.filterAttrs (_: v: v != [ ]) {
+ inherit (srv.tools) include exclude;
+ };
+ }
+ # Sampling
+ // lib.optionalAttrs (srv.sampling != null) {
+ sampling = lib.filterAttrs (_: v: v != null && v != [ ]) {
+ inherit (srv.sampling) enabled model max_tokens_cap timeout max_rpm
+ max_tool_rounds allowed_models log_level;
+ };
+ }
+ ) cfg.mcpServers;
+ })
+
+ # ── User / group ──────────────────────────────────────────────────
+ (lib.mkIf cfg.createUser {
+ users.groups.${cfg.group} = { };
+ users.users.${cfg.user} = {
+ isSystemUser = true;
+ group = cfg.group;
+ home = cfg.stateDir;
+ createHome = true;
+ shell = pkgs.bashInteractive;
+ };
+ })
+
+ # ── Host CLI ──────────────────────────────────────────────────────
+ (lib.mkIf cfg.addToSystemPackages {
+ environment.systemPackages = [ cfg.package ];
+ })
+
+ # ── Directories ───────────────────────────────────────────────────
+ {
+ systemd.tmpfiles.rules = [
+ "d ${cfg.stateDir} 0755 ${cfg.user} ${cfg.group} - -"
+ "d ${cfg.stateDir}/.hermes 0755 ${cfg.user} ${cfg.group} - -"
+ "d ${cfg.stateDir}/home 0750 ${cfg.user} ${cfg.group} - -"
+ "d ${cfg.workingDirectory} 0750 ${cfg.user} ${cfg.group} - -"
+ ];
+ }
+
+ # ── Activation: link config + auth + documents ────────────────────
+ {
+ system.activationScripts."hermes-agent-setup" = lib.stringAfter [ "users" ] ''
+ # Ensure directories exist (activation runs before tmpfiles)
+ mkdir -p ${cfg.stateDir}/.hermes
+ mkdir -p ${cfg.stateDir}/home
+ mkdir -p ${cfg.workingDirectory}
+ chown ${cfg.user}:${cfg.group} ${cfg.stateDir} ${cfg.stateDir}/.hermes ${cfg.stateDir}/home ${cfg.workingDirectory}
+
+ # Merge Nix settings into existing config.yaml.
+ # Preserves user-added keys (skills, streaming, etc.); Nix keys win.
+ # If configFile is user-provided (not generated), overwrite instead of merge.
+ ${if cfg.configFile != null then ''
+ install -o ${cfg.user} -g ${cfg.group} -m 0644 -D ${configFile} ${cfg.stateDir}/.hermes/config.yaml
+ '' else ''
+ ${configMergeScript} ${generatedConfigFile} ${cfg.stateDir}/.hermes/config.yaml
+ chown ${cfg.user}:${cfg.group} ${cfg.stateDir}/.hermes/config.yaml
+ chmod 0644 ${cfg.stateDir}/.hermes/config.yaml
+ ''}
+
+ # Managed mode marker (so interactive shells also detect NixOS management)
+ touch ${cfg.stateDir}/.hermes/.managed
+ chown ${cfg.user}:${cfg.group} ${cfg.stateDir}/.hermes/.managed
+
+ # Seed auth file if provided
+ ${lib.optionalString (cfg.authFile != null) ''
+ ${if cfg.authFileForceOverwrite then ''
+ install -o ${cfg.user} -g ${cfg.group} -m 0600 ${cfg.authFile} ${cfg.stateDir}/.hermes/auth.json
+ '' else ''
+ if [ ! -f ${cfg.stateDir}/.hermes/auth.json ]; then
+ install -o ${cfg.user} -g ${cfg.group} -m 0600 ${cfg.authFile} ${cfg.stateDir}/.hermes/auth.json
+ fi
+ ''}
+ ''}
+
+ # Seed .env from Nix-declared environment + environmentFiles.
+ # Hermes reads $HERMES_HOME/.env at startup via load_hermes_dotenv(),
+ # so this is the single source of truth for both native and container mode.
+ ${lib.optionalString (cfg.environment != {} || cfg.environmentFiles != []) ''
+ ENV_FILE="${cfg.stateDir}/.hermes/.env"
+ install -o ${cfg.user} -g ${cfg.group} -m 0600 /dev/null "$ENV_FILE"
+ cat > "$ENV_FILE" <<'HERMES_NIX_ENV_EOF'
+${envFileContent}
+HERMES_NIX_ENV_EOF
+ ${lib.concatStringsSep "\n" (map (f: ''
+ if [ -f "${f}" ]; then
+ echo "" >> "$ENV_FILE"
+ cat "${f}" >> "$ENV_FILE"
+ fi
+ '') cfg.environmentFiles)}
+ ''}
+
+ # Link documents into workspace
+ ${lib.concatStringsSep "\n" (lib.mapAttrsToList (name: _value: ''
+ install -o ${cfg.user} -g ${cfg.group} -m 0644 ${documentDerivation}/${name} ${cfg.workingDirectory}/${name}
+ '') cfg.documents)}
+ '';
+ }
+
+ # ══════════════════════════════════════════════════════════════════
+ # MODE A: Native systemd service (default)
+ # ══════════════════════════════════════════════════════════════════
+ (lib.mkIf (!cfg.container.enable) {
+ systemd.services.hermes-agent = {
+ description = "Hermes Agent Gateway";
+ wantedBy = [ "multi-user.target" ];
+ after = [ "network-online.target" ];
+ wants = [ "network-online.target" ];
+
+ environment = {
+ HOME = cfg.stateDir;
+ HERMES_HOME = "${cfg.stateDir}/.hermes";
+ HERMES_MANAGED = "true";
+ MESSAGING_CWD = cfg.workingDirectory;
+ };
+
+ serviceConfig = {
+ User = cfg.user;
+ Group = cfg.group;
+ WorkingDirectory = cfg.workingDirectory;
+
+ # cfg.environment and cfg.environmentFiles are written to
+ # $HERMES_HOME/.env by the activation script. load_hermes_dotenv()
+ # reads them at Python startup — no systemd EnvironmentFile needed.
+
+ ExecStart = lib.concatStringsSep " " ([
+ "${cfg.package}/bin/hermes"
+ "gateway"
+ ] ++ cfg.extraArgs);
+
+ Restart = cfg.restart;
+ RestartSec = cfg.restartSec;
+
+ # Hardening
+ NoNewPrivileges = true;
+ ProtectSystem = "strict";
+ ProtectHome = false;
+ ReadWritePaths = [ cfg.stateDir ];
+ PrivateTmp = true;
+ };
+
+ path = [
+ cfg.package
+ pkgs.bash
+ pkgs.coreutils
+ pkgs.git
+ ] ++ cfg.extraPackages;
+ };
+ })
+
+ # ══════════════════════════════════════════════════════════════════
+ # MODE B: OCI container (persistent writable layer)
+ # ══════════════════════════════════════════════════════════════════
+ (lib.mkIf cfg.container.enable {
+ # Ensure the container runtime is available
+ virtualisation.docker.enable = lib.mkDefault (cfg.container.backend == "docker");
+
+ systemd.services.hermes-agent = {
+ description = "Hermes Agent Gateway (container)";
+ wantedBy = [ "multi-user.target" ];
+ after = [ "network-online.target" ]
+ ++ lib.optional (cfg.container.backend == "docker") "docker.service";
+ wants = [ "network-online.target" ];
+ requires = lib.optional (cfg.container.backend == "docker") "docker.service";
+
+ preStart = ''
+ # Stable symlinks — container references these, not store paths directly
+ ln -sfn ${cfg.package} ${cfg.stateDir}/current-package
+ ln -sfn ${containerEntrypoint} ${cfg.stateDir}/current-entrypoint
+
+ # GC roots so nix-collect-garbage doesn't remove store paths in use
+ ${pkgs.nix}/bin/nix-store --add-root ${cfg.stateDir}/.gc-root --indirect -r ${cfg.package} 2>/dev/null || true
+ ${pkgs.nix}/bin/nix-store --add-root ${cfg.stateDir}/.gc-root-entrypoint --indirect -r ${containerEntrypoint} 2>/dev/null || true
+
+ # Check if container needs (re)creation
+ NEED_CREATE=false
+ if ! ${containerBin} inspect ${containerName} &>/dev/null; then
+ NEED_CREATE=true
+ elif [ ! -f ${identityFile} ] || [ "$(cat ${identityFile})" != "${containerIdentity}" ]; then
+ echo "Container config changed, recreating..."
+ ${containerBin} rm -f ${containerName} || true
+ NEED_CREATE=true
+ fi
+
+ if [ "$NEED_CREATE" = "true" ]; then
+ # Resolve numeric UID/GID — passed to entrypoint for in-container user setup
+ HERMES_UID=$(${pkgs.coreutils}/bin/id -u ${cfg.user})
+ HERMES_GID=$(${pkgs.coreutils}/bin/id -g ${cfg.user})
+
+ echo "Creating container..."
+ ${containerBin} create \
+ --name ${containerName} \
+ --network=host \
+ --entrypoint ${containerDataDir}/current-entrypoint \
+ --volume /nix/store:/nix/store:ro \
+ --volume ${cfg.stateDir}:${containerDataDir} \
+ --volume ${cfg.stateDir}/home:${containerHomeDir} \
+ ${lib.concatStringsSep " " (map (v: "--volume ${v}") cfg.container.extraVolumes)} \
+ --env HERMES_UID="$HERMES_UID" \
+ --env HERMES_GID="$HERMES_GID" \
+ --env HERMES_HOME=${containerDataDir}/.hermes \
+ --env HERMES_MANAGED=true \
+ --env HOME=${containerHomeDir} \
+ --env MESSAGING_CWD=${containerWorkDir} \
+ ${lib.concatStringsSep " " cfg.container.extraOptions} \
+ ${cfg.container.image} \
+ ${containerDataDir}/current-package/bin/hermes gateway run --replace ${lib.concatStringsSep " " cfg.extraArgs}
+
+ echo "${containerIdentity}" > ${identityFile}
+ fi
+ '';
+
+ script = ''
+ exec ${containerBin} start -a ${containerName}
+ '';
+
+ preStop = ''
+ ${containerBin} stop -t 10 ${containerName} || true
+ '';
+
+ serviceConfig = {
+ Type = "simple";
+ Restart = cfg.restart;
+ RestartSec = cfg.restartSec;
+ TimeoutStopSec = 30;
+ };
+ };
+ })
+ ]);
+ };
+}
diff --git a/nix/packages.nix b/nix/packages.nix
new file mode 100644
index 00000000..8c2b7cbd
--- /dev/null
+++ b/nix/packages.nix
@@ -0,0 +1,54 @@
+# nix/packages.nix — Hermes Agent package built with uv2nix
+{ inputs, ... }: {
+ perSystem = { pkgs, system, ... }:
+ let
+ hermesVenv = pkgs.callPackage ./python.nix {
+ inherit (inputs) uv2nix pyproject-nix pyproject-build-systems;
+ };
+
+ # Import bundled skills, excluding runtime caches
+ bundledSkills = pkgs.lib.cleanSourceWith {
+ src = ../skills;
+ filter = path: _type:
+ !(pkgs.lib.hasInfix "/index-cache/" path);
+ };
+
+ runtimeDeps = with pkgs; [
+ nodejs_20 ripgrep git openssh ffmpeg
+ ];
+
+ runtimePath = pkgs.lib.makeBinPath runtimeDeps;
+ in {
+ packages.default = pkgs.stdenv.mkDerivation {
+ pname = "hermes-agent";
+ version = "0.1.0";
+
+ dontUnpack = true;
+ dontBuild = true;
+ nativeBuildInputs = [ pkgs.makeWrapper ];
+
+ installPhase = ''
+ runHook preInstall
+
+ mkdir -p $out/share/hermes-agent $out/bin
+ cp -r ${bundledSkills} $out/share/hermes-agent/skills
+
+ ${pkgs.lib.concatMapStringsSep "\n" (name: ''
+ makeWrapper ${hermesVenv}/bin/${name} $out/bin/${name} \
+ --prefix PATH : "${runtimePath}" \
+ --set HERMES_BUNDLED_SKILLS $out/share/hermes-agent/skills
+ '') [ "hermes" "hermes-agent" "hermes-acp" ]}
+
+ runHook postInstall
+ '';
+
+ meta = with pkgs.lib; {
+ description = "AI agent with advanced tool-calling capabilities";
+ homepage = "https://github.com/NousResearch/hermes-agent";
+ mainProgram = "hermes";
+ license = licenses.mit;
+ platforms = platforms.unix;
+ };
+ };
+ };
+}
diff --git a/nix/python.nix b/nix/python.nix
new file mode 100644
index 00000000..406e7aee
--- /dev/null
+++ b/nix/python.nix
@@ -0,0 +1,28 @@
+# nix/python.nix — uv2nix virtual environment builder
+{
+ python311,
+ lib,
+ callPackage,
+ uv2nix,
+ pyproject-nix,
+ pyproject-build-systems,
+}:
+let
+ workspace = uv2nix.lib.workspace.loadWorkspace { workspaceRoot = ./..; };
+
+ overlay = workspace.mkPyprojectOverlay {
+ sourcePreference = "wheel";
+ };
+
+ pythonSet =
+ (callPackage pyproject-nix.build.packages {
+ python = python311;
+ }).overrideScope
+ (lib.composeManyExtensions [
+ pyproject-build-systems.overlays.default
+ overlay
+ ]);
+in
+pythonSet.mkVirtualEnv "hermes-agent-env" {
+ hermes-agent = [ "all" ];
+}
diff --git a/tests/tools/test_skills_sync.py b/tests/tools/test_skills_sync.py
index 1549d517..e3469c80 100644
--- a/tests/tools/test_skills_sync.py
+++ b/tests/tools/test_skills_sync.py
@@ -4,6 +4,7 @@ from pathlib import Path
from unittest.mock import patch
from tools.skills_sync import (
+ _get_bundled_dir,
_read_manifest,
_write_manifest,
_discover_bundled_skills,
@@ -467,3 +468,24 @@ class TestSyncSkills:
new_bundled_hash = _dir_hash(bundled / "old-skill")
assert manifest["old-skill"] == new_bundled_hash
assert manifest["old-skill"] != old_hash
+
+
+class TestGetBundledDir:
+ def test_env_var_override(self, tmp_path, monkeypatch):
+ """HERMES_BUNDLED_SKILLS env var overrides the default path resolution."""
+ custom_dir = tmp_path / "custom_skills"
+ custom_dir.mkdir()
+ monkeypatch.setenv("HERMES_BUNDLED_SKILLS", str(custom_dir))
+ assert _get_bundled_dir() == custom_dir
+
+ def test_default_without_env_var(self, monkeypatch):
+ """Without the env var, falls back to relative path from __file__."""
+ monkeypatch.delenv("HERMES_BUNDLED_SKILLS", raising=False)
+ result = _get_bundled_dir()
+ assert result.name == "skills"
+
+ def test_env_var_empty_string_ignored(self, monkeypatch):
+ """Empty HERMES_BUNDLED_SKILLS should fall back to default."""
+ monkeypatch.setenv("HERMES_BUNDLED_SKILLS", "")
+ result = _get_bundled_dir()
+ assert result.name == "skills"
diff --git a/tools/skills_sync.py b/tools/skills_sync.py
index b89e4599..f76fcced 100644
--- a/tools/skills_sync.py
+++ b/tools/skills_sync.py
@@ -37,7 +37,14 @@ MANIFEST_FILE = SKILLS_DIR / ".bundled_manifest"
def _get_bundled_dir() -> Path:
- """Locate the bundled skills/ directory in the repo."""
+ """Locate the bundled skills/ directory.
+
+ Checks HERMES_BUNDLED_SKILLS env var first (set by Nix wrapper),
+ then falls back to the relative path from this source file.
+ """
+ env_override = os.getenv("HERMES_BUNDLED_SKILLS")
+ if env_override:
+ return Path(env_override)
return Path(__file__).parent.parent / "skills"
diff --git a/website/docs/getting-started/installation.md b/website/docs/getting-started/installation.md
index 83ed9555..e3282fa8 100644
--- a/website/docs/getting-started/installation.md
+++ b/website/docs/getting-started/installation.md
@@ -59,6 +59,10 @@ The only prerequisite is **Git**. The installer automatically handles everything
You do **not** need to install Python, Node.js, ripgrep, or ffmpeg manually. The installer detects what's missing and installs it for you. Just make sure `git` is available (`git --version`).
:::
+:::tip Nix users
+If you use Nix (on NixOS, macOS, or Linux), there's a dedicated setup path with a Nix flake, declarative NixOS module, and optional container mode. See the **[Nix & NixOS Setup](./nix-setup.md)** guide.
+:::
+
---
## Manual Installation
diff --git a/website/docs/getting-started/nix-setup.md b/website/docs/getting-started/nix-setup.md
new file mode 100644
index 00000000..2adec8b4
--- /dev/null
+++ b/website/docs/getting-started/nix-setup.md
@@ -0,0 +1,822 @@
+---
+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/`.
+
+
+Building from a local clone
+
+```bash
+git clone https://github.com/NousResearch/hermes-agent.git
+cd hermes-agent
+nix build
+./result/bin/hermes setup
+```
+
+
+
+---
+
+## 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-24.11";
+ 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.
+:::
+
+### 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
+The full set of config keys is defined in [`nix/config-keys.json`](https://github.com/NousResearch/hermes-agent/blob/main/nix/config-keys.json) (127 leaf keys). You can paste your existing `config.yaml` into the `settings` attrset — the structure maps 1:1. The build-time `config-drift` check catches any drift between the reference and the Python source.
+:::
+
+
+Full example: all commonly customized settings
+
+```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";
+ extraVolumes = [ "/home/user/projects:/projects:rw" ];
+ extraOptions = [ "--gpus" "all" ];
+ };
+
+ # ── Service tuning ─────────────────────────────────────────────────
+ addToSystemPackages = true;
+ extraArgs = [ "--verbose" ];
+ restart = "always";
+ restartSec = 5;
+ };
+}
+```
+
+
+
+### 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.` | 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"` |
+| 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/.json` and persist across restarts and rebuilds.
+
+
+Initial OAuth authorization on headless servers
+
+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
+```
+
+
+
+### 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 ` | 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)
+/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)
+ │ ├── 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-drift # config keys match Python source
+nix build .#checks.x86_64-linux.config-roundtrip # merge script preserves user keys
+```
+
+
+What each check verifies
+
+| 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-drift` | Leaf keys extracted from Python's `DEFAULT_CONFIG` match the committed `nix/config-keys.json` reference |
+| `config-roundtrip` | 7 merge scenarios: fresh install, Nix override, user key preservation, mixed merge, MCP additive merge, nested deep merge, idempotency |
+
+
+
+---
+
+## 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..command` | `null` or `str` | `null` | Server command (stdio transport) |
+| `mcpServers..args` | `listOf str` | `[]` | Command arguments |
+| `mcpServers..env` | `attrsOf str` | `{}` | Environment variables for the server process |
+| `mcpServers..url` | `null` or `str` | `null` | Server endpoint URL (HTTP/StreamableHTTP transport) |
+| `mcpServers..headers` | `attrsOf str` | `{}` | HTTP headers, e.g. `Authorization` |
+| `mcpServers..auth` | `null` or `"oauth"` | `null` | Authentication method. `"oauth"` enables OAuth 2.1 PKCE |
+| `mcpServers..enabled` | `bool` | `true` | Enable or disable this server |
+| `mcpServers..timeout` | `null` or `int` | `null` | Tool call timeout in seconds (default: 120) |
+| `mcpServers..connect_timeout` | `null` or `int` | `null` | Connection timeout in seconds (default: 60) |
+| `mcpServers..tools` | `null` or `submodule` | `null` | Tool filtering (`include`/`exclude` lists) |
+| `mcpServers..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` |
+
+---
+
+## 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) |
diff --git a/website/sidebars.ts b/website/sidebars.ts
index 92a56bcc..0665662d 100644
--- a/website/sidebars.ts
+++ b/website/sidebars.ts
@@ -9,6 +9,7 @@ const sidebars: SidebarsConfig = {
items: [
'getting-started/quickstart',
'getting-started/installation',
+ 'getting-started/nix-setup',
'getting-started/updating',
'getting-started/learning-path',
],