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.

Plugin development service →

1. The Footprint Ladder

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:

  1. Extend existing code — zero new surface. The capability is a variation of something that already exists.
  2. CLI command + skill — manages config/state/infra expressible as shell commands. Zero model-tool footprint.
  3. Service-gated tool (check_fn) — needs structured params/returns AND only appears when a prerequisite is configured. Zero footprint otherwise.
  4. Plugin — third-party/niche/user-specific capability that doesn't ship in core. Lives in ~/.hermes/plugins/ or a pip package.
  5. 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.
  6. 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:

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:

import json, os
from tools.registry import registry

def check_requirements() -> bool:
    return bool(os.getenv("EXAMPLE_API_KEY"))

def example_tool(param: str, task_id: str = None) -> str:
    return json.dumps({"success": True, "data": "..."})

registry.register(
    name="example_tool",
    toolset="example",
    schema={"name": "example_tool", "description": "...", "parameters": {...}},
    handler=lambda args, **kw: example_tool(
        param=args.get("param", ""), task_id=kw.get("task_id")),
    check_fn=check_requirements,
    requires_env=["EXAMPLE_API_KEY"],
)

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:

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:

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

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.

Plugin development service Contact us