Skip to main content

Plugins

Hermes has a plugin system for adding custom tools, hooks, and integrations without modifying core code.

If you want to create a custom tool for yourself, your team, or one project, this is usually the right path. The developer guide's Adding Tools page is for built-in Hermes core tools that live in tools/ and toolsets.py.

→ Build a Hermes Plugin — step-by-step guide with a complete working example.

Quick overview​

Drop a directory into ~/.hermes/plugins/ with a plugin.yaml and Python code:

~/.hermes/plugins/my-plugin/
├── plugin.yaml # manifest
├── __init__.py # register() — wires schemas to handlers
├── schemas.py # tool schemas (what the LLM sees)
└── tools.py # tool handlers (what runs when called)

Start Hermes — your tools appear alongside built-in tools. The model can call them immediately.

Minimal working example​

Here is a complete plugin that adds a hello_world tool and logs every tool call via a hook.

~/.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 the model can immediately call hello_world. The hook prints a log line after every tool invocation.

The model-facing tool description belongs in schema["description"]. The optional ctx.register_tool(description=...) value is separate ToolEntry registry metadata: when omitted, it defaults to the schema description, but Hermes does not copy it back into a schema that lacks description. Prefer defining the text once in the schema. If you provide both values, keep them synchronized; the model sees the schema value.

Project-local plugins (./.hermes/plugins/)​

The gateway does not load project-local 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>.

What plugins can do​

Every ctx.* API below is available inside a plugin's register(ctx) function.

CapabilityHow
Add toolsctx.register_tool(name=..., toolset=..., schema=..., handler=...)
Add hooksctx.register_hook("post_tool_call", callback)
Add slash commandsctx.register_command(name, handler, description) — adds /name in CLI and gateway sessions
Dispatch tools from commandsctx.dispatch_tool(name, args) — invokes a registered tool with parent-agent context auto-wired
Add CLI commandsctx.register_cli_command(name, help, setup_fn, handler_fn) — adds hermes <plugin> <subcommand>
Inject messagesctx.inject_message(content, role="user", session_key=... | origin=...) - see Injecting Messages
Ship data filesPath(__file__).parent / "data" / "file.yaml"
Bundle skillsctx.register_skill(name, path) — namespaced as plugin:skill, loaded via skill_view("plugin:skill")
Gate on env varsrequires_env: [API_KEY] in plugin.yaml — prompted during hermes plugins install
Distribute via pip[project.entry-points."hermes_agent.plugins"]
Register a gateway platform (Discord, Telegram, IRC, …)ctx.register_platform(name, label, adapter_factory, check_fn, ...) — see Adding Platform Adapters
Register an image-generation backendctx.register_image_gen_provider(provider) — see Image Generation Provider Plugins
Register a video-generation backendctx.register_video_gen_provider(provider) — see Video Generation Provider Plugins
Register a context-compression enginectx.register_context_engine(engine) — see Context Engine Plugins
Register a terminal execution backend (cloud sandbox)ctx.register_terminal_environment_provider(provider) — see Terminal Environment Plugins
Register a computer-use driverctx.register_computer_use_provider(provider) — selected by computer_use.backend; see Computer-use backend plugins
Route human approval promptsctx.register_approval_transport(name, present_fn) — see Approval transports
Register a memory backendSubclass MemoryProvider in plugins/memory/<name>/__init__.py — see Memory Provider Plugins (uses a separate discovery system)
Run a host-owned LLM callctx.llm.complete(...) / ctx.llm.complete_structured(...) — borrow the user's active model + auth for a one-shot completion with optional JSON schema validation. See Plugin LLM Access
Call an MCP tool (capability-gated)ctx.call_mcp(server, tool, arguments, timeout=30) — see Calling MCP servers from plugins
Register an inference backend (LLM provider)register_provider(ProviderProfile(...)) in plugins/model-providers/<name>/__init__.py — see Model Provider Plugins (uses a separate discovery system)
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. That includes 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(), not os.getcwd(), which is the gateway's own directory. The gateway pins the session's directory while the handler runs.
  • Read session identity with gateway.session_context.get_session_env("HERMES_SESSION_PLATFORM") (and the other HERMES_SESSION_* names, such as HERMES_SESSION_ID and HERMES_SESSION_KEY), not os.environ. These values are bound per session around the handler, 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.

See Register slash commands.

Plugin discovery​

SourcePathUse case
Bundled<repo>/plugins/Ships with Hermes — see Built-in Plugins
User~/.hermes/plugins/Personal plugins
Project.hermes/plugins/Not loaded by the gateway that runs sessions; see Project-local plugins
piphermes_agent.plugins entry_pointsDistributed packages
Nixservices.hermes-agent.extraPlugins / extraPythonPackagesNixOS declarative installs — see Nix Setup

Later sources override earlier ones on name collision, so a user plugin with the same name as a bundled plugin replaces it.

Plugin sub-categories​

Within each source, Hermes also recognizes sub-category directories that route plugins to specialized discovery systems:

Sub-directoryWhat it holdsDiscovery system
plugins/ (root)General plugins — tools, hooks, slash commands, CLI commands, bundled skillsPluginManager (kind: standalone or backend)
plugins/platforms/<name>/Gateway channel adapters (ctx.register_platform())PluginManager (kind: platform, one level deeper)
plugins/image_gen/<name>/Image-generation backends (ctx.register_image_gen_provider())PluginManager (kind: backend, one level deeper)
plugins/memory/<name>/Memory providers (subclass MemoryProvider)Own loader in plugins/memory/__init__.py (kind: exclusive — one active at a time)
plugins/context_engine/<name>/Context-compression engines (ctx.register_context_engine())Own loader in plugins/context_engine/__init__.py (one active at a time)
plugins/model-providers/<name>/LLM provider profiles (register_provider(ProviderProfile(...)))Own loader in providers/__init__.py (lazily scanned on first get_provider_profile() call)

