Plugins: Make Hermes Truly Yours
Plugin System — Endless Extensions — easy-to-understand guide based on official docs (updated 2026-08-09)
Plugins: Make Hermes Truly Yours
If you’ve ever thought, “I wish Hermes could do this,” — plugins are your answer. They let you add custom tools, hooks, and integrations without touching Hermes’ core code. Think of them as LEGO bricks for your AI assistant: snap in a new capability, and the model can use it right away.
The Big Idea
Instead of modifying Hermes’ internals (which can break things and make upgrades painful), you drop a small folder into the right place. Hermes picks it up automatically, and your new tools appear alongside the built-in ones. The model can call them immediately — no restart required (well, one restart, but that’s it).
How It Works: A 30-Second Tour
Create a directory in ~/.hermes/plugins/ with a manifest file and some Python code:
~/.hermes/plugins/my-plugin/
├── plugin.yaml # manifest — name, version, description
├── __init__.py # register() — wires schemas to handlers
├── schemas.py # tool schemas (what the LLM sees)
└── tools.py # tool handlers (what runs when called)
That’s it. Start Hermes, and your tools are live.
Your First Plugin: Hello World
Let’s build something real. Here’s a complete plugin that adds a hello_world tool and logs every tool call:
~/.hermes/plugins/hello-world/plugin.yaml
name: hello-world
version: "1.0"
description: A minimal example plugin
~/.hermes/plugins/hello-world/__init__.py
"""Minimal Hermes plugin — registers a tool and a hook."""
import json
def register(ctx):
# --- Tool: hello_world ---
schema = {
"name": "hello_world",
"description": "Returns a friendly greeting for the given name.",
"parameters": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Name to greet",
}
},
"required": ["name"],
},
}
def handle_hello(params, **kwargs):
del kwargs
name = params.get("name", "World")
return json.dumps({"success": True, "greeting": f"Hello, {name}!"})
ctx.register_tool(
name="hello_world",
toolset="hello_world",
schema=schema,
handler=handle_hello,
)
# --- Hook: log every tool call ---
def on_tool_call(tool_name, params, result):
print(f"[hello-world] tool called: {tool_name}")
ctx.register_hook("post_tool_call", on_tool_call)
Drop both files into ~/.hermes/plugins/hello-world/, restart Hermes, and ask the model to say hello. It works. The hook prints a log line after every tool invocation.
One tip: Put the tool’s description in schema["description"] — that’s what the model sees. The optional ctx.register_tool(description=...) is just internal registry metadata; if you omit it, it defaults to the schema description, but Hermes won’t copy it back into a schema that has none. Define the text once in the schema, and keep both in sync if you use both.
What Plugins Can Do
The ctx.* API is your toolbox. Here’s what you can build:
| Capability | How |
|---|---|
| Add tools | ctx.register_tool(name=..., toolset=..., schema=..., handler=...) |
| Add hooks | ctx.register_hook("post_tool_call", callback) |
| Add slash commands | ctx.register_command(name, handler, description) — adds /name in CLI and gateway sessions |
| Dispatch tools from commands | ctx.dispatch_tool(name, args) — invokes a registered tool with parent-agent context auto-wired |
| Add CLI commands | ctx.register_cli_command(name, help, setup_fn, handler_fn) — adds hermes <plugin> <subcommand> |
| Inject messages | ctx.inject_message(content, role="user", session_key=... | origin=...) — see Injecting Messages |
| Post and edit a status message | handle = ctx.post_message(text), then handle.edit(text) — see Posting a live message |
| Ship data files | Path(__file__).parent / "data" / "file.yaml" |
| Bundle skills | ctx.register_skill(name, path) — namespaced as plugin:skill, loaded via skill_view("plugin:skill") |
| Gate on env vars | requires_env: [API_KEY] in plugin.yaml — prompted during hermes plugins install |
| Distribute via pip | [project.entry-points."hermes_agent.plugins"] |
| Register a gateway platform | ctx.register_platform(name, label, adapter_factory, check_fn, ...) — Discord, Telegram, IRC, and more |
| Register image/video generation | ctx.register_image_gen_provider(provider) and ctx.register_video_gen_provider(provider) |
| Register a delegation strategy | ctx.register_delegation_provider(provider) — how each delegate_task subagent is routed, selected by delegation.backend |
| Register a context engine | ctx.register_context_engine(engine) — custom context-compression engines |
| Register a terminal backend | ctx.register_terminal_environment_provider(provider) — cloud sandbox execution |
| Register a computer-use driver | ctx.register_computer_use_provider(provider) — selected by computer_use.backend |
| Route approval prompts | ctx.register_approval_transport(name, present_fn) — custom human-approval flows |
| Register a memory backend | Subclass MemoryProvider in plugins/memory/<name>/__init__.py (separate discovery system) |
| Run host-owned LLM calls | ctx.llm.complete(...) / ctx.llm.complete_structured(...) — borrow the user’s active model + auth |
| Call MCP tools | ctx.call_mcp(server, tool, arguments, timeout=30) |
| Register an inference backend | register_provider(ProviderProfile(...)) in plugins/model-providers/<name>/__init__.py |
Plugins can also ship from several sources: bundled (<repo>/plugins/), user (~/.hermes/plugins/), project (not loaded by the gateway that runs sessions), pip entry points, and NixOS declarative installs via services.hermes-agent.extraPlugins / extraPythonPackages.
Slash command handlers run in the gateway. A /command registered with ctx.register_command() runs in the gateway process that owns the session, beside the plugin’s hooks, tools and MCP servers — including commands typed in the classic hermes chat CLI. So a handler should:
- Return its text instead of calling
print(). Printed output goes to the gateway’s stdout, not to the user’s chat. - Read the session’s working directory with
agent.runtime_cwd.resolve_agent_cwd(), notos.getcwd(), which is the gateway’s own directory. The gateway pins the session’s directory while the handler runs. - Take the calling session as handler keywords: declare
session_id,session_key,platformorsource(the sender’sSessionSource) and Hermes passes them; one-argument handlers keep working unchanged. The same values are bound asHERMES_SESSION_*around the handler (gateway.session_context.get_session_env, neveros.environ), so a background job the handler starts records the session as its parent.
The classic CLI waits for the reply for as long as the handler runs. Ctrl+C stops the wait, not the handler: the handler finishes in the gateway and its reply is not shown. The command runs over its own connection to the gateway, so the session’s prompts, interrupts and other commands are not held up while it runs.
Plugins Are Opt-In
General plugins and user-installed backends are disabled by default. Discovery still finds them (so they show up in hermes plugins and /plugins), but nothing with hooks or tools loads until you add the plugin’s name to plugins.enabled in ~/.hermes/config.yaml. That keeps third-party code from running without your explicit consent.
hermes plugins # interactive toggle (space to check/uncheck)
hermes plugins enable <name> # add to allow-list
hermes plugins disable <name> # remove from allow-list + add to disabled
hermes plugins update # list every plugin with an update, then "Update all N? [Y/n]"
hermes plugins update --yes # same, without the question (scripts, cron; required without a terminal)
hermes plugins update my-plugin --force # accept a caution security-scan verdict without the question
If you saved a tool selection (hermes tools, so platform_toolsets lists toolsets explicitly), disabling a plugin also takes its toolset out of those saved lists, and enabling it puts the toolset back. The CLI and the Desktop/TUI/dashboard switch behave the same. With no saved selection the lists are left alone: a plugin’s toolset is on by default.
hermes plugins update with no name runs the same check and lists the plugins it can move: catalog installs (re-pinned to the reviewed catalog commit) and tracked Git installs (pulled). It names every plugin it skips and why: pinned, needs fixing, status unknown, or a pip distribution Hermes does not upgrade. In a terminal it asks once, then updates each plugin through the same path as hermes plugins update NAME, so new capabilities, a widened catalog pin and scan findings still prompt per plugin. One failure does not stop the rest; the summary lists what updated, what was already current and what failed with its reason (exit code 1 if any failed). Without a terminal it prints the list and updates nothing unless you pass --yes; with --yes, consent prompts fail closed as in any non-interactive update (new capabilities stay ungranted, a widened catalog pin is not applied).
After hermes plugins install owner/repo, you’re asked Enable 'name' now? [y/N] — it defaults to no. For scripted installs, skip the prompt with --enable or --no-enable. A memory provider (a plugin whose __init__.py registers a MemoryProvider) asks Use 'name' as the memory provider now? instead: yes (or --enable) sets memory.provider, which is the only switch that activates a provider; no leaves it for hermes memory setup. Bundled “always-works” infrastructure (bundled platform plugins, bundled backends, memory providers, context engines, model providers) bypasses the allow-list and is activated through config.yaml instead.
hermes plugins update scans the new code with the same rules before it replaces the installed copy, so a plugin that picks up risky code in an update is flagged before it runs. A caution verdict on a Git update asks Update anyway? [y/N], and hermes plugins update NAME --force accepts it without the question. Hermes Desktop shows the same findings in a dialog with an Update anyway button. Catalog updates move to a reviewed pin, so a caution verdict there does not ask. In every case nothing changes until the update is accepted.
Hermes Desktop shows an Update button on a Git-installed plugin when the last update check found a newer commit. The gateway runs that check every plugins.auto_update_check_hours (default 24), and hermes plugins check-updates runs it on demand.
Heads up: plugins.enabled governs plugins only. Gateway event hooks under ~/.hermes/hooks/<name>/ are not plugins and are not gated by plugins.enabled or plugins.disabled. That directory is trusted by placement: any subdirectory holding a valid HOOK.yaml + handler.py is imported by the gateway at startup, and placing the files there is the opt-in.
Tuning Plugin Loads
Two optional settings in ~/.hermes/config.yaml let you cap how long plugin work is allowed to take:
# Optional: deadline (seconds) for each Git clone, fetch or checkout
# during plugin installation, including automatic memory-provider migration.
# Default 300; values above 3600 are clamped. A subdirectory install
# (owner/repo/path/to/plugin) downloads only that folder's files.
clone_timeout_seconds: 300
# Optional: deadline (seconds) for one plugin's import + register() at load.
# A plugin that overruns it is skipped with the reason "load timed out after
# Ns" (reported like any other load failure: the startup warning and the
# in-session `/plugins` listing) and the remaining plugins keep loading; the
# stuck thread is abandoned. Default 10; set 0 to disable; values above 600
# are clamped.
load_timeout_seconds: 10
Running Plugins Out of Process
By default, third-party Python plugins are imported straight into the Hermes process. If you’d rather keep them at arm’s length, set plugins.isolation: host — that moves them into a plugin host: one separate Python process per profile, started on demand, that imports the profile’s user-installed plugins and talks to Hermes over a private pipe.
plugins:
isolation: host # default: in_process
host:
launcher: [] # optional argv prefix for the host, e.g. a sandbox runner
Plugins themselves don’t change. They get the same ctx and register tools, hooks, slash commands, skills and provider objects exactly as before. Bundled plugins keep running in-process. What changes in host mode:
- No shared interpreter. A plugin’s module never enters the Hermes process, so it can’t read another profile’s data from memory or patch Hermes internals. Under the multiplex gateway, every profile gets its own host with only that profile’s environment and secrets.
- Crashes stay contained. A plugin that crashes or exits kills its host, not Hermes; the in-flight call returns a tool error and Hermes restarts the host and reloads its plugins (with bounded retries).
- A few surfaces need in-process code and fail that plugin with a clear reason instead of loading: gateway platform adapters (
register_platform), approval transports, Telegram/platform handlers, model-provider profiles that build their own SDK client (create_client), terminal backends (the backend’s commands fail with this reason; thesingularityplugin is one), streaming dashboard endpoints, and plugins that monkeypatch Hermes modules. Run those withisolation: in_process.
hermes plugins validate <dir> and hermes plugins show <name> report whether a plugin runs in the host and, if not, why.
Locking it for a shared deployment. plugins.isolation is ordinary profile config, so anyone who can edit a profile’s config.yaml can turn it off. When the profiles belong to people you’re isolating from each other, pin it in the managed scope instead — the managed value wins over every profile’s own config and hermes config set refuses to change it:
# /etc/hermes/config.yaml (root-owned, read by every profile on the machine)
plugins:
isolation: host
host:
launcher: [...] # pin the sandbox runner too, if you use one
Run the agents’ terminal on an isolated backend (Docker, SSH, …) as well, so the agent itself cannot reach the operator’s files.
Safety First
The gateway does not load project-local plugins under ./.hermes/plugins/. Every Hermes session (hermes, hermes --tui, the Desktop app, messaging and cron) runs in the one gateway, which discovers plugins once for the whole process and never from the folder you started a session in. Setting HERMES_ENABLE_PROJECT_PLUGINS=true therefore has no effect on sessions: when a CLI or TUI session on the gateway’s machine starts with it set in a folder whose ./.hermes/plugins/ has plugins, Hermes shows a warning at the start of that session and writes the same line to gateway.log.
To use a plugin, install it as a user plugin (~/.hermes/plugins/ or hermes plugins install) and enable it with hermes plugins enable <name>.
Pinning and Private Repositories
For a reproducible install, pin a full immutable commit (tags, branches, and abbreviated SHAs are not accepted):
hermes plugins install owner/repo --ref 0123456789abcdef0123456789abcdef01234567
Hermes checks out the commit detached, verifies that HEAD exactly matches the requested SHA, and records the canonical source, installed revision, and pin status in the current profile. hermes plugins update refuses to move a pinned plugin; choose a new exact commit explicitly with hermes plugins install <source> --force --ref <new-commit>. A forced reinstall from the same source replaces the plugin’s code but keeps your files: untracked and git-ignored files stay in place, and edits to tracked files are copied to ~/.hermes/plugins-backup/<name>-<sha>/. A reinstall from a different source starts clean — to reset a plugin completely, hermes plugins remove it first.
The same pin is available in Hermes Desktop: Skills → Plugins → Install from Git has a Pin to commit field that takes the full 40-character SHA, and the plugins list shows a pinned @ <sha8> badge on every pinned install so a team can confirm everyone is running the same commit. hermes plugins list prints the pin in its Source column (git pinned@<sha8>).
hermes plugins install clones non-interactively (it never prompts for a username or password), so a private repo needs a credential Hermes can find on its own. Every clone, pinned --ref fetch, and hermes plugins update pull is attempted anonymously first — public repos never see your credential, so a stale or revoked token can’t break a public install. Only when the remote refuses anonymous access does Hermes look for a credential. For an https:// source it tries, in order: GITHUB_TOKEN or GH_TOKEN from your .env (GitHub hosts only); the gh CLI’s login (gh auth login), GitHub hosts only; then your git credential helper (git credential fill) for that host — which works for GitLab, Bitbucket and self-hosted servers if a credential is already stored. The credential is sent as a one-shot HTTP header for that install or update and is never written into the plugin’s .git/config or the install metadata. SSH sources (git@host:owner/repo.git) authenticate through your ssh-agent as before. The same resolution applies to hermes plugins update, catalog MCP installs from git, and profile distributions fetched from a git URL.
If a token goes bad, hermes doctor sends a configured GITHUB_TOKEN/GH_TOKEN to api.github.com (under API Connectivity) and, when GitHub rejects it, names the variable and the .env file that carries the expired token so you can remove or replace it.
The Bottom Line
Plugins are the cleanest way to extend Hermes. No core modifications, no merge conflicts, no “it worked on my machine” drama. Just drop a folder, restart, and go.
Want the full walkthrough? Check out the Build a Hermes Plugin guide — it has a complete working example you can copy and adapt.
Your Hermes, your rules. Happy building!
📖 Official Docs
This article is based on the official Hermes Agent documentation:Official docs › user-guide/features/plugins