Hermes Agent Deep Cuts: Three Gates Between `plugins install` and a Tool the Model Can Call
Part of the Hermes Agent: Deep Cuts series

Hermes Agent Deep Cuts: Three Gates Between `plugins install` and a Tool the Model Can Call

hermes plugins list on this machine prints more than eighty entries, and every single one says not enabled. Browser automation works. Image generation works. Kanban works. The mem0 memory provider works. All of it shipped through the plugin system, none of it marked enabled. So either “not enabled” means nothing, or it means something narrower than you assumed. It means the latter, and the difference is the whole architecture.

This post is part of the ongoing Deep Cuts series, one feature per post, written past the happy path. Today: the plugin system, on Hermes Agent v0.20.1 (2026.8.13).

The mechanism: four sources, one registry

Hermes discovers plugins from four places, in this order, with later sources overriding earlier ones on name collision:

  1. Bundled<repo>/plugins/<name>/, shipped with the install
  2. User~/.hermes/plugins/<name>/
  3. Project./.hermes/plugins/<name>/, disabled unless you set HERMES_ENABLE_PROJECT_PLUGINS=true
  4. Pip — packages exposing the hermes_agent.plugins entry-point group

Every directory plugin needs two things: a plugin.yaml manifest and an __init__.py with a register(ctx) function. register() runs exactly once at startup. Everything a plugin can do flows through ctx: register_tool() puts a tool in the same registry the built-ins use, register_hook() subscribes to lifecycle events, and the rest of the surface covers slash commands (register_command), CLI subcommands (register_cli_command), bundled skills (register_skill), gateway platforms (register_platform), image/video-gen backends, context engines, approval transports, MCP calls, and host-owned LLM calls via ctx.llm.complete().

The manifest declares what the plugin claims to do, and the loader checks the claim. provides_tools and provides_hooks are lists; the manifest kind field routes the plugin to the right loader:

  • standalone — general plugins, gated by plugins.enabled
  • backend — pluggable backend for a core tool (image_gen, web providers); bundled backends auto-load
  • exclusive — categories with exactly one active provider (memory)
  • platform — gateway messaging adapters; bundled platform plugins auto-load

The hook surface is the interesting part. VALID_HOOKS in hermes_cli/plugins.py has 37 names on this install, and the docs’ “26 lifecycle events” figure is stale. They split into three behavioral families, and the distinction matters:

  • Directive hookspre_tool_call can return {"action": "block"} to veto a call or {"action": "approve"} to escalate it to the human approval gate. pre_verify can keep the agent going instead of finishing a turn.
  • Transform hookstransform_tool_result, transform_terminal_output, transform_llm_output, transform_api_error_classification rewrite what the model sees. The first non-None return wins.
  • Observers — everything else: pre_llm_call, post_llm_call, the stream hooks, pre/post_api_request, api_request_error, session lifecycle, kanban worker lifecycle, pre_approval_request / post_approval_response. Return values are ignored.

Two design choices stand out. First, pre_llm_call context injection appends to the user message, never the system prompt, because the system prompt must stay byte-identical across turns to keep Anthropic and OpenRouter prefix caches warm. A plugin that wants the model to remember something pays the cache price of zero. Second, injected context is capped at 10,000 characters per hook by default; anything over spills to $HERMES_HOME/hook_outputs/<session>/<uuid>.txt with a head/tail preview, so a runaway plugin cannot inflate every turn’s prompt. That cap is in the hooks.output_spill config block.