User plugins at ~/.hermes/plugins/model-providers/<name>/ override bundled model providers of the same name (last-writer-wins in register_provider()), so you can replace a built-in provider profile without any repo edits. Memory providers resolve the other way round: for ~/.hermes/plugins/memory/<name>/ the bundled provider wins on a name collision (bundled, then user, then project, then entry points; first seen wins), so a user memory provider needs its own unique name.

Plugins are opt-in (with a few exceptions)​

General plugins and user-installed backends are disabled by default — discovery 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. This stops third-party code from running without your explicit consent.

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. See the gateway hook trust model.

plugins:
enabled:
- my-tool-plugin
- disk-cleanup
disabled: # optional deny-list — always wins if a name appears in both
- noisy-plugin
# 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: wall-clock cap (seconds) for timeout-bounded in-process Python
# plugin hook callbacks (hot-path observers + pre_tool_call). Default 30;
# set 0 to disable; values above 600 are clamped. Timed-out pre_tool_call
# callbacks fail closed (block the tool). Caller-thread hooks such as
# subagent_stop are never moved onto a timeout worker.
# Shell hooks keep their own per-entry timeout under the top-level hooks: key.
hook_callback_timeout: 30
# 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

Three ways to flip state:

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

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.

After hermes plugins install owner/repo, you're asked Enable 'name' now? [y/N] — defaults to no. Skip the prompt for scripted installs 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.

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>. Like an update, a forced reinstall from the source the plugin was installed from replaces its 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 profile-local install metadata contains no config values, environment values, secrets, or capability grants.

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>). Pins work for private repositories too, through the same stored credentials described below.

Installing from a private repository​

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 cannot 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:

  1. GITHUB_TOKEN or GH_TOKEN from your .env (GitHub hosts only).
  2. The gh CLI's login (gh auth login), GitHub hosts only.
  3. Your git credential helper (git credential fill) for that host — 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; it 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.

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.

What the allow-list does NOT gate​

Several categories of plugin bypass plugins.enabled — they're part of Hermes' built-in surface and would break basic functionality if gated off by default:

Plugin kindHow it's activated instead
Bundled platform plugins (IRC, Teams, etc. under plugins/platforms/)Auto-loaded so every shipped gateway channel is available. The actual channel turns on via gateway.platforms.<name>.enabled in config.yaml.
Bundled backends (image-gen providers under plugins/image_gen/, etc.)Auto-loaded so the default backend "just works". Selection happens via <category>.provider in config.yaml (e.g. image_gen.provider: openai).
Memory providers (plugins/memory/)All discovered; exactly one is active, chosen by memory.provider in config.yaml.
Context engines (plugins/context_engine/)All discovered; one is active, chosen by context.engine in config.yaml.
Model providers (plugins/model-providers/)All bundled providers under plugins/model-providers/ discover and register at the first get_provider_profile() call. The user picks one at a time via --provider or config.yaml.
Pip-installed backend pluginsOpt-in via plugins.enabled (same as general plugins).
User-installed platforms (under ~/.hermes/plugins/platforms/)Opt-in via plugins.enabled — third-party gateway adapters need explicit consent.

In short: bundled "always-works" infrastructure loads automatically; third-party general plugins are opt-in. The plugins.enabled allow-list is the gate specifically for arbitrary code a user drops into ~/.hermes/plugins/.

Approval transports​

An approval transport changes where a human sees and answers an existing Hermes tool-approval request. It does not decide whether a command needs approval and it is not an authorization-policy API.

def present(request):
# Deliver request.command and request.description to your UI, wait for
# its authenticated human response, then return a request-bound decision.
choice = send_to_my_ui_and_wait(request) # once/session/always/deny
return request.respond(choice)


def register(ctx):
ctx.register_approval_transport("my-ui", present)

present may be synchronous or async. Hermes runs it on a bounded worker and enforces the canonical approvals.timeout even if the plugin does not. The request is immutable and contains redacted display text, its host presentation class (cli or gateway), the host timeout, allowed choices, and an opaque request ID/digest. Return the result of request.respond(choice); unbound dictionaries and stale or changed request IDs/digests are rejected. A plugin cannot return a scope that the host did not offer (for example, always on a once-only request).

Registration alone does nothing. Enabling the plugin and explicitly selecting its transport are separate consent steps:

plugins:
enabled: [my-approval-plugin]

security:
approval:
transport: my-ui
transport_fallback: deny # default

Transport exceptions, timeouts, unavailable registrations, invalid choices, and stale responses deny by default. To deliberately show the prompt on the ordinary CLI/TUI/gateway/ACP surface when the selected transport fails, set transport_fallback: builtin. Without that exact opt-in, Hermes never materializes the prompt on another surface.

Hermes still owns hardline blocks, sudo-stdin protection, user deny rules, request binding, allowed scopes, persistence, hooks, and final authorization. Hardline commands are blocked before any transport callback. There is intentionally no plugin approval policy, auto-allow callback, or required pre_tool_call policy in this interface. A future approval-policy capability may use the plugin capability-consent model, but transport selection does not grant it.

Migration for existing users​

When you upgrade to a version of Hermes that has opt-in plugins (config schema v21+), any user plugins already installed under ~/.hermes/plugins/ that weren't already in plugins.disabled are automatically grandfathered into plugins.enabled. Your existing setup keeps working. Bundled standalone plugins are NOT grandfathered — even existing users have to opt in explicitly. (Bundled platform/backend plugins never needed grandfathering because they were never gated.)

Available hooks​

Plugins can register the 27 lifecycle events currently accepted by hermes_cli.plugins.VALID_HOOKS. The Event Hooks catalog is canonical for exact timing, return handling, payload fields, and privacy notes.

Descriptive categoryShipped hooks
Directive/controlpre_tool_call, pre_llm_call, pre_verify, pre_gateway_dispatch
Transformtransform_tool_result, transform_terminal_output, transform_llm_output, pre_transcription
Observerpost_tool_call, post_llm_call, pre_api_request, post_api_request, api_request_error, pre_auxiliary_call, post_auxiliary_call, on_stream_start, on_stream_delta, on_stream_end, on_interim_message, on_session_start, on_session_end, on_session_finalize, on_session_reset, agent_loop_stopped, on_skill_lifecycle, subagent_start, subagent_stop, pre_approval_request, post_approval_response, on_human_input_request, on_human_input_resolved, pre_command, kanban_task_claimed, kanban_task_completed, kanban_task_blocked

