{"repo":"NousResearch/hermes-agent","public_id":"4594","display_label":"Nix/NixOS matrix integration and improvements","summary":"These items address Matrix support on NixOS, including bug fixes for platform gating, overlay and package overridability, declarative plugin installation via NixOS module, and a migration from matrix-nio to mautrix-python.","subsystem":"environments","counts":{"members":6,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":2,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":7461},"resolution":{"state":"resolved","by":"pr-7461,pr-15953"},"freshness":{"state":"verified","last_verified_at":"2026-04-27T18:48:33+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.29066666666666663},"fix_edges":[{"issue_number":4594,"pr_number":7461,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":14453,"pr_number":15953,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":4594,"title":"[Setup]: Matrix does not work on NixOS setup","url":null,"state":"closed"},{"kind":"issue","number":14453,"title":"feat(nix): declarative plugin installation for NixOS module","url":null,"state":"closed"},{"kind":"pr","number":7229,"title":"feat(nix): add overlay and make package overridable","url":null,"state":"closed","close_reason":{"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":7461,"title":"fix(nix): gate matrix extra to Linux in [all] profile","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["pyproject.toml","tests/test_project_metadata.py"],"fix_hunks":[{"file":"pyproject.toml","hunks":["@@ -88,10 +88,10 @@ all = [\n   \"hermes-agent[modal]\",\n   \"hermes-agent[daytona]\",\n   \"hermes-agent[messaging]\",\n-  # matrix excluded: python-olm (required by matrix-nio[e2e]) is upstream-broken\n-  # on modern macOS (archived libolm, C++ errors with Clang 21+). Including it\n-  # here causes the entire [all] install to fail, dropping all other extras.\n-  # Users who need Matrix can install manually: pip install 'hermes-agent[matrix]'\n+  # matrix: python-olm (required by matrix-nio[e2e]) is upstream-broken on\n+  # modern macOS (archived libolm, C++ errors with Clang 21+).  On Linux the\n+  # [matrix] extra's own marker pulls in the [e2e] variant automatically.\n+  \"hermes-agent[matrix]; sys_platform == 'linux'\",\n   \"hermes-agent[cron]\",\n   \"hermes-agent[cli]\",\n   \"hermes-agent[dev]\","]},{"file":"tests/test_project_metadata.py","hunks":["@@ -11,12 +11,19 @@ def _load_optional_dependencies():\n     return project[\"optional-dependencies\"]\n \n \n-def test_matrix_extra_exists_but_excluded_from_all():\n+def test_matrix_extra_linux_only_in_all():\n     \"\"\"matrix-nio[e2e] depends on python-olm which is upstream-broken on modern\n     macOS (archived libolm, C++ errors with Clang 21+).  The [matrix] extra is\n-    kept for opt-in install but deliberately excluded from [all] so one broken\n-    upstream dep doesn't nuke every other extra during ``hermes update``.\"\"\"\n+    included in [all] but gated to Linux via a platform marker so that\n+    ``hermes update`` doesn't fail on macOS.\"\"\"\n     optional_dependencies = _load_optional_dependencies()\n \n     assert \"matrix\" in optional_dependencies\n+    # Must NOT be unconditional — python-olm has no macOS wheels.\n     assert \"hermes-agent[matrix]\" not in optional_dependencies[\"all\"]\n+    # Must be present with a Linux platform marker.\n+    linux_gated = [\n+        dep for dep in optional_dependencies[\"all\"]\n+        if \"matrix\" in dep and \"linux\" in dep\n+    ]\n+    assert linux_gated, \"expected hermes-agent[matrix] with sys_platform=='linux' marker in [all]\""]},{"file":"uv.lock","hunks":["@@ -1661,7 +1661,7 @@ dependencies = [\n     { name = \"fal-client\" },\n     { name = \"fire\" },\n     { name = \"firecrawl-py\" },\n-    { name = \"httpx\" },\n+    { name = \"httpx\", extra = [\"socks\"] },\n     { name = \"jinja2\" },\n     { name = \"openai\" },\n     { name = \"parallel-web\" },","@@ -1691,6 +1691,8 @@ all = [\n     { name = \"faster-whisper\" },\n     { name = \"honcho-ai\" },\n     { name = \"lark-oapi\" },\n+    { name = \"markdown\", marker = \"sys_platform == 'linux'\" },\n+    { name = \"matrix-nio\", extra = [\"e2e\"], marker = \"sys_platform == 'linux'\" },\n     { name = \"mcp\" },\n     { name = \"mistralai\" },\n     { name = \"modal\" },","@@ -1827,6 +1829,7 @@ requires-dist = [\n     { name = \"hermes-agent\", extras = [\"homeassistant\"], marker = \"extra == 'all'\" },\n     { name = \"hermes-agent\", extras = [\"honcho\"], marker = \"extra == 'all'\" },\n     { name = \"hermes-agent\", extras = [\"honcho\"], marker = \"extra == 'termux'\" },\n+    { name = \"hermes-agent\", extras = [\"matrix\"], marker = \"sys_platform == 'linux' and extra == 'all'\" },\n     { name = \"hermes-agent\", extras = [\"mcp\"], marker = \"extra == 'all'\" },\n     { name = \"hermes-agent\", extras = [\"mcp\"], marker = \"extra == 'termux'\" },\n     { name = \"hermes-agent\", extras = [\"messaging\"], marker = \"extra == 'all'\" },","@@ -1839,7 +1842,7 @@ requires-dist = [\n     { name = \"hermes-agent\", extras = [\"tts-premium\"], marker = \"extra == 'all'\" },\n     { name = \"hermes-agent\", extras = [\"voice\"], marker = \"extra == 'all'\" },\n     { name = \"honcho-ai\", marker = \"extra == 'honcho'\", specifier = \">=2.0.1,<3\" },\n-    { name = \"httpx\", specifier = \">=0.28.1,<1\" },\n+    { name = \"httpx\", extras = [\"socks\"], specifier = \">=0.28.1,<1\" },\n     { name = \"jinja2\", specifier = \">=3.1.5,<4\" },\n     { name = \"lark-oapi\", marker = \"extra == 'feishu'\", specifier = \">=1.5.3,<2\" },\n     { name = \"markdown\", marker = \"extra == 'matrix'\", specifier = \">=3.6,<4\" },","@@ -2033,6 +2036,9 @@ wheels = [\n http2 = [\n     { name = \"h2\" },\n ]\n+socks = [\n+    { name = \"socksio\" },\n+]\n \n [[package]]\n name = \"httpx-sse\"","@@ -4500,6 +4506,15 @@ wheels = [\n     { url = \"https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl\", hash = \"sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2\", size = 10235, upload-time = \"2024-02-25T23:20:01.196Z\" },\n ]\n \n+[[package]]\n+name = \"socksio\"\n+version = \"1.0.0\"\n+source = { registry = \"https://pypi.org/simple\" }\n+sdist = { url = \"https://files.pythonhosted.org/packages/f8/5c/48a7d9495be3d1c651198fd99dbb6ce190e2274d0f28b9051307bdec6b85/socksio-1.0.0.tar.gz\", hash = \"sha256:f88beb3da5b5c38b9890469de67d0cb0f9d494b78b106ca1845f96c10b91c4ac\", size = 19055, upload-time = \"2020-04-17T15:50:34.664Z\" }\n+wheels = [\n+    { url = \"https://files.pythonhosted.org/packages/37/c3/6eeb6034408dac0fa653d126c9204ade96b819c936e136c5e8a6897eee9c/socksio-1.0.0-py3-none-any.whl\", hash = \"sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3\", size = 12763, upload-time = \"2020-04-17T15:50:31.878Z\" },\n+]\n+\n [[package]]\n name = \"sounddevice\"\n version = \"0.5.5\"\n"]}]},{"kind":"pr","number":7488,"title":"refactor(matrix): migrate from matrix-nio to mautrix-python","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#4594","number":4594}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":15953,"title":"feat(nix): declarative plugin installation for NixOS module","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["flake.nix","nix/checks.nix","nix/hermes-agent.nix","nix/nixosModules.nix","nix/overlays.nix","nix/packages.nix","nix/python.nix","website/docs/getting-started/nix-setup.md","website/docs/guides/build-a-hermes-plugin.md","website/docs/user-guide/features/plugins.md"],"fix_hunks":[{"file":"flake.nix","hunks":["@@ -36,6 +36,7 @@\n \n       imports = [\n         ./nix/packages.nix\n+        ./nix/overlays.nix\n         ./nix/nixosModules.nix\n         ./nix/checks.nix\n         ./nix/devShell.nix"]},{"file":"nix/checks.nix","hunks":["@@ -7,9 +7,7 @@\n   perSystem = { pkgs, system, lib, ... }:\n     let\n       hermes-agent = inputs.self.packages.${system}.default;\n-      hermesVenv = pkgs.callPackage ./python.nix {\n-        inherit (inputs) uv2nix pyproject-nix pyproject-build-systems;\n-      };\n+      hermesVenv = hermes-agent.hermesVenv;\n \n       configMergeScript = pkgs.callPackage ./configMergeScript.nix { };\n ","@@ -193,6 +191,35 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2)\n           echo \"ok\" > $out/result\n         '';\n \n+        # Verify extraPythonPackages PYTHONPATH injection\n+        extra-python-packages = let\n+          testPkg = pkgs.python312Packages.pyfiglet;\n+          hermesWithExtra = hermes-agent.override {\n+            extraPythonPackages = [ testPkg ];\n+          };\n+        in pkgs.runCommand \"hermes-extra-python-packages\" { } ''\n+          set -e\n+          echo \"=== Checking extraPythonPackages PYTHONPATH injection ===\"\n+\n+          grep -q \"PYTHONPATH\" ${hermesWithExtra}/bin/hermes || \\\n+            (echo \"FAIL: PYTHONPATH not in wrapper\"; exit 1)\n+          echo \"PASS: PYTHONPATH present in wrapper\"\n+\n+          grep -q \"${testPkg}\" ${hermesWithExtra}/bin/hermes || \\\n+            (echo \"FAIL: test package path not in PYTHONPATH\"; exit 1)\n+          echo \"PASS: test package path found in wrapper\"\n+\n+          echo \"=== Checking base package has no PYTHONPATH ===\"\n+          if grep -q \"PYTHONPATH\" ${hermes-agent}/bin/hermes; then\n+            echo \"FAIL: base package should not have PYTHONPATH\"; exit 1\n+          fi\n+          echo \"PASS: base package clean\"\n+\n+          echo \"=== All extraPythonPackages checks passed ===\"\n+          mkdir -p $out\n+          echo \"ok\" > $out/result\n+        '';\n+\n         # ── Config merge + round-trip test ────────────────────────────────\n         # Tests the merge script (Nix activation behavior) across 7\n         # scenarios, then verifies Python's load_config() reads correctly."]},{"file":"nix/hermes-agent.nix","hunks":["@@ -0,0 +1,186 @@\n+# nix/hermes-agent.nix — Overridable Hermes Agent package\n+#\n+# callPackage auto-wires nixpkgs args; flake inputs are passed explicitly.\n+# Users override via: pkgs.hermes-agent.override { extraPythonPackages = [...]; }\n+{\n+  lib,\n+  stdenv,\n+  makeWrapper,\n+  callPackage,\n+  python312,\n+  nodejs_22,\n+  ripgrep,\n+  git,\n+  openssh,\n+  ffmpeg,\n+  tirith,\n+  # Flake inputs — passed explicitly by packages.nix and overlays.nix\n+  uv2nix,\n+  pyproject-nix,\n+  pyproject-build-systems,\n+  npm-lockfile-fix,\n+  # Overridable parameters\n+  extraPythonPackages ? [ ],\n+}:\n+let\n+  hermesVenv = callPackage ./python.nix {\n+    inherit uv2nix pyproject-nix pyproject-build-systems;\n+  };\n+\n+  hermesNpmLib = callPackage ./lib.nix {\n+    inherit npm-lockfile-fix;\n+  };\n+\n+  hermesTui = callPackage ./tui.nix {\n+    inherit hermesNpmLib;\n+  };\n+\n+  hermesWeb = callPackage ./web.nix {\n+    inherit hermesNpmLib;\n+  };\n+\n+  bundledSkills = lib.cleanSourceWith {\n+    src = ../skills;\n+    filter = path: _type: !(lib.hasInfix \"/index-cache/\" path);\n+  };\n+\n+  runtimeDeps = [\n+    nodejs_22\n+    ripgrep\n+    git\n+    openssh\n+    ffmpeg\n+    tirith\n+  ];\n+\n+  runtimePath = lib.makeBinPath runtimeDeps;\n+\n+  sitePackagesPath = python312.sitePackages;\n+\n+  # Walk propagatedBuildInputs to include transitive Python deps in PYTHONPATH.\n+  # Without this, a plugin listing e.g. requests as a dep would fail at runtime\n+  # if requests isn't already in the sealed uv2nix venv.\n+  allExtraPythonPackages = python312.pkgs.requiredPythonModules extraPythonPackages;\n+\n+  pythonPath = lib.makeSearchPath sitePackagesPath allExtraPythonPackages;\n+\n+  pyprojectHash = builtins.hashString \"sha256\" (builtins.readFile ../pyproject.toml);\n+  uvLockHash =\n+    if builtins.pathExists ../uv.lock then\n+      builtins.hashString \"sha256\" (builtins.readFile ../uv.lock)\n+    else\n+      \"none\";\n+in\n+stdenv.mkDerivation {\n+  pname = \"hermes-agent\";\n+  version = (builtins.fromTOML (builtins.readFile ../pyproject.toml)).project.version;\n+\n+  dontUnpack = true;\n+  dontBuild = true;\n+  nativeBuildInputs = [ makeWrapper ];\n+\n+  installPhase = ''\n+    runHook preInstall\n+\n+    mkdir -p $out/share/hermes-agent $out/bin\n+    cp -r ${bundledSkills} $out/share/hermes-agent/skills\n+    cp -r ${hermesWeb} $out/share/hermes-agent/web_dist\n+\n+    mkdir -p $out/ui-tui\n+    cp -r ${hermesTui}/lib/hermes-tui/* $out/ui-tui/\n+\n+    ${lib.concatMapStringsSep \"\\n\"\n+      (name: ''\n+        makeWrapper ${hermesVenv}/bin/${name} $out/bin/${name} \\\n+          --suffix PATH : \"${runtimePath}\" \\\n+          --set HERMES_BUNDLED_SKILLS $out/share/hermes-agent/skills \\\n+          --set HERMES_WEB_DIST $out/share/hermes-agent/web_dist \\\n+          --set HERMES_TUI_DIR $out/ui-tui \\\n+          --set HERMES_PYTHON ${hermesVenv}/bin/python3 \\\n+          --set HERMES_NODE ${nodejs_22}/bin/node \\\n+          ${lib.optionalString (extraPythonPackages != [ ]) ''--suffix PYTHONPATH : \"${pythonPath}\"''}\n+      '')\n+      [\n+        \"hermes\"\n+        \"hermes-agent\"\n+        \"hermes-acp\"\n+      ]\n+    }\n+\n+    ${lib.optionalString (extraPythonPackages != [ ]) ''\n+      echo \"=== Checking for plugin/core package collisions ===\"\n+      ${hermesVenv}/bin/python3 -c \"\n+import pathlib, sys, re\n+\n+def canonical(name):\n+    return re.sub(r'[-_.]+', '-', name).lower()\n+\n+# Collect core venv package names\n+core = set()\n+venv_sp = pathlib.Path('${hermesVenv}/${sitePackagesPath}')\n+for di in venv_sp.glob('*.dist-info'):\n+    meta = di / 'METADATA'\n+    if meta.exists():\n+        for line in meta.read_text().splitlines():\n+            if line.startswith('Name:'):\n+                core.add(canonical(line.split(':', 1)[1].strip()))\n+                break\n+\n+# Check each extra package for collisions\n+extras_dirs = [${lib.concatMapStringsSep \", \" (p: \"'${toString p}'\") allExtraPythonPackages}]\n+for edir in extras_dirs:\n+    sp = pathlib.Path(edir) / '${sitePackagesPath}'\n+    if not sp.exists():\n+        continue\n+    for di in sp.glob('*.dist-info'):\n+        meta = di / 'METADATA'\n+        if not meta.exists():\n+            continue\n+        for line in meta.read_text().splitlines():\n+            if line.startswith('Name:'):\n+                pkg = canonical(line.split(':', 1)[1].strip())\n+                if pkg in core:\n+                    print(f'ERROR: plugin package \\\"{pkg}\\\" collides with a package in hermes sealed venv', file=sys.stderr)\n+                    print(f'  from: {di}', file=sys.stderr)\n+                    print(f'  Remove this dependency from extraPythonPackages.', file=sys.stderr)\n+                    sys.exit(1)\n+                break\n+\n+print('No collisions found.')\n+      \"\n+      echo \"=== No collisions ===\"\n+    ''}\n+\n+    runHook postInstall\n+  '';\n+\n+  passthru = {\n+    inherit hermesTui hermesWeb hermesNpmLib hermesVenv;\n+\n+    devShellHook = ''\n+      STAMP=\".nix-stamps/hermes-agent\"\n+      STAMP_VALUE=\"${pyprojectHash}:${uvLockHash}\"\n+      if [ ! -f \"$STAMP\" ] || [ \"$(cat \"$STAMP\")\" != \"$STAMP_VALUE\" ]; then\n+        echo \"hermes-agent: installing Python dependencies...\"\n+        uv venv .venv --python ${python312}/bin/python3 2>/dev/null || true\n+        source .venv/bin/activate\n+        uv pip install -e \".[all]\"\n+        [ -d mini-swe-agent ] && uv pip install -e ./mini-swe-agent 2>/dev/null || true\n+        [ -d tinker-atropos ] && uv pip install -e ./tinker-atropos 2>/dev/null || true\n+        mkdir -p .nix-stamps\n+        echo \"$STAMP_VALUE\" > \"$STAMP\"\n+      else\n+        source .venv/bin/activate\n+        export HERMES_PYTHON=${hermesVenv}/bin/python3\n+      fi\n+    '';\n+  };\n+\n+  meta = with lib; {\n+    description = \"AI agent with advanced tool-calling capabilities\";\n+    homepage = \"https://github.com/NousResearch/hermes-agent\";\n+    mainProgram = \"hermes\";\n+    license = licenses.mit;\n+    platforms = platforms.unix;\n+  };\n+}"]},{"file":"nix/nixosModules.nix","hunks":["@@ -28,6 +28,8 @@\n \n   let\n     cfg = config.services.hermes-agent;\n+    effectivePackage = if cfg.extraPythonPackages == [ ] then cfg.package\n+      else cfg.package.override { inherit (cfg) extraPythonPackages; };\n     hermes-agent = inputs.self.packages.${pkgs.stdenv.hostPlatform.system}.default;\n \n     # Deep-merge config type (from 0xrsydn/nix-hermes-agent)","@@ -456,6 +458,52 @@\n         description = \"Extra packages available on PATH.\";\n       };\n \n+      extraPlugins = mkOption {\n+        type = types.listOf types.package;\n+        default = [ ];\n+        description = ''\n+          Directory-based plugin packages to symlink into the hermes plugins\n+          directory. Each package should contain a plugin.yaml and __init__.py\n+          at its root. Hermes discovers these automatically on startup.\n+        '';\n+        example = literalExpression ''\n+          [\n+            (pkgs.fetchFromGitHub {\n+              owner = \"stephenschoettler\";\n+              repo = \"hermes-lcm\";\n+              name = \"hermes-lcm\";\n+              rev = \"v0.7.0\";\n+              hash = \"sha256-...\";\n+            })\n+          ]\n+        '';\n+      };\n+\n+      extraPythonPackages = mkOption {\n+        type = types.listOf types.package;\n+        default = [ ];\n+        description = ''\n+          Python packages to add to PYTHONPATH for entry-point plugin discovery.\n+          These are pip-packaged plugins that register via the\n+          hermes_agent.plugins entry-point group. Each package must be built\n+          with the same Python interpreter as hermes (python312).\n+        '';\n+        example = literalExpression ''\n+          [\n+            (pkgs.python312Packages.buildPythonPackage {\n+              pname = \"rtk-hermes\";\n+              version = \"1.0.0\";\n+              src = pkgs.fetchFromGitHub {\n+                owner = \"ogallotti\";\n+                repo = \"rtk-hermes\";\n+                rev = \"main\";\n+                hash = \"sha256-...\";\n+              };\n+            })\n+          ]\n+        '';\n+      };\n+\n       restart = mkOption {\n         type = types.str;\n         default = \"always\";","@@ -570,7 +618,7 @@\n       # so interactive shells share state (sessions, skills, cron) with the\n       # gateway service instead of creating a separate ~/.hermes/.\n       (lib.mkIf cfg.addToSystemPackages {\n-        environment.systemPackages = [ cfg.package ];\n+        environment.systemPackages = [ effectivePackage ];\n         environment.variables.HERMES_HOME = \"${cfg.stateDir}/.hermes\";\n       })\n ","@@ -581,6 +629,16 @@\n         });\n       })\n \n+      # ── Assertions ─────────────────────────────────────────────────────\n+      {\n+        assertions = let\n+          names = map lib.getName cfg.extraPlugins;\n+        in [{\n+          assertion = (lib.length names) == (lib.length (lib.unique names));\n+          message = \"services.hermes-agent.extraPlugins: duplicate plugin names detected: ${toString names}. If using fetchFromGitHub, set name = \\\"plugin-name\\\" to disambiguate.\";\n+        }];\n+      }\n+\n       # ── Warnings ──────────────────────────────────────────────────────\n       (lib.mkIf (cfg.container.enable && !cfg.addToSystemPackages && cfg.container.hostUsers != []) {\n         warnings = [","@@ -602,6 +660,7 @@\n           \"d ${cfg.stateDir}/.hermes/sessions 2770 ${cfg.user} ${cfg.group} - -\"\n           \"d ${cfg.stateDir}/.hermes/logs   2770 ${cfg.user} ${cfg.group} - -\"\n           \"d ${cfg.stateDir}/.hermes/memories 2770 ${cfg.user} ${cfg.group} - -\"\n+          \"d ${cfg.stateDir}/.hermes/plugins 2770 ${cfg.user} ${cfg.group} - -\"\n           \"d ${cfg.stateDir}/home           0750 ${cfg.user} ${cfg.group} - -\"\n           \"d ${cfg.workingDirectory}         2770 ${cfg.user} ${cfg.group} - -\"\n         ];","@@ -623,7 +682,7 @@\n           find ${cfg.stateDir}/.hermes -maxdepth 1 \\\n             \\( -name \"*.db\" -o -name \"*.db-wal\" -o -name \"*.db-shm\" -o -name \"SOUL.md\" \\) \\\n             -exec chmod g+rw {} + 2>/dev/null || true\n-          for _subdir in cron sessions logs memories; do\n+          for _subdir in cron sessions logs memories plugins; do\n             mkdir -p \"${cfg.stateDir}/.hermes/$_subdir\"\n             chown ${cfg.user}:${cfg.group} \"${cfg.stateDir}/.hermes/$_subdir\"\n             chmod 2770 \"${cfg.stateDir}/.hermes/$_subdir\"","@@ -732,6 +791,22 @@ HERMES_NIX_ENV_EOF\n           ${lib.concatStringsSep \"\\n\" (lib.mapAttrsToList (name: _value: ''\n             install -o ${cfg.user} -g ${cfg.group} -m 0640 ${documentDerivation}/${name} ${cfg.workingDirectory}/${name}\n           '') cfg.documents)}\n+\n+        # ── Declarative plugins ─────────────────────────────────────────\n+        # Remove stale managed symlinks (plugins removed from config)\n+        find ${cfg.stateDir}/.hermes/plugins -maxdepth 1 -type l -name 'nix-managed-*' -delete 2>/dev/null || true\n+\n+        ${lib.concatStringsSep \"\\n\" (map (plugin:\n+          let\n+            name = lib.getName plugin;\n+          in ''\n+            if [ ! -f \"${plugin}/plugin.yaml\" ]; then\n+              echo \"ERROR: extraPlugins entry '${plugin}' has no plugin.yaml\" >&2\n+              exit 1\n+            fi\n+            ln -sfn ${plugin} ${cfg.stateDir}/.hermes/plugins/nix-managed-${name}\n+            chown -h ${cfg.user}:${cfg.group} ${cfg.stateDir}/.hermes/plugins/nix-managed-${name}\n+          '') cfg.extraPlugins)}\n         '';\n       }\n ","@@ -762,7 +837,7 @@ HERMES_NIX_ENV_EOF\n             # reads them at Python startup — no systemd EnvironmentFile needed.\n \n             ExecStart = lib.concatStringsSep \" \" ([\n-              \"${cfg.package}/bin/hermes\"\n+              \"${effectivePackage}/bin/hermes\"\n               \"gateway\"\n             ] ++ cfg.extraArgs);\n ","@@ -785,7 +860,7 @@ HERMES_NIX_ENV_EOF\n           };\n \n           path = [\n-            cfg.package\n+            effectivePackage\n             pkgs.bash\n             pkgs.coreutils\n             pkgs.git","@@ -810,11 +885,11 @@ HERMES_NIX_ENV_EOF\n \n           preStart = ''\n             # Stable symlinks — container references these, not store paths directly\n-            ln -sfn ${cfg.package} ${cfg.stateDir}/current-package\n+            ln -sfn ${effectivePackage} ${cfg.stateDir}/current-package\n             ln -sfn ${containerEntrypoint} ${cfg.stateDir}/current-entrypoint\n \n             # GC roots so nix-collect-garbage doesn't remove store paths in use\n-            ${pkgs.nix}/bin/nix-store --add-root ${cfg.stateDir}/.gc-root --indirect -r ${cfg.package} 2>/dev/null || true\n+            ${pkgs.nix}/bin/nix-store --add-root ${cfg.stateDir}/.gc-root --indirect -r ${effectivePackage} 2>/dev/null || true\n             ${pkgs.nix}/bin/nix-store --add-root ${cfg.stateDir}/.gc-root-entrypoint --indirect -r ${containerEntrypoint} 2>/dev/null || true\n \n             # Check if container needs (re)creation"]},{"file":"nix/overlays.nix","hunks":["@@ -0,0 +1,10 @@\n+# nix/overlays.nix — Expose pkgs.hermes-agent for external NixOS configs\n+{ inputs, ... }:\n+{\n+  flake.overlays.default = final: _: {\n+    hermes-agent = final.callPackage ./hermes-agent.nix {\n+      inherit (inputs) uv2nix pyproject-nix pyproject-build-systems;\n+      npm-lockfile-fix = inputs.npm-lockfile-fix.packages.${final.stdenv.hostPlatform.system}.default;\n+    };\n+  };\n+}"]},{"file":"nix/packages.nix","hunks":["@@ -4,120 +4,19 @@\n   perSystem =\n     { pkgs, inputs', ... }:\n     let\n-      hermesVenv = pkgs.callPackage ./python.nix {\n+      hermesAgent = pkgs.callPackage ./hermes-agent.nix {\n         inherit (inputs) uv2nix pyproject-nix pyproject-build-systems;\n-      };\n-\n-      hermesNpmLib = pkgs.callPackage ./lib.nix {\n         npm-lockfile-fix = inputs'.npm-lockfile-fix.packages.default;\n       };\n-\n-      hermesTui = pkgs.callPackage ./tui.nix {\n-        inherit hermesNpmLib;\n-      };\n-\n-      # Import bundled skills, excluding runtime caches\n-      bundledSkills = pkgs.lib.cleanSourceWith {\n-        src = ../skills;\n-        filter = path: _type: !(pkgs.lib.hasInfix \"/index-cache/\" path);\n-      };\n-\n-      hermesWeb = pkgs.callPackage ./web.nix {\n-        inherit hermesNpmLib;\n-      };\n-\n-      runtimeDeps = with pkgs; [\n-        nodejs_22\n-        ripgrep\n-        git\n-        openssh\n-        ffmpeg\n-        tirith\n-      ];\n-\n-      runtimePath = pkgs.lib.makeBinPath runtimeDeps;\n-\n-      # Lockfile hashes for dev shell stamps\n-      pyprojectHash = builtins.hashString \"sha256\" (builtins.readFile ../pyproject.toml);\n-      uvLockHash =\n-        if builtins.pathExists ../uv.lock then\n-          builtins.hashString \"sha256\" (builtins.readFile ../uv.lock)\n-        else\n-          \"none\";\n     in\n     {\n       packages = {\n-        default = pkgs.stdenv.mkDerivation {\n-          pname = \"hermes-agent\";\n-          version = (fromTOML (builtins.readFile ../pyproject.toml)).project.version;\n-\n-          dontUnpack = true;\n-          dontBuild = true;\n-          nativeBuildInputs = [ pkgs.makeWrapper ];\n-\n-          installPhase = ''\n-            runHook preInstall\n-\n-            mkdir -p $out/share/hermes-agent $out/bin\n-            cp -r ${bundledSkills} $out/share/hermes-agent/skills\n-            cp -r ${hermesWeb} $out/share/hermes-agent/web_dist\n-\n-            # copy pre-built TUI (same layout as dev: ui-tui/dist/ + node_modules/)\n-            mkdir -p $out/ui-tui\n-            cp -r ${hermesTui}/lib/hermes-tui/* $out/ui-tui/\n-\n-            ${pkgs.lib.concatMapStringsSep \"\\n\"\n-              (name: ''\n-                makeWrapper ${hermesVenv}/bin/${name} $out/bin/${name} \\\n-                  --suffix PATH : \"${runtimePath}\" \\\n-                  --set HERMES_BUNDLED_SKILLS $out/share/hermes-agent/skills \\\n-                  --set HERMES_WEB_DIST $out/share/hermes-agent/web_dist \\\n-                  --set HERMES_TUI_DIR $out/ui-tui \\\n-                  --set HERMES_PYTHON ${hermesVenv}/bin/python3 \\\n-                  --set HERMES_NODE ${pkgs.nodejs_22}/bin/node\n-              '')\n-              [\n-                \"hermes\"\n-                \"hermes-agent\"\n-                \"hermes-acp\"\n-              ]\n-            }\n-\n-            runHook postInstall\n-          '';\n-\n-          passthru.devShellHook = ''\n-            STAMP=\".nix-stamps/hermes-agent\"\n-            STAMP_VALUE=\"${pyprojectHash}:${uvLockHash}\"\n-            if [ ! -f \"$STAMP\" ] || [ \"$(cat \"$STAMP\")\" != \"$STAMP_VALUE\" ]; then\n-              echo \"hermes-agent: installing Python dependencies...\"\n-              uv venv .venv --python ${pkgs.python312}/bin/python3 2>/dev/null || true\n-              source .venv/bin/activate\n-              uv pip install -e \".[all]\"\n-              [ -d mini-swe-agent ] && uv pip install -e ./mini-swe-agent 2>/dev/null || true\n-              [ -d tinker-atropos ] && uv pip install -e ./tinker-atropos 2>/dev/null || true\n-              mkdir -p .nix-stamps\n-              echo \"$STAMP_VALUE\" > \"$STAMP\"\n-            else\n-              source .venv/bin/activate\n-              export HERMES_PYTHON=${hermesVenv}/bin/python3\n-            fi\n-          '';\n-\n-          meta = with pkgs.lib; {\n-            description = \"AI agent with advanced tool-calling capabilities\";\n-            homepage = \"https://github.com/NousResearch/hermes-agent\";\n-            mainProgram = \"hermes\";\n-            license = licenses.mit;\n-            platforms = platforms.unix;\n-          };\n-        };\n-\n-        tui = hermesTui;\n-        web = hermesWeb;\n+        default = hermesAgent;\n+        tui = hermesAgent.hermesTui;\n+        web = hermesAgent.hermesWeb;\n \n-        fix-lockfiles = hermesNpmLib.mkFixLockfiles {\n-          packages = [ hermesTui hermesWeb ];\n+        fix-lockfiles = hermesAgent.hermesNpmLib.mkFixLockfiles {\n+          packages = [ hermesAgent.hermesTui hermesAgent.hermesWeb ];\n         };\n       };\n     };"]},{"file":"nix/python.nix","hunks":["@@ -7,6 +7,7 @@\n   pyproject-nix,\n   pyproject-build-systems,\n   stdenv,\n+  dependency-groups ? [ \"all\" ],\n }:\n let\n   workspace = uv2nix.lib.workspace.loadWorkspace { workspaceRoot = ./..; };","@@ -96,5 +97,5 @@ let\n       ]);\n in\n pythonSet.mkVirtualEnv \"hermes-agent-env\" {\n-  hermes-agent = [ \"all\" ];\n+  hermes-agent = dependency-groups;\n }"]},{"file":"website/docs/getting-started/nix-setup.md","hunks":["@@ -599,6 +599,93 @@ The `preStart` script creates a GC root at `${stateDir}/.gc-root` pointing to th\n \n ---\n \n+## Plugins\n+\n+The NixOS module supports declarative plugin installation — no imperative `hermes plugins install` needed.\n+\n+### Directory Plugins (`extraPlugins`)\n+\n+For plugins that are just a source tree with `plugin.yaml` + `__init__.py` (e.g., [hermes-lcm](https://github.com/stephenschoettler/hermes-lcm)):\n+\n+```nix\n+services.hermes-agent.extraPlugins = [\n+  (pkgs.fetchFromGitHub {\n+    owner = \"stephenschoettler\";\n+    repo = \"hermes-lcm\";\n+    rev = \"v0.7.0\";\n+    hash = \"sha256-...\";\n+  })\n+];\n+```\n+\n+Plugins are symlinked into `$HERMES_HOME/plugins/` at activation time. Hermes discovers them via its normal directory scan. Removing a plugin from the list and running `nixos-rebuild switch` removes the symlink.\n+\n+### Entry-Point Plugins (`extraPythonPackages`)\n+\n+For pip-packaged plugins that register via `[project.entry-points.\"hermes_agent.plugins\"]` (e.g., [rtk-hermes](https://github.com/ogallotti/rtk-hermes)):\n+\n+```nix\n+services.hermes-agent.extraPythonPackages = [\n+  (pkgs.python312Packages.buildPythonPackage {\n+    pname = \"rtk-hermes\";\n+    version = \"1.0.0\";\n+    src = pkgs.fetchFromGitHub {\n+      owner = \"ogallotti\";\n+      repo = \"rtk-hermes\";\n+      rev = \"v1.0.0\";\n+      hash = \"sha256-...\";\n+    };\n+    format = \"pyproject\";\n+    build-system = [ pkgs.python312Packages.setuptools ];\n+  })\n+];\n+```\n+\n+The package's `site-packages` is added to PYTHONPATH in the hermes wrapper. `importlib.metadata` discovers the entry point at session start.\n+\n+### Combining Both\n+\n+A directory plugin with third-party Python dependencies needs both options:\n+\n+```nix\n+services.hermes-agent = {\n+  extraPlugins = [ my-plugin-src ];          # plugin source\n+  extraPythonPackages = [ pkgs.python312Packages.redis ];  # its Python dep\n+  extraPackages = [ pkgs.redis ];            # system binary it needs\n+};\n+```\n+\n+### Using the Overlay\n+\n+External flakes can override the package directly:\n+\n+```nix\n+{\n+  inputs.hermes-agent.url = \"github:NousResearch/hermes-agent\";\n+  outputs = { hermes-agent, nixpkgs, ... }: {\n+    nixpkgs.overlays = [ hermes-agent.overlays.default ];\n+    # Then: pkgs.hermes-agent.override { extraPythonPackages = [...]; }\n+  };\n+}\n+```\n+\n+### Plugin Configuration\n+\n+Plugins still need to be enabled in `config.yaml`. Add them via the declarative settings:\n+\n+```nix\n+services.hermes-agent.settings.plugins.enabled = [\n+  \"hermes-lcm\"\n+  \"rtk-rewrite\"\n+];\n+```\n+\n+:::note\n+A build-time collision check prevents plugin packages from shadowing core hermes dependencies. If a plugin provides a package already in the sealed venv, `nixos-rebuild` fails with a clear error.\n+:::\n+\n+---\n+\n ## Development\n \n ### Dev Shell","@@ -721,6 +808,8 @@ nix build .#checks.x86_64-linux.config-roundtrip    # merge script preserves use\n |---|---|---|---|\n | `extraArgs` | `listOf str` | `[]` | Extra args for `hermes gateway` |\n | `extraPackages` | `listOf package` | `[]` | Extra packages on service PATH (native mode only) |\n+| `extraPlugins` | `listOf package` | `[]` | Directory plugin packages to symlink into `$HERMES_HOME/plugins/`. Each must contain `plugin.yaml` |\n+| `extraPythonPackages` | `listOf package` | `[]` | Python packages added to PYTHONPATH for entry-point plugin discovery. Build with `python312Packages` |\n | `restart` | `str` | `\"always\"` | systemd `Restart=` policy |\n | `restartSec` | `int` | `5` | systemd `RestartSec=` value |\n "]},{"file":"website/docs/guides/build-a-hermes-plugin.md","hunks":["@@ -633,6 +633,43 @@ pip install hermes-plugin-calculator\n # Plugin auto-discovered on next hermes startup\n ```\n \n+### Distribute for NixOS\n+\n+NixOS users can install your plugin declaratively if you provide a `pyproject.toml` with entry points:\n+\n+**Entry-point plugins** (recommended for distribution):\n+```nix\n+# User's configuration.nix\n+services.hermes-agent.extraPythonPackages = [\n+  (pkgs.python312Packages.buildPythonPackage {\n+    pname = \"my-plugin\";\n+    version = \"1.0.0\";\n+    src = pkgs.fetchFromGitHub {\n+      owner = \"you\";\n+      repo = \"hermes-my-plugin\";\n+      rev = \"v1.0.0\";\n+      hash = \"sha256-...\";  # nix-prefetch-url --unpack\n+    };\n+    format = \"pyproject\";\n+    build-system = [ pkgs.python312Packages.setuptools ];\n+  })\n+];\n+```\n+\n+**Directory plugins** (no `pyproject.toml` needed):\n+```nix\n+services.hermes-agent.extraPlugins = [\n+  (pkgs.fetchFromGitHub {\n+    owner = \"you\";\n+    repo = \"hermes-my-plugin\";\n+    rev = \"v1.0.0\";\n+    hash = \"sha256-...\";\n+  })\n+];\n+```\n+\n+See the [Nix Setup guide](/docs/getting-started/nix-setup#plugins) for complete documentation including overlay usage and collision checking.\n+\n ## Common mistakes\n \n **Handler doesn't return JSON string:**"]},{"file":"website/docs/user-guide/features/plugins.md","hunks":["@@ -99,6 +99,7 @@ Project-local plugins under `./.hermes/plugins/` are disabled by default. Enable\n | User | `~/.hermes/plugins/` | Personal plugins |\n | Project | `.hermes/plugins/` | Project-specific plugins (requires `HERMES_ENABLE_PROJECT_PLUGINS=true`) |\n | pip | `hermes_agent.plugins` entry_points | Distributed packages |\n+| Nix | `services.hermes-agent.extraPlugins` / `extraPythonPackages` | NixOS declarative installs — see [Nix Setup](/docs/getting-started/nix-setup#plugins) |\n \n Later sources override earlier ones on name collision, so a user plugin with the same name as a bundled plugin replaces it.\n ","@@ -155,6 +156,23 @@ Hermes has three kinds of plugins:\n \n Memory providers and context engines are **provider plugins** — only one of each type can be active at a time. General plugins can be enabled in any combination.\n \n+## NixOS declarative plugins\n+\n+On NixOS, plugins can be installed declaratively via the module options — no `hermes plugins install` needed. See the **[Nix Setup guide](/docs/getting-started/nix-setup#plugins)** for full details.\n+\n+```nix\n+services.hermes-agent = {\n+  # Directory plugin (source tree with plugin.yaml)\n+  extraPlugins = [ (pkgs.fetchFromGitHub { ... }) ];\n+  # Entry-point plugin (pip package)\n+  extraPythonPackages = [ (pkgs.python312Packages.buildPythonPackage { ... }) ];\n+  # Enable in config\n+  settings.plugins.enabled = [ \"my-plugin\" ];\n+};\n+```\n+\n+Declarative plugins are symlinked with a `nix-managed-` prefix — they coexist with manually installed plugins and are cleaned up automatically when removed from the Nix config.\n+\n ## Managing plugins\n \n ```bash\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}