Plugins can go further than hooks: middleware (tool_request, llm_request, tool_execution, llm_execution) rewrites the effective payload before hooks and approvals see it. ctx.dispatch_tool() lets a plugin invoke any built-in tool through the normal approval, redaction, and budget pipelines. It is a real tool call, not a shortcut around them. The bundled security-guidance plugin shows the pattern: it hooks transform_tool_result, scans file writes for eval(, pickle.load, yaml.load, verify=False and friends, and appends a warning to the tool result instead of blocking. Files still get written; the model sees the warning next turn and self-corrects. Block mode exists behind SECURITY_GUIDANCE_BLOCK=1.

Gate one: plugins.enabled (does it load?)

General plugins are opt-in. Discovery finds everything, but nothing with hooks or tools executes until the plugin’s name lands in plugins.enabled in ~/.hermes/config.yaml:

plugins:
  enabled:
    - my-tool-plugin
    - disk-cleanup
  disabled:      # deny-list — always wins if a name appears in both
    - noisy-plugin

There are three states, and the CLI is careful to distinguish them: enabled (in the allow-list), disabled (explicitly off), and not enabled (discovered, never opted in). That third state is what the 80 rows in hermes plugins list are showing me.

The catch is the exceptions table, straight from the docs: bundled platform plugins, bundled backends, memory providers, context engines, and model providers bypass the gate entirely. They auto-load because gating them would break the base install. The allow-list exists specifically for arbitrary code you drop into ~/.hermes/plugins/. So not enabled on a bundled entry tells you almost nothing about whether it is running. The migration path has the same asymmetry: when opt-in plugins landed (config schema v21+), already-installed user plugins were automatically grandfathered into plugins.enabled, but bundled standalone plugins were not. Existing users had to opt into bundled plugins by hand.

Since #64228, plugins declare privileged host surfaces in the manifest, and the user consents once at install or enable time:

name: my-plugin
capabilities:
  - tools.override        # replace built-in tools
  - llm.model_override    # pick the model for host-owned LLM calls

There are seven capability ids, each mapping 1:1 to an enforcing gate that already existed: tools.override, llm.provider_override, llm.model_override, llm.agent_id_override, llm.profile_override, llm.task_override, gateway.platform_actions. The legacy plugins.entries.<id>.allow_* keys keep working but are deprecated; a gate is open when either the capability is granted or the legacy key is set.

The consent state is what makes this interesting. It records a hash of the capability set the user saw, plus a timestamp. When a plugin update declares capabilities whose set hash differs, the additions stay ungranted until the user re-consents. A plugin update can never silently widen its access. And the ground rule is spelled out in hermes_cli/plugin_capabilities.py: everything defaults OFF. Any failure to read consent state means not granted. Non-interactive installs (cron, CI) complete the install but never grant declared capabilities; you have to run hermes plugins enable <id> interactively later. On this box, hermes plugins capabilities returns exactly No plugins declare or hold capabilities.

Here is the sentence every operator needs to internalize, quoted from the module docstring:

This is NOT a sandbox. In-process Python plugins remain trusted code — a malicious plugin can import anything, monkey-patch core, and ignore all of this.

Capabilities govern which registrations succeed and which ctx methods are live. They are a consent and audit layer. The only real boundary is your decision about which code you import, and the system says so out loud rather than pretending otherwise.

Gate three: the toolset filter (does the model see the tools?)

This is the gate nobody expects, and the one that produces the classic “I enabled the plugin and nothing happened” report. Plugins register tools under a toolset name, and toolset visibility is decided per platform by hermes tools, the same picker that controls built-in toolsets. The relevant logic lives in hermes_cli/tools_config.py:

  • A plugin toolset explicitly listed in the platform’s saved config is enabled.
  • A toolset in _DEFAULT_OFF_TOOLSETS stays off until the user picks it. That set is {"homeassistant", "spotify", "discord", "discord_admin", "video", "video_gen", "x_search", "a2a"}.
  • A new plugin not yet seen by hermes tools defaults to enabled.
  • A plugin that hermes tools has seen but is absent from the saved list is disabled. That is the “user turned it off” signal, tracked via known_plugin_toolsets in config.

The spotify plugin is the canonical example. It ships bundled, its manifest declares seven tools, and it sits in _DEFAULT_OFF_TOOLSETS because nobody wants seven Spotify tool schemas in the model’s context unless they actually use Spotify. Enable the plugin, restart, and there are no Spotify tools. This profile’s config carries the trace of exactly this: known_plugin_toolsets: cli: [a2a, spotify] — the toolsets were seen by the picker, saved, and therefore stay off until toggled on.

So the full path from plugins install to “the model can call it” is three hops: the plugin must be enabled (load), its privileged surfaces must be consented (capability grants), and its toolset must be visible on the platform (toolset distribution). Each fails closed.

Advanced usage: pin, pack, doctor

Installation is designed around reproducibility. hermes plugins install owner/repo --ref <40-char-sha> checks out the commit detached, verifies HEAD matches the requested SHA, and records the canonical source, installed revision, and pin in profile-local metadata. Tags, branches, and abbreviated SHAs are rejected. A pinned plugin cannot be moved by hermes plugins update; you choose a new exact commit explicitly with --force --ref. The metadata contains no config values, env values, secrets, or capability grants.

The community index (hermes plugins search) is a static JSON catalog fetched from NousResearch/hermes-plugin-index, cached for 24 hours, with a bundled seed for offline use. My search just now returned No plugins matched 'spotify' (index source: seed), which is the seed cache talking. The docs are blunt about what index inclusion means: indexed ≠ audited. Review covers the entry’s metadata only; installing still goes through the normal consent flow.

Plugin packs are the shareable layer on top. A hermes-pack.yaml pins a set of plugins; pack install shows a mandatory review screen, asks one confirmation for the pack contents, then runs the standard per-plugin capability prompts. There is no --yes, non-interactive sessions cannot install packs, and secret-shaped keys, capability grants, and allow_* gates are rejected in pack config seeds and stripped on export. Partial failure is fine: each plugin installs independently and the command exits non-zero only if any failed.

hermes plugins doctor [path-or-id] is the verification tool that most users never find. It runs the same discovery, manifest parsing, namespaced import, register(ctx), hook registry, and tool registry that Hermes itself uses, against a temporary HERMES_HOME, and blocks direct socket connections during registration. Real output from this box, against the bundled security-guidance plugin:

$ hermes plugins doctor security-guidance
Plugin Doctor: /home/dazeb/.hermes/hermes-agent/plugins/security-guidance
  manifest: security-guidance 0.1.0 (standalone)
  WARN: registration adds hook 'pre_tool_call' not listed in provides_hooks
  WARN: registration adds hook 'transform_tool_result' not listed in provides_hooks
  OK: runtime discovery, manifest parsing, import, and registration passed
  registrations: 0 tool(s), 2 hook(s)

The WARN lines are the drift check doing its job: security-guidance registers two hooks but its manifest declares none, so the declared/registered mismatch is surfaced. That is exactly the kind of silent rot doctor exists to catch. --ci makes errors exit non-zero for CI use.

Two distribution details worth knowing. requires_env gates loading on environment variables and prompts for missing ones during install. python_dependencies (manifest v2) is declaration and surfacing only — Hermes validates it and prints a pip install hint, but never auto-installs, because installing arbitrary packages into the shared venv is a supply-chain surface the project has explicitly deferred. Your plugin’s SDK is your problem.

The gotchas that make the happy path fail

  1. You enabled the plugin and the tools never appeared. Almost always gate three, not a broken install. If the toolset is in _DEFAULT_OFF_TOOLSETS (spotify, a2a, x_search, homeassistant, discord, video), toggle it in hermes tools. If it is “known but absent,” hermes tools saw it and you (or an earlier config) turned it off.

  2. Non-interactive installs silently grant nothing. A plugin installed by a script or cron job declares capabilities; they stay ungranted until someone runs hermes plugins enable interactively. A well-behaved plugin probes with ctx.has_capability() and degrades; a badly written one just fails at the boundary.

  3. Bundled standalone plugins are not grandfathered. Upgrade to opt-in schema and your previously-loaded bundled plugins stop loading until you enable them. User plugins were grandfathered; bundled ones were not, on purpose.

  4. A crashing register() disables the plugin. The loader catches exceptions and continues. The failure is logged, but if you are not tailing ~/.hermes/logs/agent.log, your plugin just quietly stops existing. HERMES_PLUGINS_DEBUG=1 hermes plugins list is the tool for this: it prints every skip reason and a full traceback on register failure.

  5. Overriding a built-in tool needs two opt-ins. register_tool(..., override=True) plus the tools.override capability or the legacy allow_tool_override: true key. Without the grant you get PluginToolOverrideError and the plugin is disabled. This is deliberate: an enabled plugin that silently replaces shell_exec or write_file could intercept everything routed through it.

  6. Handlers have a contract. Return JSON strings, always, even on error. Accept **kwargs. Never raise. The registry will not crash on a dict return, but the model-facing pipeline expects the string form.

  7. Your plugin runs in a multithreaded process. Delegated tool calls, background workers, and the self-improvement fork all share the process. A naive global _client + is None check + build is a TOCTOU race that leaks whichever resource the loser opened. Use lazy_singleton / SingletonSlot from plugins/plugin_utils.py. The honcho memory plugin is the reference consumer.

  8. MCP access from plugins is default-off. ctx.call_mcp() requires a per-plugin, per-server mcp_allowlist in config, no wildcards, 30-second enforced timeout, and the result must be treated as untrusted data. Granting a server gives the plugin the same access to it as the model has.

How to verify it is actually working

hermes plugins list                        # three states: enabled / disabled / not enabled
hermes plugins capabilities                # declared vs granted — should match your consent
hermes plugins doctor . --ci               # validate your plugin against the real runtime contracts
HERMES_PLUGINS_DEBUG=1 hermes plugins list # every skip reason, every registration, full tracebacks
hermes logs --level WARNING | grep -i plugin   # the same logs, when you cannot run with the env var

Inside a session, /plugins shows what is actually loaded right now: name, version, tool count, hook count. If you enabled a plugin and /plugins does not list it, the load gate failed. If it lists but the model never calls the tools, check the toolset gate. If it lists and the tools call but fail on a host surface, check the capability grants. Three gates, three checks, and each one fails closed.

Facts, inference, and open questions

Observed (docs + installed v0.20.1 source + live runs): four discovery sources with later-overrides-earlier ordering; the plugin.yaml + register(ctx) contract; manifest kinds standalone/backend/exclusive/platform; 37 hook names in VALID_HOOKS on this install (docs still say 26); the three hook families; pre_llm_call user-message injection with the 10,000-char spill cap; _DEFAULT_OFF_TOOLSETS = {homeassistant, spotify, discord, discord_admin, video, video_gen, x_search, a2a}; known_plugin_toolsets in this profile’s config; the seven capability ids and the consent-hash re-consent rule; the “NOT a sandbox” docstring; hermes plugins list showing 83 entries, all not enabled; hermes plugins capabilities returning nothing; hermes plugins doctor security-guidance surfacing two declared/registered hook drift warnings; pinned --ref installs rejecting non-SHA refs; pack rules (no --yes, secrets stripped, per-plugin consent); the security-guidance and spotify bundled plugins as real examples; the user-grandfathered / bundled-not-grandfathered migration split; python_dependencies never auto-installed.

Inference: the three-gate separation is a deliberate risk decomposition. Gate one controls code execution, gate two controls privileged surface access, gate three controls model-visible tool count, and each fails independently so no single mistake (a bad enable, a careless consent, a stale toolset save) compounds into a full compromise. The bundled-exceptions table is the same trade-off the docs admit: infrastructure that must work is trusted by default, and everything third-party is distrusted by default. That is a coherent posture, and it is only as strong as the operator’s willingness to read plugin source before enabling.

Open questions: the pip-dependency isolation seam is explicitly deferred, so a plugin that genuinely needs an SDK must install it into the shared venv itself. The supply-chain story for third-party plugins is incomplete until that lands. The docs’ “26 hooks” count is stale against the v0.20.1 source’s 37, which suggests the docs lag the runtime on surface area. And the honest question the whole design points at: with in-process Python plugins, the consent form is only as good as the audit behind it, and Hermes has not reviewed third-party code for you.

Plugins are how Hermes stays one process instead of a zoo of daemons: every backend, provider, platform, and integration is the same plugin.yaml plus register(ctx) shape, loaded by the same manager. The price of that economy is that “enabled” is the start of the trust decision, not the end. The plugin runs in your process with your permissions. The three gates decide what it can reach, and the only wall that cannot be configured away is the one between your judgment and the code you let in.

Sources

Keep reading