These categories describe current behavior rather than defining future naming rules. Plugin middleware remains a separate registry/surface.

Plugin types​

Hermes has four kinds of plugins:

TypeWhat it doesSelectionLocation
General pluginsAdd tools, hooks, slash commands, CLI commandsMulti-select (enable/disable)~/.hermes/plugins/
Memory providersReplace or augment built-in memorySingle-select (one active)plugins/memory/
Context enginesReplace the built-in context compressorSingle-select (one active)plugins/context_engine/
Model providersDeclare an inference backend (OpenRouter, Anthropic, …)Multi-register, picked by --provider / config.yamlplugins/model-providers/

Memory providers and context engines are provider plugins — only one of each type can be active at a time. Model providers are also plugins, but many load simultaneously; the user picks one at a time via --provider or config.yaml. General plugins can be enabled in any combination.

Pluggable interfaces — where to go for each​

The table above shows the four plugin categories, but within "General plugins" the PluginContext exposes several distinct extension points — and Hermes also accepts extensions outside the Python plugin system (config-driven backends, shell-hooked commands, external servers, etc.). Use this table to find the right doc for what you want to build:

Want to add…HowAuthoring guide
A tool the LLM can callPython plugin — ctx.register_tool()Build a Hermes Plugin · Adding Tools
A lifecycle hook (pre/post LLM, session start/end, tool filter)Python plugin — ctx.register_hook()Hooks reference · Build a Hermes Plugin
A slash command for the CLI / gatewayPython plugin — ctx.register_command()Build a Hermes Plugin · Extending the CLI
A subcommand for hermes <thing>Python plugin — ctx.register_cli_command()Extending the CLI
A bundled skill that your plugin shipsPython plugin — ctx.register_skill()Creating Skills
An inference backend (LLM provider: OpenAI-compat, Codex, Anthropic-Messages, Bedrock)Provider plugin — register_provider(ProviderProfile(...)) in plugins/model-providers/<name>/Model Provider Plugins · Adding Providers
A gateway channel (Discord / Telegram / IRC / Teams / etc.)Platform plugin — ctx.register_platform() in plugins/platforms/<name>/Adding Platform Adapters
A memory backend (Honcho, Mem0, Supermemory, …)Memory plugin — subclass MemoryProvider in plugins/memory/<name>/Memory Provider Plugins
A context-compression strategyContext-engine plugin — ctx.register_context_engine()Context Engine Plugins
An image-generation backend (DALL·E, SDXL, …)Backend plugin — ctx.register_image_gen_provider()Image Generation Provider Plugins
A video-generation backend (Veo, Kling, Pixverse, Grok-Imagine, Runway, …)Backend plugin — ctx.register_video_gen_provider()Video Generation Provider Plugins
A computer-use driver (desktop control behind the computer_use tool)Single-select provider plugin — ctx.register_computer_use_provider(), selected by computer_use.backendComputer-use backend plugins
A TTS backend (any CLI — Piper, VoxCPM, Kokoro, xtts, voice-cloning scripts, …)Config-driven (recommended) — declare under tts.providers.<name> with type: command in config.yaml. OR Python backend plugin — ctx.register_tts_provider() for Python-SDK / streaming engines that need more than a shell template.TTS Setup · Python plugin guide
An STT backend (any CLI — whisper.cpp, custom whisper binary, local ASR CLI)Config-driven (recommended) — declare under stt.providers.<name> with type: command in config.yaml, or set HERMES_LOCAL_STT_COMMAND for the legacy single-command escape hatch. OR Python backend plugin — ctx.register_transcription_provider() for Python-SDK engines (OpenRouter, SenseAudio, Gemini-STT, etc.).STT Setup · Python plugin guide
External tools via MCP (filesystem, GitHub, Linear, Notion, any MCP server)Config-driven — declare mcp_servers.<name> with command: / url: in config.yaml. Hermes auto-discovers the server's tools and registers them alongside built-ins.MCP
Additional skill sources (custom GitHub repos, private skill indexes)CLI — hermes skills tap add <repo>Skills Hub · Publishing a custom tap
Gateway event hooks (fire on gateway:startup, session:start, agent:end, command:*)Drop HOOK.yaml + handler.py into ~/.hermes/hooks/<name>/Event Hooks
Shell hooks (run a shell command on events — notifications, audit logs, desktop alerts)Config-driven — declare under hooks: in config.yamlShell Hooks
note

Not everything is a Python plugin. Some extension surfaces intentionally use config-driven shell commands (TTS, STT, shell hooks) so any CLI you already have becomes a plugin without writing Python. Others are external servers (MCP) the agent connects to and auto-registers tools from. And some are drop-in directories (gateway hooks) with their own manifest format. Pick the right surface for the integration style that fits your use case; the authoring guides in the table above each cover placeholders, discovery, and examples.

NixOS declarative plugins​

On NixOS, plugins can be installed declaratively via the module options — no hermes plugins install needed. See the Nix Setup guide for full details.

services.hermes-agent = {
# Directory plugin (source tree with plugin.yaml)
extraPlugins = [ (pkgs.fetchFromGitHub { ... }) ];
# Entry-point plugin (pip package)
extraPythonPackages = [ (config.services.hermes-agent.package.python.pkgs.buildPythonPackage { ... }) ];
# Enable in config
settings.plugins.enabled = [ "my-plugin" ];
};

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.

Managing plugins​

