Building Custom Plugins for Hermes Agent — A Practical Guide
Hermes Agent is designed to be extended primarily through plugins and skills, not by growing the core. This guide walks through the plugin architecture, tool registration, lifecycle hooks, skill authoring, and the footprint ladder that governs what belongs in core vs. what ships as a plugin.
Before building anything, settle the footprint question. Every capability goes through a decision ladder — choose the highest (least-footprint) rung that correctly solves the problem:
Extend existing code — zero new surface. The capability is a variation of something that already exists.
CLI command + skill — manages config/state/infra expressible as shell commands. Zero model-tool footprint.
Service-gated tool (check_fn) — needs structured params/returns AND only appears when a prerequisite is configured. Zero footprint otherwise.
Plugin — third-party/niche/user-specific capability that doesn't ship in core. Lives in ~/.hermes/plugins/ or a pip package.
MCP server (in the catalog) — if the capability genuinely needs to be a tool but isn't core-fundamental, prefer building it as an MCP server.
New core tool — only when the capability is fundamental, broadly useful to nearly every user, and unreachable via terminal + file or an MCP server. Last resort.
The bar for a new core tool is high because every tool ships on every API call. Most new capability should arrive as a CLI command + skill, a service-gated tool, or a plugin — not as core surface.
2. Plugin Architecture
Hermes has two plugin surfaces, both living under plugins/ in the repo so repo-shipped plugins can be discovered alongside user-installed ones in ~/.hermes/plugins/ and pip-installed entry points.
The PluginManager discovers plugins from ~/.hermes/plugins/, ./.hermes/plugins/, and pip entry points. Each plugin exposes a register(ctx) function that can:
Register CLI subcommands via ctx.register_cli_command(...) — the plugin's argparse tree is wired into hermes at startup
Discovery timing pitfall: discover_plugins() only runs as a side effect of importing model_tools.py. Code paths that read plugin state without importing model_tools.py first must call discover_plugins() explicitly (it's idempotent).
3. Registering a Tool
Every tool lives in tools/your_tool.py and registers via the registry at import time. Here's the canonical pattern:
All handlers MUST return a JSON string. The registry handles schema collection, dispatch, availability checking, and error wrapping.
Auto-discovery: any tools/*.py file with a top-level registry.register() call is imported automatically — no manual import list to maintain. Wiring into a toolset is still a deliberate, manual step in toolsets.py.
Path references in schemas: if the schema description mentions file paths, use display_hermes_home() to make them profile-aware. The schema is generated at import time, which is after _apply_profile_override() sets HERMES_HOME.
State files: if a tool stores persistent state (caches, logs, checkpoints), use get_hermes_home() for the base directory — never Path.home() / ".hermes". This ensures each profile gets its own state.
4. Lifecycle Hooks
Hooks are the interception points where plugins can observe and react to agent activity:
pre_tool_call / post_tool_call
Fired before and after every tool execution. Use for validation, logging, rate limiting, or modifying arguments/results. Invoked from model_tools.py.
pre_llm_call / post_llm_call
Fired around every LLM call. Use for observability, cost tracking, prompt inspection, or response post-processing.
on_session_start / on_session_end
Fired when a session begins and ends. Use for setup, teardown, state migration, or session-level metrics. Invoked from run_agent.py.
Hook payload data is additive — new fields are keyword fields. Signature-inspect callbacks so old narrow signatures receive only fields they declare, while **kwargs callbacks receive the complete payload.
5. Skill Authoring
Skills are markdown-driven capability packs. They live in skills/ (built-in, loadable by default) or optional-skills/ (heavier/niche, installed explicitly). A skill's entry point is SKILL.md with YAML frontmatter:
---
name: my-skill
description: What this skill does in ≤60 chars.
version: 1.0.0
author: Your Name
platforms: [linux, macos]
metadata:
hermes:
tags: [automation, devops]
category: devops
config:
my_setting: description of setting
---
# My Skill
What it does and doesn't do.
## When to Use
...
Hardline standards for every new or modernized skill:
description ≤ 60 characters, one sentence, ends with a period — no marketing words, no repeating the skill name
Tools referenced in SKILL.md prose must be native Hermes tools or MCP servers the skill explicitly expects — name them in backticks
platforms: gating audited against actual script imports — default to cross-platform, gate only when the dependency is genuinely platform-bound
author credits the human contributor first — Hermes Agent is the secondary collaborator
Scripts go in scripts/, references in references/, templates in templates/ — don't inline-write parsers every call
Tests live at tests/skills/test_<skill>_skill.py — stdlib + pytest + unittest.mock only, no live network calls
6. Toolset Wiring
Registering a tool is not enough — it must be exposed to an agent via a toolset. All toolsets are defined in toolsets.py as a single TOOLSETS dict. Each platform's adapter picks a base toolset (e.g. Telegram uses "messaging"); _HERMES_CORE_TOOLS is the default bundle most platforms inherit from.
Enable/disable per platform via hermes tools (the curses UI) or the tools.<platform>.enabled / tools.<platform>.disabled lists in config.yaml.
Cross-tool references in schemas: tool schema descriptions must not mention tools from other toolsets by name — those tools may be unavailable. If a cross-reference is needed, add it dynamically in get_tool_definitions() in model_tools.py.
7. Memory Provider Plugins
Memory-provider plugins are a separate discovery system for pluggable memory backends. Each provider implements the MemoryProvider ABC and is orchestrated by agent/memory_manager.py. Lifecycle hooks include sync_turn(turn_messages), prefetch(query), shutdown(), and optional post_setup(hermes_home, config).
Discovery covers the same four sources as the general PluginManager — bundled, $HERMES_HOME/plugins/, ./.hermes/plugins/, and entry points — but with bundled-first precedence. A memory provider is activated by name, so a dropped-in directory must not be able to shadow a shipped one.
CLI commands via plugins/memory/<name>/cli.py: if a memory plugin defines register_cli(subparser), discover_plugin_cli_commands() finds it at argparse setup time. The framework only exposes CLI commands for the currently active memory provider.
8. Model Provider Plugins
Every inference backend ships as a plugin under plugins/model-providers/<name>/. Each plugin's __init__.py calls providers.register_provider(ProviderProfile(...)) at module load. Discovery is lazy and separate — scanned on first get_provider_profile() or list_providers() call, NOT by the general PluginManager.
Scan order: bundled → user plugins → legacy providers/<name>.py. User plugins of the same name override bundled ones — register_provider() is last-writer-wins.
9. Native Plugin Compatibility
The canonical contract: compatibility is enforced as a behavior contract, not through a monolithic version literal. Keep documented plugin surfaces additive:
Add hook payload data as keyword fields; signature-inspect callbacks so old narrow signatures receive only fields they declare
Do not remove or rename PluginContext methods; make new parameters optional with defaults and keyword-only where possible
Ignore unknown native manifest fields
Give new provider methods default implementations, and signature-inspect optional callback kwargs
Use a local schema version only for a capability with a wire or persisted contract, and preserve old state/config/session replay or ship a migration
Deprecations require a once-per-process warning, a documented replacement and migration note, and at least two subsequent minor releases before removal.
10. Common Pitfalls
Hardcoding ~/.hermes paths: use get_hermes_home() from hermes_constants for code paths, display_hermes_home() for user-facing messages. Hardcoding breaks profiles.
Plugin modifying core files: plugins MUST NOT modify core files (run_agent.py, cli.py, gateway/run.py, etc.). If a plugin needs a capability the framework doesn't expose, expand the generic plugin surface — never hardcode plugin-specific logic into core.
No concrete consumer: a hook is NOT speculative if a contributor has a real, stated use case. Don't add hooks with no consumer — removing a hook after plugins depend on it is hard.
HERMES_* env vars for non-secret config:.env is for secrets only. All behavioral settings go in config.yaml. Reject PRs that tell users to set non-credential values in .env.
Next Steps
Ready to build custom plugins for Hermes Agent? Zion Tech Group offers plugin development services — we build custom integrations, domain-specific skills, memory backends, model provider plugins, and platform adapters tailored to your workflows.