feat: declarative NixOS module following nix-openclaw patterns

- Deep-merge config attrset → cli-config.yaml (like nix-openclaw's JSON config)
- Declarative documents option (SOUL.md, AGENTS.md, USER.md — inline or file paths)
- MCP servers as structured Nix options (merged into config)
- Environment files for secrets (systemd EnvironmentFile, outside Nix store)
- Non-secret environment vars (separate from secrets)
- Proper tmpfiles rules, activation scripts for config + document linking
- systemd hardening (NoNewPrivileges, ProtectSystem, PrivateTmp)
- Comprehensive README with full usage examples
This commit is contained in:
Ciphercat 2026-03-15 23:59:09 +00:00
commit 3dd03ba2ae
2 changed files with 437 additions and 92 deletions

210
README.md
View file

@ -1,10 +1,12 @@
# nix-hermes # nix-hermes
Nix package and NixOS module for [Hermes Agent](https://github.com/NousResearch/hermes-agent) by Nous Research. Declarative Nix package and NixOS module for [Hermes Agent](https://github.com/NousResearch/hermes-agent) by Nous Research.
Everything is configured in Nix. Config, documents, secrets, service — one `nixos-rebuild switch` and it's live.
## Quick Start ## Quick Start
### Flake usage ### 1. Add to your flake
```nix ```nix
{ {
@ -14,60 +16,196 @@ Nix package and NixOS module for [Hermes Agent](https://github.com/NousResearch/
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem { nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
modules = [ modules = [
nix-hermes.nixosModules.hermes-agent nix-hermes.nixosModules.hermes-agent
{ ./hermes.nix # your config (see below)
services.hermes-agent = {
enable = true;
environmentFile = "/run/secrets/hermes-env"; # API keys
};
}
]; ];
}; };
}; };
} }
``` ```
### Just the package ### 2. Configure declaratively
```bash ```nix
nix run github:0xrsydn/nix-hermes -- --help # hermes.nix
{ ... }:
{
services.hermes-agent = {
enable = true;
# ── Declarative config (renders to cli-config.yaml) ──
config = {
model = {
default = "anthropic/claude-opus-4.6";
provider = "openrouter";
};
terminal = {
backend = "local";
timeout = 180;
lifetime_seconds = 300;
};
agent = {
max_turns = 60;
reasoning_effort = "medium";
};
memory = {
memory_enabled = true;
user_profile_enabled = true;
memory_char_limit = 2200;
nudge_interval = 10;
};
compression = {
enabled = true;
threshold = 0.85;
summary_model = "google/gemini-3-flash-preview";
};
toolsets = [ "all" ];
};
# ── Secrets (not in Nix store) ──
environmentFiles = [
"/run/secrets/hermes-env" # ANTHROPIC_API_KEY, TELEGRAM_TOKEN, etc.
];
# ── Non-secret env vars ──
environment = {
LLM_MODEL = "anthropic/claude-opus-4.6";
};
# ── Workspace documents (inline or file paths) ──
documents = {
"SOUL.md" = ''
# SOUL.md
You are a sharp, pragmatic AI assistant.
'';
"AGENTS.md" = ''
# AGENTS.md
Read SOUL.md first. Then help the user.
'';
"USER.md" = ''
# USER.md
Name: Your Human
'';
# Or reference a file:
# "SOUL.md" = ./documents/SOUL.md;
};
# ── MCP servers ──
mcpServers = {
context7 = {
command = "npx";
args = [ "-y" "@upstash/context7-mcp@latest" ];
};
};
# ── Extra tools on PATH ──
extraPackages = with pkgs; [ jq ripgrep curl ];
};
}
``` ```
### NixOS module options ### 3. Create secrets file
```bash
# /run/secrets/hermes-env (or wherever you manage secrets)
OPENROUTER_API_KEY=sk-or-...
ANTHROPIC_API_KEY=sk-ant-...
TELEGRAM_TOKEN=123456:ABC...
TELEGRAM_ALLOWED_USERS=your_user_id
```
### 4. Deploy
```bash
nixos-rebuild switch
systemctl status hermes-agent
journalctl -u hermes-agent -f
```
## Architecture
```
You (Telegram/Discord/WhatsApp/Slack) → Gateway → Tools → Machine does things
```
### How it works
1. `services.hermes-agent.config` attrset is deep-merged and rendered to `cli-config.yaml`
2. Documents are installed into the workspace directory
3. Secrets stay outside the Nix store via `environmentFiles`
4. systemd service runs `hermes gateway` with everything wired up
### Directory layout
```
/var/lib/hermes/ # stateDir
├── .hermes/ # Hermes home (HERMES_HOME)
│ ├── cli-config.yaml # Generated from config option
│ ├── .env # Secrets (from environmentFiles)
│ ├── memory/ # Agent memory (runtime)
│ ├── skills/ # Skills (runtime)
│ └── logs/ # Session logs
├── workspace/ # workingDirectory
│ ├── SOUL.md # From documents option
│ ├── AGENTS.md
│ └── USER.md
└── logs/
└── gateway.log # Service log
```
## Module Options
| Option | Type | Default | Description | | Option | Type | Default | Description |
|--------|------|---------|-------------| |--------|------|---------|-------------|
| `enable` | bool | `false` | Enable the Hermes Agent gateway service | | `enable` | bool | `false` | Enable Hermes Agent gateway |
| `config` | attrset | `{}` | Declarative config (→ cli-config.yaml) |
| `configFile` | path | `null` | Use existing config file (overrides `config`) |
| `documents` | attrset | `{}` | Workspace files (string or path values) |
| `environmentFiles` | list | `[]` | Secret env files (systemd EnvironmentFile) |
| `environment` | attrset | `{}` | Non-secret env vars |
| `mcpServers` | attrset | `{}` | MCP server configs (merged into config) |
| `user` | string | `"hermes"` | Service user | | `user` | string | `"hermes"` | Service user |
| `group` | string | `"hermes"` | Service group | | `group` | string | `"hermes"` | Service group |
| `homeDir` | path | `/var/lib/hermes` | State directory | | `stateDir` | path | `/var/lib/hermes` | State directory |
| `workDir` | path | `${homeDir}/workspace` | Working directory | | `workingDirectory` | path | `${stateDir}/workspace` | Working directory |
| `environmentFile` | path | `null` | Secrets file (API keys, tokens) |
| `extraEnvironment` | attrs | `{}` | Extra env vars |
| `extraArgs` | list | `[]` | Extra CLI args for `hermes gateway` |
| `extraPackages` | list | `[]` | Extra packages on PATH | | `extraPackages` | list | `[]` | Extra packages on PATH |
| `extraArgs` | list | `[]` | Extra `hermes gateway` args |
| `logPath` | path | `${stateDir}/logs/gateway.log` | Log file |
| `restart` | string | `"always"` | systemd Restart policy |
| `restartSec` | int | `5` | Restart delay |
### Environment file example ## Config Reference
The `config` attrset maps directly to Hermes' `cli-config.yaml`. Key sections:
| Section | Purpose |
|---------|---------|
| `model` | Default model, provider, base_url |
| `terminal` | Backend (local/ssh/docker/modal), cwd, timeout |
| `agent` | max_turns, verbose, reasoning_effort, personalities |
| `memory` | memory_enabled, user_profile_enabled, char limits |
| `compression` | Context compression settings |
| `session_reset` | Auto-reset policy for messaging |
| `skills` | Skill creation nudge settings |
| `toolsets` | Which tool groups to enable |
| `mcp_servers` | MCP server connections |
| `delegation` | Subagent settings |
| `browser` | Browser tool settings |
| `stt` | Voice transcription config |
| `display` | UI/skin settings |
See the [full config reference](https://raw.githubusercontent.com/NousResearch/hermes-agent/main/cli-config.yaml.example).
## Just the Package
```bash ```bash
ANTHROPIC_API_KEY=sk-ant-... # Run directly
OPENROUTER_API_KEY=sk-or-... nix run github:0xrsydn/nix-hermes -- --help
TELEGRAM_TOKEN=123456:ABC...
OPENAI_API_KEY=sk-...
```
## What's included # In a dev shell
nix develop github:0xrsydn/nix-hermes
- **`hermes`** — Interactive CLI # Use the overlay
- **`hermes-agent`** — Agent runner nixpkgs.overlays = [ nix-hermes.overlays.default ];
- **`hermes-acp`** — ACP adapter
- **NixOS module** — systemd service for gateway mode
- Runtime deps wrapped: Node.js 22, ripgrep, ffmpeg, git
## Development
```bash
nix develop # Enter dev shell with hermes on PATH
nix build # Build the package
``` ```
## License ## License

View file

@ -4,122 +4,329 @@ self:
let let
cfg = config.services.hermes-agent; cfg = config.services.hermes-agent;
hermes-agent = self.packages.${pkgs.system}.hermes-agent; hermes-agent = self.packages.${pkgs.system}.hermes-agent;
# Deep-merge config type (same pattern as nix-openclaw)
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);
};
# Convert Nix attrset → YAML via JSON intermediary
# (Hermes reads YAML but YAML is a superset of JSON, so JSON works)
configJson = builtins.toJSON cfg.config;
generatedConfigFile = pkgs.writeText "cli-config.yaml" configJson;
configFile = if cfg.configFile != null then cfg.configFile else generatedConfigFile;
# Generate .env file from environment attrset (non-secret env vars)
envFileContent = lib.concatStringsSep "\n" (
lib.mapAttrsToList (k: v: "${k}=${v}") cfg.environment
);
generatedEnvFile = pkgs.writeText "hermes-env" envFileContent;
# Document files → symlinked into workspace
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
)
);
in in
{ {
options.services.hermes-agent = { options.services.hermes-agent = with lib; {
enable = lib.mkEnableOption "Hermes Agent gateway service"; enable = mkEnableOption "Hermes Agent gateway service";
package = lib.mkOption { # ── Package ──────────────────────────────────────────────────────────
type = lib.types.package; package = mkOption {
type = types.package;
default = hermes-agent; default = hermes-agent;
description = "The hermes-agent package to use."; description = "The hermes-agent package to use.";
}; };
user = lib.mkOption { # ── Service identity ─────────────────────────────────────────────────
type = lib.types.str; unitName = mkOption {
default = "hermes"; type = types.str;
description = "User under which Hermes Agent runs."; default = "hermes-agent";
description = "systemd unit name (<unitName>.service).";
}; };
group = lib.mkOption { user = mkOption {
type = lib.types.str; type = types.str;
default = "hermes"; default = "hermes";
description = "Group under which Hermes Agent runs."; description = "System user running the gateway.";
}; };
homeDir = lib.mkOption { group = mkOption {
type = lib.types.path; 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"; default = "/var/lib/hermes";
description = "Home directory for Hermes Agent state."; description = "State directory (HERMES_HOME base). Contains .hermes/ subdir.";
}; };
workDir = lib.mkOption { workingDirectory = mkOption {
type = lib.types.path; type = types.str;
default = "${cfg.homeDir}/workspace"; default = "${cfg.stateDir}/workspace";
defaultText = lib.literalExpression ''"''${cfg.homeDir}/workspace"''; defaultText = literalExpression ''"''${cfg.stateDir}/workspace"'';
description = "Working directory for Hermes Agent."; description = "Working directory for the agent (MESSAGING_CWD).";
}; };
environmentFile = lib.mkOption { # ── Config (declarative YAML) ────────────────────────────────────────
type = lib.types.nullOr lib.types.path; configFile = mkOption {
type = types.nullOr types.path;
default = null; default = null;
description = '' description = ''
Path to an environment file containing secrets (API keys, tokens). Path to an existing cli-config.yaml. If set, takes precedence over
This file should contain lines like: the declarative `config` option.
ANTHROPIC_API_KEY=sk-...
TELEGRAM_TOKEN=...
OPENROUTER_API_KEY=...
''; '';
}; };
extraEnvironment = lib.mkOption { config = mkOption {
type = lib.types.attrsOf lib.types.str; type = deepConfigType;
default = { }; default = { };
description = "Extra environment variables for the Hermes Agent service."; description = ''
example = lib.literalExpression '' Declarative Hermes config (attrset). Deep-merged across module definitions
and rendered as cli-config.yaml. See upstream cli-config.yaml.example for
all available keys.
'';
example = literalExpression ''
{ {
HERMES_DEFAULT_MODEL = "anthropic/claude-sonnet-4-20250514"; model = {
default = "anthropic/claude-sonnet-4-20250514";
provider = "openrouter";
};
terminal = {
backend = "local";
timeout = 180;
};
agent = {
max_turns = 60;
reasoning_effort = "medium";
};
memory = {
memory_enabled = true;
user_profile_enabled = true;
};
toolsets = [ "all" ];
} }
''; '';
}; };
extraArgs = lib.mkOption { # ── Secrets / environment ────────────────────────────────────────────
type = lib.types.listOf lib.types.str; environmentFiles = mkOption {
type = types.listOf types.str;
default = [ ]; default = [ ];
description = "Extra command-line arguments to pass to hermes gateway."; description = ''
Paths to environment files containing secrets (API keys, tokens).
These are passed as systemd EnvironmentFile= entries.
Use leading '-' to ignore missing files.
Example file contents:
ANTHROPIC_API_KEY=sk-ant-...
OPENROUTER_API_KEY=sk-or-...
TELEGRAM_TOKEN=123456:ABC...
'';
}; };
extraPackages = lib.mkOption { environment = mkOption {
type = lib.types.listOf lib.types.package; type = types.attrsOf types.str;
default = { };
description = ''
Non-secret environment variables for Hermes. These are written to
an env file (visible in the Nix store do NOT put secrets here).
Use `environmentFiles` for secrets.
'';
example = literalExpression ''
{
LLM_MODEL = "anthropic/claude-opus-4.6";
MESSAGING_CWD = "/var/lib/hermes/workspace";
}
'';
};
# ── Documents (SOUL.md, AGENTS.md, etc.) ─────────────────────────────
documents = mkOption {
type = types.attrsOf (types.either types.str types.path);
default = { };
description = ''
Workspace document files. Keys are filenames, values are either
inline strings or paths to files. These are symlinked into the
workspace directory on activation.
'';
example = literalExpression ''
{
"SOUL.md" = '''
# SOUL.md
You are a helpful AI assistant.
''';
"AGENTS.md" = ./my-agents.md;
"USER.md" = '''
# USER.md
Name: Fay
''';
}
'';
};
# ── Service behavior ─────────────────────────────────────────────────
logPath = mkOption {
type = types.str;
default = "${cfg.stateDir}/logs/gateway.log";
defaultText = literalExpression ''"''${cfg.stateDir}/logs/gateway.log"'';
description = "Log file path.";
};
extraArgs = mkOption {
type = types.listOf types.str;
default = [ ];
description = "Extra command-line arguments for `hermes gateway`.";
};
execStart = mkOption {
type = types.nullOr types.str;
default = null;
description = "Override ExecStart command. If unset, runs: hermes gateway.";
};
extraPackages = mkOption {
type = types.listOf types.package;
default = [ ]; default = [ ];
description = "Extra packages to make available on PATH."; description = "Extra packages to make 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.";
};
# ── MCP servers ──────────────────────────────────────────────────────
mcpServers = mkOption {
type = types.attrsOf (types.submodule {
options = {
command = mkOption { type = types.str; description = "MCP server command."; };
args = mkOption { type = types.listOf types.str; default = [ ]; };
env = mkOption { type = types.attrsOf types.str; default = { }; };
timeout = mkOption { type = types.nullOr types.int; default = null; };
};
});
default = { };
description = "MCP server configurations (merged into config.mcp_servers).";
};
}; };
config = lib.mkIf cfg.enable { config = lib.mkIf cfg.enable {
users.users.${cfg.user} = { # ── Merge MCP servers into config ────────────────────────────────────
isSystemUser = true; services.hermes-agent.config = lib.mkIf (cfg.mcpServers != { }) {
group = cfg.group; mcp_servers = lib.mapAttrs (_name: srv:
home = cfg.homeDir; { inherit (srv) command args; }
createHome = true; // lib.optionalAttrs (srv.env != { }) { inherit (srv) env; }
description = "Hermes Agent service user"; // lib.optionalAttrs (srv.timeout != null) { inherit (srv) timeout; }
) cfg.mcpServers;
}; };
users.groups.${cfg.group} = { }; # ── User / group ─────────────────────────────────────────────────────
users.groups.${cfg.group} = lib.mkIf cfg.createUser { };
users.users.${cfg.user} = lib.mkIf cfg.createUser {
isSystemUser = true;
group = cfg.group;
home = cfg.stateDir;
createHome = true;
shell = pkgs.bashInteractive;
};
systemd.services.hermes-agent = { # ── Directories ──────────────────────────────────────────────────────
systemd.tmpfiles.rules = [
"d ${cfg.stateDir} 0750 ${cfg.user} ${cfg.group} - -"
"d ${cfg.stateDir}/.hermes 0750 ${cfg.user} ${cfg.group} - -"
"d ${cfg.workingDirectory} 0750 ${cfg.user} ${cfg.group} - -"
"d ${builtins.dirOf cfg.logPath} 0750 ${cfg.user} ${cfg.group} - -"
];
# ── Activation: link config + documents into state dir ───────────────
system.activationScripts."hermes-agent-setup" = lib.stringAfter [ "users" ] ''
# Link config file
install -o ${cfg.user} -g ${cfg.group} -m 0640 -D ${configFile} ${cfg.stateDir}/.hermes/cli-config.yaml
# 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)}
'';
# ── systemd service ──────────────────────────────────────────────────
systemd.services.${cfg.unitName} = {
description = "Hermes Agent Gateway"; description = "Hermes Agent Gateway";
wantedBy = [ "multi-user.target" ];
after = [ "network-online.target" ]; after = [ "network-online.target" ];
wants = [ "network-online.target" ]; wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
environment = { environment = {
HOME = cfg.homeDir; HOME = cfg.stateDir;
HERMES_HOME = "${cfg.homeDir}/.hermes"; HERMES_HOME = "${cfg.stateDir}/.hermes";
} // cfg.extraEnvironment; MESSAGING_CWD = cfg.workingDirectory;
};
serviceConfig = { serviceConfig = {
Type = "simple";
User = cfg.user; User = cfg.user;
Group = cfg.group; Group = cfg.group;
WorkingDirectory = cfg.workDir; WorkingDirectory = cfg.workingDirectory;
ExecStart = lib.concatStringsSep " " ([
EnvironmentFile =
[ generatedEnvFile ]
++ cfg.environmentFiles;
ExecStart =
if cfg.execStart != null then cfg.execStart
else lib.concatStringsSep " " ([
"${cfg.package}/bin/hermes" "${cfg.package}/bin/hermes"
"gateway" "gateway"
] ++ cfg.extraArgs); ] ++ cfg.extraArgs);
Restart = "on-failure";
RestartSec = 5; Restart = cfg.restart;
RestartSec = cfg.restartSec;
StandardOutput = "append:${cfg.logPath}";
StandardError = "append:${cfg.logPath}";
# Hardening # Hardening
NoNewPrivileges = true; NoNewPrivileges = true;
ProtectSystem = "strict"; ProtectSystem = "strict";
ProtectHome = false; ProtectHome = false;
ReadWritePaths = [ cfg.homeDir ]; ReadWritePaths = [ cfg.stateDir ];
PrivateTmp = true; PrivateTmp = true;
} // lib.optionalAttrs (cfg.environmentFile != null) {
EnvironmentFile = cfg.environmentFile;
}; };
path = [ cfg.package ] ++ cfg.extraPackages; path = [
cfg.package
pkgs.bash
pkgs.coreutils
pkgs.git
] ++ cfg.extraPackages;
}; };
}; };
} }