hermes plugins # unified interactive UI
hermes plugins list # table: enabled / disabled / not enabled (bundled backends,
# platforms and the live memory.provider count as enabled)
hermes plugins search <term> # search the Hermes plugin catalog
hermes plugins install <name> # install a catalog entry (repo @ reviewed pinned SHA)
hermes plugins install user/repo # install from Git, then prompt Enable? [y/N]
hermes plugins install user/repo --enable # request enable; dependency consent still applies
hermes plugins install user/repo --no-enable # install but leave disabled (no prompt)
hermes plugins update my-plugin # pull latest (local edits are autostashed and re-applied)
hermes plugins remove my-plugin # uninstall; also drops it from plugins.enabled/disabled/entries
# and resets memory.provider when it was the live provider
hermes plugins enable my-plugin # add to allow-list
hermes plugins disable my-plugin # remove from allow-list + add to disabled (bundled platforms:
# either spelling works, e.g. photon-platform or platforms/photon)
hermes plugins capabilities [my-plugin] # declared vs granted capabilities
hermes plugins check-updates # read-only: is any installed plugin outdated?
hermes plugins adopt my-plugin # track a self-cloned plugin dir (read its git origin)
hermes plugins trust-update-url my-plugin # confirm a changed update_url after review

Update checks and provenance​

Hermes records Git install source and revision in .install-metadata.json. Unpinned tracked installs compare the saved source's remote HEAD, or a matching saved update_url feed. Pinned installs remain pinned. Self-cloned directories need hermes plugins adopt NAME before they become tracked installations. Manually copied or provenance-drifted directories receive diagnostic guidance. Pip entry-point plugins can report an owning distribution's available version; that check does not turn them into Git-managed installs.

hermes plugins check-updates leaves plugin files unchanged. A scheduled gateway check runs when plugins.auto_update_check_hours is due: default 24 hours, 0 disables it. Its receipt is available through hermes pm status and the desktop sync-status view. This is not a hard once-per-day limit if you configure a different interval.

By default, updates require hermes plugins update NAME. Setting plugins.auto_apply: true opts tracked Git plugins into unattended updates. Both routes use the update security scan. Auto-apply does not manage pinned, manual, drifted, or pip-distribution rows.

If a manifest changes or introduces update_url, Hermes refuses the new address until you approve it with hermes plugins trust-update-url NAME. This is a feed-source check, not a sandbox against already trusted plugin code.

Dependency preparation and preservation​

Python dependency installation has a separate consent/admission step. plugins install --enable does not bypass that step. A declined or non-interactive dependency install can leave the plugin installed but disabled. Node sidecar dependencies have a separate prompt and remain plugin-local.

PM prepares Python dependencies with core and the enabled plugin set before publishing the new environment and configuration. A resolution failure preserves the previous selection. Restart Hermes when a new selected environment is not yet active in the running process.

The enabled set is the union over the default home and every profile under profiles/, read from each config.yaml (plugins.enabled, plugins.disabled, memory.provider). PM refuses to guess at a home it cannot read: a config.yaml that is not valid YAML, is not a mapping, or has a non-list plugins.enabled/plugins.disabled or non-string memory.provider fails dependency preparation for all homes (could not parse plugin selection: <path>), rather than silently dropping that profile's plugins from the next environment. Fix or remove the offending file; an empty config.yaml is fine.

Ordinary Hermes application updates preserve user plugin directories, including wrapper files and external sidecar links. Explicit plugin updates or removals can change those files. See Package management and the plugin authoring guide.

Hermes Desktop registers the hermes:// URL scheme, so a website, README, or chat message can link straight to a plugin install:

hermes://plugin/install?catalog=NAME # catalog entry, installs the reviewed pin
hermes://plugin/install?repo=owner/repo # any git repo
hermes://plugin/install?repo=owner/repo&enable=1 # enable the agent plugin after install
hermes://plugin/install?repo=owner/repo&force=1 # replace an existing install
hermes://plugin/install?catalog=<name> # reviewed catalog entry at its pinned commit

The catalog=<name> form is what the Open in Hermes Desktop button on every Plugin Catalog card uses. Desktop resolves the name against the live catalog (the same feed the Capabilities → Plugins picker shows) and opens the same reviewed catalog entry dialog an in-app pick does: the agent half installs at the catalog's pinned commit, never the branch tip. The link carries no repo URL, and a name that is not in the catalog shows an error toast and nothing else — it is never reinterpreted as a git path, so a link cannot smuggle an unreviewed repo behind a familiar-looking name.

For a repo= link, clicking one opens Hermes and shows a confirmation dialog — the repo id, a "Before you install" note, and GitHub browse + clone links — then shallow-clones the repo to detect what it ships (an agent plugin — backend Python, a desktop plugin — app UI, or both). You pick the components with checkboxes and confirm. Nothing is installed until you do; deep links never auto-install, and agent-plugin installs go through the same install-time security scanning as hermes plugins install.

Hybrid repos (agent + desktop halves in one repo) use one link and one dialog. The same modal is reachable without a link via Capabilities → Plugins → Install from Git. Legacy hermes://plugin-agent/… and hermes://plugin-desktop/… URLs route into the same dialog. In dev builds (npm run dev) the scheme is hermes-dev://.

Websites need no SDK — a normal anchor works:

<a href="hermes://plugin/install?repo=owner/repo&enable=1">Install in Hermes</a>

MCP servers have the equivalent link form — see Add to Hermes link.

Plugins can declare the privileged host surfaces they want in their plugin.yaml:

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

When a plugin declares capabilities, hermes plugins install (and hermes plugins enable) shows the list with one-line risk descriptions and asks once. Consenting records the grant under plugins.entries.<id>.granted_capabilities together with a consent hash and timestamp. Declining leaves the plugin enabled with those capabilities off — a well-behaved plugin probes with ctx.has_capability() and degrades gracefully.

Update re-consent: if a plugin update declares capabilities you haven't granted, hermes plugins update surfaces the additions and asks again. New capabilities stay off until you consent — a plugin update can never silently widen its access. Catalog re-pins go one step further: when the new pin adds tools, hooks, Python dependencies, host capabilities, a provider sign-in (requires_auth) or a Desktop UI half the installed version did not have, the CLI shows the delta and asks y/N before anything moves, and the Desktop / dashboard Update button opens the same confirmation. Declining (or a non-interactive session) leaves the plugin at the old pin.

Non-interactive sessions fail closed: installing or updating without a TTY completes the install, but declared capabilities are not granted. Run hermes plugins enable <id> interactively to grant them later.

Inspect the state at any time:

hermes plugins capabilities # all plugins with declared/granted capabilities
hermes plugins capabilities my-plugin # one plugin, declared vs granted

Capability ids map 1:1 to the older per-feature config gates, which keep working but are deprecated in favor of the consent flow:

CapabilityLegacy key (plugins.entries.<id>.…)
tools.overrideallow_tool_override
llm.provider_overridellm.allow_provider_override
llm.model_overridellm.allow_model_override
llm.agent_id_overridellm.allow_agent_id_override
llm.profile_overridellm.allow_profile_override
llm.task_overridellm.allow_task_override
gateway.platform_actionsallow_platform_actions

A gate is open when either the capability is granted or the legacy key is set — existing configs keep working unchanged.

Not a sandbox

Capabilities are a consent and audit layer, not isolation. Plugins run as regular in-process Python: a malicious plugin can ignore every gate here. Granting a capability is a statement of trust in the plugin author — it is not a code audit, and Hermes has not reviewed the plugin's code. Only install plugins from sources you trust.

Platform actions​

ctx.platform_actions gives a plugin a minimal, capability-gated verb set for acting on connected chat platforms through the live gateway adapter registry — the sanctioned alternative to monkeypatching an adapter. It is off by default: every call re-checks the gateway.platform_actions capability (legacy key plugins.entries.<id>.allow_platform_actions), and an ungranted call returns a structured error instead of acting.

v1 verbs (both async, both return a plain dict, and neither ever raises into hook dispatch):

result = await ctx.platform_actions.add_reaction(
platform="telegram", chat_id="-100123", message_id="456", emoji="👍",
)
result = await ctx.platform_actions.set_thread_title(
platform="discord", chat_id="123", thread_id="456", title="New title",
)
if not result["ok"]:
print(result["error"], result.get("detail"))

Success is {"ok": True, "action": <verb>}. Failures are {"ok": False, "error": <code>, "detail": <str>} with stable error codes: capability_not_granted, invalid_argument, gateway_unavailable, unknown_platform, adapter_not_registered, adapter_disconnected, unsupported_platform_action, action_failed. Actions validate that the target adapter exists and is connected before acting; a disconnected or missing adapter degrades to a structured error, never an exception.

Platforms supported in v1: Telegram and Discord. Telegram's add_reaction sets the bot's reaction (the Bot API replaces a previous bot reaction rather than stacking). Every action — allowed or denied — is written to the log with the plugin id, verb, platform, and outcome.

Security note

Platform actions are a messaging-as-the-bot power: a granted plugin can react and rename threads in any chat the gateway bot can reach, not just the chat that triggered the hook. Grant gateway.platform_actions only to plugins you trust, and prefer plugins that document exactly which actions they take. Raw platform SDK payload/handle access is deliberately not part of this surface — per the #64176 round-2 design correction it requires its own capability (gateway.raw_events) with a "no stability guarantee" label and a separate design, and has not shipped.

Discovering plugins — the Hermes plugin catalog​

hermes plugins search <term> searches the Hermes plugin catalog — the curated, SHA-pinned catalog maintained in the hermes-agent repository (plugin-catalog/). Matching covers entry names, descriptions, and declared tools:

hermes plugins search telegram # search the catalog
hermes plugins browse # browse every entry
hermes plugins info <name> # full details for one entry

Once you've found a plugin, install it by bare name — the name resolves to the entry's repository at its pinned commit SHA, and catalog provenance is recorded so hermes plugins update can re-pin when the catalog moves:

hermes plugins install <catalog-name>

Explicit owner/repo or Git-URL identifiers never touch the catalog and are flagged as custom (unreviewed) sources. An explicit --ref <40-char commit SHA> pins a custom install.

See Plugin Catalog for the full trust model, admission CI, and submission workflow.

Cataloged ≠ audited

A catalog entry means the entry's metadata and declared capabilities were reviewed at admission — it is not a code audit. Installing still goes through the normal consent flow (plugins install disabled by default, enabling is an explicit step, and tool-override rights require a separate grant). Review a plugin's source before enabling it.

Plugin packs​

A plugin pack is a declarative, shareable YAML file (hermes-pack.yaml) that pins a set of plugins — like sharing a modpack. Installing a pack fans out to ordinary pinned installs; nothing new exists at runtime.

name: voice-assistant-pack
description: STT + streaming TTS + approval relay
author: hyper
version: 1.0.0
plugins:
- name: hermes-telegram-business # bare plugin-catalog name…
ref: e905f3bc5eeaa5a9dab9bc5155601b3ebec75757
- repo: owner/approval-relay # …or explicit owner/repo (or git URL)
ref: 8f3c2d1a9b4e5f6071829304a5b6c7d8e9f00112
subdir: plugins/relay # optional monorepo path
config: # optional, non-secret seeds only
hermes-media-studio:
default_model: flux-3
skills: [] # declared list only (not auto-installed yet)
hermes plugins pack show ./hermes-pack.yaml # dry-run review
hermes plugins pack install ./hermes-pack.yaml # review → confirm → install
hermes plugins pack export > hermes-pack.yaml # snapshot the current install
hermes plugins pack export --enabled-only # only plugins.enabled

Supply-chain posture. Every entry's ref must be an exact 40-character commit SHA — tags and branch names are rejected with an error naming the entry, the same rule as the plugin catalog. Pack installs ride the exact same pinned install path as hermes plugins install --ref <sha> and record the same provenance in plugins/.install-metadata.json, so two installs of the same pack resolve identically. Packs build on the manifest v2 fields (manifest_version, api_version, requires_plugins) — each plugin's own manifest still validates through the normal install path.

Consent is never bulk-granted. pack install shows a mandatory review screen (every plugin, source, pinned ref, and the capabilities it declares), then asks one confirmation for the pack contents. After that, each plugin's declared capabilities go through the standard per-plugin capability-consent prompt — identical to a single hermes plugins install. There is no --yes, and non-interactive sessions cannot install packs.

Secrets never travel in packs. config: seeds are limited to non-secret plugins.entries.<id> keys — secret-shaped key names (*token*, *key*, *password*, …), capability grants, and the deprecated allow_* trust gates are rejected on install and stripped on export. Plugins that need secrets declare them in their own requires_env, which prompts during install as usual. Existing user values in plugins.entries.<id> always win over pack seeds.

Partial failure. Each plugin installs independently; failures are reported per plugin, the rest continue, and the command exits non-zero if any plugin failed.

Export caveats. pack export only includes plugins with known Git provenance (installed via hermes plugins install). Local-only plugins are listed as warning comments in the emitted YAML, not as installable entries.

The skills: list is parsed and displayed at install time but not yet auto-installed — install those manually for now (hermes skills). Wiring skill-hub ids into pack install is a documented follow-up seam.

Install-time security scanning​

Every hermes plugins install and hermes plugins update runs a static security scan over the plugin tree before it is activated (inspired by Claude Cowork's skill & plugin security scanning). The scanner reuses the same threat-pattern engine as the Skills Hub guard — exfiltration of credential stores, reverse shells, destructive commands, persistence mechanisms, obfuscated execution, and prompt injection in documentation files — with plugin-aware exemptions: a provider plugin reading its own API key from the environment (the documented requires_env pattern) is not flagged.

Three verdicts, matching Cowork's pass/warn/fail:

VerdictBehavior
safeInstalls normally, no extra output
cautionFindings are shown; you confirm Install anyway? [y/N] (or pass --force)
dangerousBlocked. --force does not override

On hermes plugins update, a dangerous verdict on the updated tree disables the plugin until you review the findings and re-enable it. A dangerous block names the critical findings that caused it (e.g. 1 critical of 42 findings (destructive_root_rm)), so a single blocking line is not hidden behind the total.

Text that cannot run on the host at install time is scored as context, not as the plugin's behaviour, so it can lower a finding but never delete it — every finding stays in the report with file and line:

  • Documentation prose (README.md, AGENTS.md, docs/**/*.md, .txt, .rst, .html) can never on its own produce dangerous: a command or credential path quoted there (an uninstall step, a refusal list naming ~/.ssh) steps down one severity, and a README removing the plugin's own install directory (rm -rf "$HOME/.hermes/plugins/<name>") is a note. Agent-facing shapes keep full severity — prompt injection, Markdown exfil, agent-config edits, curl … | sh one-liners, an authorized_keys append, a leaked provider key — and so does anything under a bundled skills/ tree or in after-install.md, which the agent reads as instructions.
  • Test trees and fixtures (tests/, test/, testing/, spec/, specs/, fixtures/ at the plugin root; __tests__/ and __fixtures__/ at any depth; *.test.*, *.spec.*, test_*.py, *_test.*) are still scanned — a plugin's __init__.py can import from them — but a quoted-only hostile string (verdict_for("rm -rf /"), a redaction corpus with a fake sk-… key) is a note, and test code that would execute on import (os.system('rm -rf /')) is capped at caution. The same finding in any other file (setup.sh, src/spec/…) is still dangerous.
  • Whole-line comments and CHANGELOG.md describe a defense; they score as prose does.
  • Base64 that decodes to a media header (PNG/JPEG/GIF/WOFF/PDF … in a data URI or JSON scenery) is informational; base64 -d piped into a text filter (grep, jq) is a note, piped into a shell or interpreter it keeps full severity; sudo / env| as an alternation member of a regex literal (/approval|sudo|secret/, a redaction pattern) is a note, in a command string (subprocess.run("sudo …")) it is not.

Likewise, a generic sample token (hardcoded_secret) inside a runtime .py file's if __name__ == "__main__": self-test block is capped at caution — the loader imports plugins and never runs that block — while every other finding inside it (destructive commands, provider-shaped keys such as sk-…) and the same token anywhere above the guard keep full severity.

Scanning is on by default; disable it in config.yaml:

plugins:
scan_on_install: false

Running plugins out of process (plugins.isolation)​

By default third-party Python plugins are imported into the Hermes process, as they always have been. Setting plugins.isolation: host 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 do not change. They receive the same ctx and register tools, hooks, slash commands, skills and provider objects (image/video generation, web search, browser, TTS/STT, memory, context engines, model-provider profiles) exactly as before; Hermes registers matching entries on its side that call into the host. Dashboard plugin APIs are served by the host too. Bundled plugins keep running in-process. In-process dashboard API handlers share the gateway's event loop, so they must not block it (see Don't block the event loop).

On a gateway serving several profiles, every profile other than the one the gateway started with runs its own dashboard plugins (installed under its own plugins/ folder or the root ~/.hermes/plugins/, the profile's own copy winning a same-name conflict, and enabled in its own config) in its own plugin host, whatever its plugins.isolation says: another profile's plugin code never runs inside the gateway. The host does not carry websockets yet, so such a plugin's websocket routes are refused (HTTP 501) for that profile; its HTTP routes work. A slow hosted handler holds only its own profile's plugin requests, and a request the host has not answered after 60 s gets HTTP 504. Such a host starts on the profile's first dashboard plugin request and runs until the gateway exits: one extra Python process for each profile that has used a dashboard plugin.

What changes in host mode:

  • No shared interpreter. A plugin's module never enters the Hermes process, so it cannot read another profile's data from memory or patch Hermes internals. Under the multiplex gateway every profile gets its own host, started with only that profile's environment and secrets.
  • Crashes stay contained. A plugin that crashes or exits kills its host, not Hermes; the call in flight returns a tool error and Hermes restarts the host and reloads its plugins (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), streaming and websocket dashboard endpoints, and plugins that monkeypatch Hermes modules. Run those with isolation: in_process.

Locking it for a shared deployment. plugins.isolation is ordinary profile config, so whoever can edit a profile's config.yaml can turn it off. When the profiles belong to people you are 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.

hermes plugins validate <dir> and hermes plugins show <name> report whether a plugin runs in the host and, if not, why. Across the plugin catalog at the time of writing, 299 of 348 entries run in the host unchanged.

Interactive UI​

Running hermes plugins with no arguments opens a composite interactive screen:

Plugins
↑↓ navigate SPACE toggle ENTER configure/confirm ESC done

General Plugins
→ [✓] my-tool-plugin — Custom search tool
[ ] webhook-notifier — Event hooks
[ ] disk-cleanup — Auto-cleanup of ephemeral files [bundled]

Provider Plugins
Memory Provider ▸ honcho
Context Engine ▸ compressor
  • General Plugins section — checkboxes, toggle with SPACE. A row opens checked when the plugin is active right now: listed in plugins.enabled, or a bundled platform, backend or model provider (on without a list entry), or the selected provider of a category. Only rows you flip are written on exit: unticking adds the plugin to plugins.disabled (explicit off), ticking adds it to plugins.enabled and clears a stale disable. Opening the picker and leaving changes nothing.
  • Provider Plugins section — shows current selection. Press ENTER to drill into a radio picker where you choose one active provider.
  • Bundled plugins appear in the same list with a [bundled] tag.

Provider plugin selections are saved to config.yaml:

memory:
provider: "honcho" # empty string = built-in only

context:
engine: "compressor" # default built-in compressor

Enabled vs. disabled vs. neither​

Plugins occupy one of three states:

StateMeaningIn plugins.enabled?In plugins.disabled?
enabledLoaded on next sessionYesNo
disabledExplicitly off — won't load even if also in enabled(irrelevant)Yes
not enabledDiscovered but never opted inNoNo

The default for a newly-installed or bundled plugin is not enabled. hermes plugins list shows all three distinct states so you can tell what's been explicitly turned off vs. what's just waiting to be enabled.

In a running session, /plugins shows which plugins are currently loaded.

Injecting Messages​

Plugins can inject messages into a CLI conversation, a known gateway session, or a new gateway session in a given chat using ctx.inject_message():

# The chat the plugin is working for (see "Without a session key" below; prefer passing session_key)
ctx.inject_message("New data arrived from the webhook", role="user")

# Existing gateway conversation
ctx.inject_message(
"New data arrived from the webhook",
role="user",
session_key="agent:main:telegram:dm:123456789",
)

# Start (or continue) the session for a chat/thread, e.g. a Discord forum post the plugin just opened
ctx.inject_message(
"Review the attached spec and post a plan here.",
origin={
"platform": "discord",
"chat_id": "1290000000000000000", # the forum post (thread) id
"chat_type": "thread",
"thread_id": "1290000000000000000",
"user_id": "123456789012345678", # who the turn runs as; must pass the gateway's allowlist
"user_name": "Desk Orchestrator",
},
)

Signature: ctx.inject_message(content: str, role: str = "user", *, session_key: str | None = None, origin: Mapping[str, Any] | None = None) -> bool

When the classic CLI runs plugins in its own process (no gateway session):

  • If the agent is idle (waiting for user input), the message is queued as the next input and starts a new turn.
  • If the agent is mid-turn (actively running), the message interrupts the current operation — the same as a user typing a new message and pressing Enter.
  • For non-"user" roles, the content is prefixed with [role] (e.g. [system] ...).
  • Returns True if the message was queued successfully.

Without a session key (session_key and origin both omitted), when the plugin runs in the gateway (or its plugin host) — which is where plugins run for classic CLI, TUI and Desktop sessions — the gateway picks the chat:

  1. If the plugin is handling a local session's own call (its slash command, a hook fired for that session, or a tool or hook in its turn), the message goes to that session. This never guesses, however many sessions are open.
  2. With no session in the calling context (a timer, a background thread — a thread the plugin starts does not inherit the call's context), it goes to the one local session of the plugin's profile that was started by the classic CLI (hermes chat) and has a viewer attached. TUI and Desktop sessions, messaging chats and other profiles' sessions are never picked this way.
  3. Otherwise (no such session, or several) it is refused: False, nothing is injected anywhere, and the gateway log names the plugin and says to pass a session key.

The chosen session receives the text as a queued input, the same way as a keyed injection; it needs the allow_gateway_injection permission below. When that is a classic CLI session, the turn and its reply are in the session's transcript, but the attached classic CLI does not display them. A call made for a messaging chat without a key is refused, as before. Prefer passing session_key: a key-less call can be refused when the chat is ambiguous, and key-less injection may be retired later.

In gateway mode with a key:

  • session_key must identify an existing gateway session. It is the stable routing key, not the CLI session ID.
  • Hermes reuses that session's stored platform, chat, thread, profile, and conversation history. Plugins cannot change that route through session_key.
  • The session must belong to the plugin's own profile: the key of another profile's chat or local session gets False right away, with the reason in the gateway log.
  • Hermes rechecks the stored route against the gateway's current authorisation rules before dispatch.
  • Routes that relied only on an adapter-time or upstream authorisation decision are rejected unless Hermes can revalidate them from current core allowlists, pairing, or explicit allow-all configuration.
  • Injected text is always conversational input. It cannot invoke slash commands, approve tools, or resolve pending confirmation and clarification prompts.
  • The route and conversation are pinned while dispatch is pending. Hermes drops the request if topic recovery changes the route or the session rotates before handling starts.
  • The request enters the platform adapter's normal message path. Active sessions use the existing busy-session queue rather than starting a competing turn.
  • Returns True when the live gateway accepts the request for asynchronous dispatch. This does not confirm that the agent turn or platform delivery has completed.
  • Returns False when both session_key and origin are given, neither is given and no chat is chosen (above), the permission is not granted, or no live host can accept the request. Unknown or unroutable session keys discovered after asynchronous acceptance are written to the gateway log.

Starting a session with origin (gateway mode only):

  • origin uses the same shape as a stored session origin (SessionSource.to_dict()): platform and chat_id are required; chat_type, thread_id, user_id, user_name, chat_name and the other origin fields are optional. Use it when no session exists yet — for example, a bot-authored forum post, which platforms like Discord never deliver back to the bot as an inbound message.
  • The session is created in the plugin's own profile (the profile whose plugin manager loaded it) through the normal session path, with a fresh prompt and an empty history, and the reply is delivered to that chat/thread. If the chat already has a session in that profile, the text joins it exactly like a human message would (the busy-session queue applies), so a human follow-up in the same thread continues the same session.
  • Under the gateway's session authority (every gateway-owned session), the plugin turn is committed to that session's durable FIFO and borrows the connector provenance of a human message already admitted there. An origin whose chat has no human message admitted yet (no session, or a session with no committed human turn) is therefore refused: inject_message returns False right away and the gateway log says "has no human message admitted yet". Hermes never mints platform provenance for a chat the platform did not deliver. Have the first message in that chat come from the platform (or a human), then inject with origin or session_key.
  • A plugin can never target another profile. False is returned (with the reason in the gateway log) when the origin names a different profile, when the gateway's own profile routing sends that chat to a different profile, when the plugin's profile is not served by this gateway, or when that profile has no connected adapter for platform — another profile's bot is never borrowed. On a multiplexed gateway the turn runs under the plugin's profile home and secrets.
  • Authorisation is not bypassed: the origin's user must pass the gateway's current allowlists, pairing, or allow-all configuration for that profile, the same check as the session_key path. Choose a user_id that is authorised, or the request is dropped at dispatch and logged.
  • The same text rules apply: no slash commands, approvals, or prompt resolution. The permission below is read from the plugin's own profile config.yaml.

Ink TUI (hermes --tui) and the desktop / dashboard chat are a third host. They do not set the classic CLI reference and they do not register on the messaging-gateway injector — those two hosts stay separate so a live gateway cannot clobber the TUI (or the reverse). Pass the session's durable session_key (the ses_… id), not the ephemeral UI session id. Hermes queues the text on that session's prompt queue: a busy session keeps the message for the next turn, an idle session starts one. A key that is not a live TUI session is left for the messaging gateway when one is running, and is never rerouted to a different chat.

This enables plugins like remote control viewers, messaging bridges, or webhook receivers to feed messages into the conversation from external sources.

Gateway injection can send an agent response to an external messaging platform. It is disabled by default for every plugin. Grant it per plugin in config.yaml:

plugins:
entries:
my-plugin:
allow_gateway_injection: true
warning

Only grant gateway injection to plugins you trust. Hermes checks this host API permission, restricts it to existing session routes or new sessions in the plugin's own profile, and applies current authorisation, but Python plugins run in-process and this setting is not a sandbox.

note

This plugin API does not expose a public HTTP endpoint or CLI command for external processes. The plugin must already know the target gateway session_key, for example from its own trusted configuration or previously retained session state.

Plugin code inside the gateway​

Hooks, tools and injected turns of every chat and every local session (CLI, TUI, Desktop) run in the one gateway process, not in the client you launched. Some process-level habits from the classic CLI do not carry over:

  • os.getcwd() is the gateway's own directory, shared by every session; Hermes never changes it. Use ctx.session_cwd() for the working directory of the session the hook or tool runs for: the workspace a CLI/TUI/Desktop session was started in, or the profile's terminal cwd for a chat ("" when there is none). Pass it as cwd= to subprocesses. It reads the session from the running turn, so it works in code that runs for a session: turn hooks, tool handlers and approval hooks. Ingress hooks such as pre_gateway_dispatch run before the message is admitted to any session; there it returns the profile's terminal cwd, or "" when none is configured.
  • ctx.session_surface() names the calling session's client: "cli", "tui", "desktop", "acp", or the messaging platform ("telegram", …); "" outside a session.
  • print() output goes to the gateway's gateway-stdio.log, not to your terminal. Use logging instead.
  • input() gets end-of-file: the gateway has no terminal. Ask through a tool result or a slash command.

In the classic CLI process itself (for example a plugin slash command), ctx.session_cwd() is the launch directory and ctx.session_surface() is "cli".

Calling MCP servers from plugins​

ctx.call_mcp() lets a plugin call a tool on one of the user's configured MCP servers — synchronously, from any hook or tool handler — routing through Hermes' existing native MCP client (same connections, trust-tier gates, circuit breaker, and reconnect logic as model-invoked MCP tools; never a parallel client).

result = ctx.call_mcp(
"knowledge_rag", # server name from mcp.servers
"query_knowledge", # tool on that server
{"query": "deploy runbook"},
timeout=30, # seconds; clamped to 1–600
)
if result["ok"]:
print(result["result"])
else:
print("MCP error:", result["error"])

Signature: ctx.call_mcp(server: str, tool: str, arguments: dict | None = None, timeout: float = 30) -> dict

Returns a stable envelope: {"ok": True, "result": ...} (plus structuredContent when the server provides it) or {"ok": False, "error": "..."}. Results over ~64 KB are truncated and flagged with "truncated": True.

Security: default-off, per-server allowlist​

A plugin has no MCP access by default. The operator must grant each server explicitly in config.yaml:

plugins:
entries:
my-plugin:
mcp_allowlist: ["knowledge_rag", "github"]
  • Calling a server not in the list raises PermissionError naming the exact config key to set.
  • The grant is per-server and per-plugin — never ambient authority over every configured server, and "*" wildcards are not honored.
  • Every call has an enforced timeout (default 30 s) so a hung MCP server cannot stall the hook or tool pipeline that invoked it.
  • MCP servers return untrusted content. Treat result as data, not instructions — don't feed it into privileged decisions (approvals, command execution) without validation.
warning

Granting mcp_allowlist gives the plugin the same access to that MCP server as the model has — including any write-capable tools the server exposes (subject to the server's trust tier gates). Grant only servers the plugin genuinely needs.

See the full guide for handler contracts, schema format, hook behavior, error handling, and common mistakes.