跳到主要内容
本文尚未翻译

本文暂时显示英文原文,中文翻译正在进行中。翻译完成后将自动更新。

原文链接:English Version

Build a Hermes Plugin

This guide walks through building a complete Hermes plugin from scratch. By the end you'll have a working plugin with multiple tools, lifecycle hooks, shipped data files, and a bundled skill — everything the plugin system supports.

What you're building

A calculator plugin with two tools:

  • calculate — evaluate math expressions (2**16, sqrt(144), pi * 5**2)
  • unit_convert — convert between units (100 F → 37.78 C, 5 km → 3.11 mi)

Plus a hook that logs every tool call, and a bundled skill file.

Step 1: Create the plugin directory

mkdir -p ~/.hermes/plugins/calculator
cd ~/.hermes/plugins/calculator

Step 2: Write the manifest

Create plugin.yaml:

name: calculator
version: 1.0.0
description: Math calculator — evaluate expressions and convert units
provides_tools:
- calculate
- unit_convert
provides_hooks:
- post_tool_call

This tells Hermes: "I'm a plugin called calculator, I provide tools and hooks." The provides_tools and provides_hooks fields are lists of what the plugin registers.

Optional fields you could add:

author: Your Name
requires_env: # gate loading on env vars; prompted during install
- SOME_API_KEY # simple format — plugin disabled if missing
- name: OTHER_KEY # rich format — shows description/url during install
description: "Key for the Other service"
url: "https://other.com/keys"
secret: true

Step 3: Write the tool schemas

Create schemas.py — this is what the LLM reads to decide when to call your tools:

"""Tool schemas — what the LLM sees."""

CALCULATE = {
"name": "calculate",
"description": (
"Evaluate a mathematical expression and return the result. "
"Supports arithmetic (+, -, *, /, **), functions (sqrt, sin, cos, "
"log, abs, round, floor, ceil), and constants (pi, e). "
"Use this for any math the user asks about."
),
"parameters": {
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "Math expression to evaluate (e.g., '2**10', 'sqrt(144)')",
},
},
"required": ["expression"],
},
}

UNIT_CONVERT = {
"name": "unit_convert",
"description": (
"Convert a value between units. Supports length (m, km, mi, ft, in), "
"weight (kg, lb, oz, g), temperature (C, F, K), data (B, KB, MB, GB, TB), "
"and time (s, min, hr, day)."
),
"parameters": {
"type": "object",
"properties": {
"value": {
"type": "number",
"description": "The numeric value to convert",
},
"from_unit": {
"type": "string",
"description": "Source unit (e.g., 'km', 'lb', 'F', 'GB')",
},
"to_unit": {
"type": "string",
"description": "Target unit (e.g., 'mi', 'kg', 'C', 'MB')",
},
},
"required": ["value", "from_unit", "to_unit"],
},
}

Why schemas matter: The description field is how the LLM decides when to use your tool. Be specific about what it does and when to use it. The parameters define what arguments the LLM passes.

Step 4: Write the tool handlers

Create tools.py — this is the code that actually executes when the LLM calls your tools:

"""Tool handlers — the code that runs when the LLM calls each tool."""

import json
import math

# Safe globals for expression evaluation — no file/network access
_SAFE_MATH = {
"abs": abs, "round": round, "min": min, "max": max,
"pow": pow, "sqrt": math.sqrt, "sin": math.sin, "cos": math.cos,
"tan": math.tan, "log": math.log, "log2": math.log2, "log10": math.log10,
"floor": math.floor, "ceil": math.ceil,
"pi": math.pi, "e": math.e,
"factorial": math.factorial,
}


def calculate(args: dict, **kwargs) -> str:
"""Evaluate a math expression safely.

Rules for handlers:
1. Receive args (dict) — the parameters the LLM passed
2. Do the work
3. Return a JSON string — ALWAYS, even on error
4. Accept **kwargs for forward compatibility
"""
expression = args.get("expression", "").strip()
if not expression:
return json.dumps({"error": "No expression provided"})

try:
result = eval(expression, {"__builtins__": {}}, _SAFE_MATH)
return json.dumps({"expression": expression, "result": result})
except ZeroDivisionError:
return json.dumps({"expression": expression, "error": "Division by zero"})
except Exception as e:
return json.dumps({"expression": expression, "error": f"Invalid: {e}"})


# Conversion tables — values are in base units
_LENGTH = {"m": 1, "km": 1000, "mi": 1609.34, "ft": 0.3048, "in": 0.0254, "cm": 0.01}
_WEIGHT = {"kg": 1, "g": 0.001, "lb": 0.453592, "oz": 0.0283495}
_DATA = {"B": 1, "KB": 1024, "MB": 1024**2, "GB": 1024**3, "TB": 1024**4}
_TIME = {"s": 1, "ms": 0.001, "min": 60, "hr": 3600, "day": 86400}


def _convert_temp(value, from_u, to_u):
# Normalize to Celsius
c = {"F": (value - 32) * 5/9, "K": value - 273.15}.get(from_u, value)
# Convert to target
return {"F": c * 9/5 + 32, "K": c + 273.15}.get(to_u, c)


def unit_convert(args: dict, **kwargs) -> str:
"""Convert between units."""
value = args.get("value")
from_unit = args.get("from_unit", "").strip()
to_unit = args.get("to_unit", "").strip()

if value is None or not from_unit or not to_unit:
return json.dumps({"error": "Need value, from_unit, and to_unit"})

try:
# Temperature
if from_unit.upper() in {"C","F","K"} and to_unit.upper() in {"C","F","K"}:
result = _convert_temp(float(value), from_unit.upper(), to_unit.upper())
return json.dumps({"input": f"{value} {from_unit}", "result": round(result, 4),
"output": f"{round(result, 4)} {to_unit}"})

# Ratio-based conversions
for table in (_LENGTH, _WEIGHT, _DATA, _TIME):
lc = {k.lower(): v for k, v in table.items()}
if from_unit.lower() in lc and to_unit.lower() in lc:
result = float(value) * lc[from_unit.lower()] / lc[to_unit.lower()]
return json.dumps({"input": f"{value} {from_unit}",
"result": round(result, 6),
"output": f"{round(result, 6)} {to_unit}"})

return json.dumps({"error": f"Cannot convert {from_unit}{to_unit}"})
except Exception as e:
return json.dumps({"error": f"Conversion failed: {e}"})

Key rules for handlers:

  1. Signature: def my_handler(args: dict, **kwargs) -> str
  2. Return: Always a JSON string. Success and errors alike.
  3. Never raise: Catch all exceptions, return error JSON instead.
  4. Accept **kwargs: Hermes may pass additional context in the future.

Step 5: Write the registration

Create __init__.py — this wires schemas to handlers:

"""Calculator plugin — registration."""

import logging

from . import schemas, tools

logger = logging.getLogger(__name__)

# Track tool usage via hooks
_call_log = []

def _on_post_tool_call(tool_name, args, result, task_id, **kwargs):
"""Hook: runs after every tool call (not just ours)."""
_call_log.append({"tool": tool_name, "session": task_id})
if len(_call_log) > 100:
_call_log.pop(0)
logger.debug("Tool called: %s (session %s)", tool_name, task_id)


def register(ctx):
"""Wire schemas to handlers and register hooks."""
ctx.register_tool(name="calculate", toolset="calculator",
schema=schemas.CALCULATE, handler=tools.calculate)
ctx.register_tool(name="unit_convert", toolset="calculator",
schema=schemas.UNIT_CONVERT, handler=tools.unit_convert)

# This hook fires for ALL tool calls, not just ours
ctx.register_hook("post_tool_call", _on_post_tool_call)

What register() does:

  • Called exactly once at startup
  • ctx.register_tool() puts your tool in the registry — the model sees it immediately
  • ctx.register_hook() subscribes to lifecycle events
  • ctx.register_cli_command() registers a CLI subcommand (e.g. hermes my-plugin <subcommand>)
  • If this function crashes, the plugin is disabled but Hermes continues fine

Step 6: Test it

Start Hermes:

hermes

You should see calculator: calculate, unit_convert in the banner's tool list.

Try these prompts:

What's 2 to the power of 16?
Convert 100 fahrenheit to celsius
What's the square root of 2 times pi?
How many gigabytes is 1.5 terabytes?

Check plugin status:

/plugins

Output:

Plugins (1):
✓ calculator v1.0.0 (2 tools, 1 hooks)

Your plugin's final structure

~/.hermes/plugins/calculator/
├── plugin.yaml # "I'm calculator, I provide tools and hooks"
├── __init__.py # Wiring: schemas → handlers, register hooks
├── schemas.py # What the LLM reads (descriptions + parameter specs)
└── tools.py # What runs (calculate, unit_convert functions)

Four files, clear separation:

  • Manifest declares what the plugin is
  • Schemas describe tools for the LLM
  • Handlers implement the actual logic
  • Registration connects everything

What else can plugins do?

Ship data files

Put any files in your plugin directory and read them at import time:

# In tools.py or __init__.py
from pathlib import Path

_PLUGIN_DIR = Path(__file__).parent
_DATA_FILE = _PLUGIN_DIR / "data" / "languages.yaml"

with open(_DATA_FILE) as f:
_DATA = yaml.safe_load(f)

Bundle a skill

Include a skill.md file and install it during registration:

import shutil
from pathlib import Path

def _install_skill():
"""Copy our skill to ~/.hermes/skills/ on first load."""
try:
from hermes_cli.config import get_hermes_home
dest = get_hermes_home() / "skills" / "my-plugin" / "SKILL.md"
except Exception:
dest = Path.home() / ".hermes" / "skills" / "my-plugin" / "SKILL.md"

if dest.exists():
return # don't overwrite user edits

source = Path(__file__).parent / "skill.md"
if source.exists():
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, dest)

def register(ctx):
ctx.register_tool(...)
_install_skill()

Gate on environment variables

If your plugin needs an API key:

# plugin.yaml — simple format (backwards-compatible)
requires_env:
- WEATHER_API_KEY

If WEATHER_API_KEY isn't set, the plugin is disabled with a clear message. No crash, no error in the agent — just "Plugin weather disabled (missing: WEATHER_API_KEY)".

When users run hermes plugins install, they're prompted interactively for any missing requires_env variables. Values are saved to .env automatically.

For a better install experience, use the rich format with descriptions and signup URLs:

# plugin.yaml — rich format
requires_env:
- name: WEATHER_API_KEY
description: "API key for OpenWeather"
url: "https://openweathermap.org/api"
secret: true
FieldRequiredDescription
nameYesEnvironment variable name
descriptionNoShown to user during install prompt
urlNoWhere to get the credential
secretNoIf true, input is hidden (like a password field)

Both formats can be mixed in the same list. Already-set variables are skipped silently.

Conditional tool availability

For tools that depend on optional libraries:

ctx.register_tool(
name="my_tool",
schema={...},
handler=my_handler,
check_fn=lambda: _has_optional_lib(), # False = tool hidden from model
)

Register multiple hooks

def register(ctx):
ctx.register_hook("pre_tool_call", before_any_tool)
ctx.register_hook("post_tool_call", after_any_tool)
ctx.register_hook("pre_llm_call", inject_memory)
ctx.register_hook("on_session_start", on_new_session)
ctx.register_hook("on_session_end", on_session_end)

Hook reference

Each hook is documented in full on the Event Hooks reference — callback signatures, parameter tables, exactly when each fires, and examples. Here's the summary:

HookFires whenCallback signatureReturns
pre_tool_callBefore any tool executestool_name: str, args: dict, task_id: strignored
post_tool_callAfter any tool returnstool_name: str, args: dict, result: str, task_id: strignored
pre_llm_callOnce per turn, before the tool-calling loopsession_id: str, user_message: str, conversation_history: list, is_first_turn: bool, model: str, platform: strcontext injection
post_llm_callOnce per turn, after the tool-calling loop (successful turns only)session_id: str, user_message: str, assistant_response: str, conversation_history: list, model: str, platform: strignored
on_session_startNew session created (first turn only)session_id: str, model: str, platform: strignored
on_session_endEnd of every run_conversation call + CLI exitsession_id: str, completed: bool, interrupted: bool, model: str, platform: strignored
pre_api_requestBefore each HTTP request to the LLM providermethod: str, url: str, headers: dict, body: dictignored
post_api_requestAfter each HTTP response from the LLM providermethod: str, url: str, status_code: int, response: dictignored

Most hooks are fire-and-forget observers — their return values are ignored. The exception is pre_llm_call, which can inject context into the conversation.

All callbacks should accept **kwargs for forward compatibility. If a hook callback crashes, it's logged and skipped. Other hooks and the agent continue normally.

pre_llm_call context injection

This is the only hook whose return value matters. When a pre_llm_call callback returns a dict with a "context" key (or a plain string), Hermes injects that text into the current turn's user message. This is the mechanism for memory plugins, RAG integrations, guardrails, and any plugin that needs to provide the model with additional context.

Return format

# Dict with context key
return {"context": "Recalled memories:\n- User prefers dark mode\n- Last project: hermes-agent"}

# Plain string (equivalent to the dict form above)
return "Recalled memories:\n- User prefers dark mode"

# Return None or don't return → no injection (observer-only)
return None

Any non-None, non-empty return with a "context" key (or a plain non-empty string) is collected and appended to the user message for the current turn.

How injection works

Injected context is appended to the user message, not the system prompt. This is a deliberate design choice:

  • Prompt cache preservation — the system prompt stays identical across turns. Anthropic and OpenRouter cache the system prompt prefix, so keeping it stable saves 75%+ on input tokens in multi-turn conversations. If plugins modified the system prompt, every turn would be a cache miss.
  • Ephemeral — the injection happens at API call time only. The original user message in the conversation history is never mutated, and nothing is persisted to the session database.
  • The system prompt is Hermes's territory — it contains model-specific guidance, tool enforcement rules, personality instructions, and cached skill content. Plugins contribute context alongside the user's input, not by altering the agent's core instructions.

Example: Memory recall plugin

"""Memory plugin — recalls relevant context from a vector store."""

import httpx

MEMORY_API = "https://your-memory-api.example.com"

def recall_context(session_id, user_message, is_first_turn, **kwargs):
"""Called before each LLM turn. Returns recalled memories."""
try:
resp = httpx.post(f"{MEMORY_API}/recall", json={
"session_id": session_id,
"query": user_message,
}, timeout=3)
memories = resp.json().get("results", [])
if not memories:
return None # nothing to inject

text = "Recalled context from previous sessions:\n"
text += "\n".join(f"- {m['text']}" for m in memories)
return {"context": text}
except Exception:
return None # fail silently, don't break the agent

def register(ctx):
ctx.register_hook("pre_llm_call", recall_context)

Example: Guardrails plugin

"""Guardrails plugin — enforces content policies."""

POLICY = """You MUST follow these content policies for this session:
- Never generate code that accesses the filesystem outside the working directory
- Always warn before executing destructive operations
- Refuse requests involving personal data extraction"""

def inject_guardrails(**kwargs):
"""Injects policy text into every turn."""
return {"context": POLICY}

def register(ctx):
ctx.register_hook("pre_llm_call", inject_guardrails)

Example: Observer-only hook (no injection)

"""Analytics plugin — tracks turn metadata without injecting context."""

import logging
logger = logging.getLogger(__name__)

def log_turn(session_id, user_message, model, is_first_turn, **kwargs):
"""Fires before each LLM call. Returns None — no context injected."""
logger.info("Turn: session=%s model=%s first=%s msg_len=%d",
session_id, model, is_first_turn, len(user_message or ""))
# No return → no injection

def register(ctx):
ctx.register_hook("pre_llm_call", log_turn)

Multiple plugins returning context

When multiple plugins return context from pre_llm_call, their outputs are joined with double newlines and appended to the user message together. The order follows plugin discovery order (alphabetical by plugin directory name).

Register CLI commands

Plugins can add their own hermes <plugin> subcommand tree:

def _my_command(args):
"""Handler for hermes my-plugin <subcommand>."""
sub = getattr(args, "my_command", None)
if sub == "status":
print("All good!")
elif sub == "config":
print("Current config: ...")
else:
print("Usage: hermes my-plugin <status|config>")

def _setup_argparse(subparser):
"""Build the argparse tree for hermes my-plugin."""
subs = subparser.add_subparsers(dest="my_command")
subs.add_parser("status", help="Show plugin status")
subs.add_parser("config", help="Show plugin config")
subparser.set_defaults(func=_my_command)

def register(ctx):
ctx.register_tool(...)
ctx.register_cli_command(
name="my-plugin",
help="Manage my plugin",
setup_fn=_setup_argparse,
handler_fn=_my_command,
)

After registration, users can run hermes my-plugin status, hermes my-plugin config, etc.

Memory provider plugins use a convention-based approach instead: add a register_cli(subparser) function to your plugin's cli.py file. The memory plugin discovery system finds it automatically — no ctx.register_cli_command() call needed. See the Memory Provider Plugin guide for details.

Active-provider gating: Memory plugin CLI commands only appear when their provider is the active memory.provider in config. If a user hasn't set up your provider, your CLI commands won't clutter the help output.

Distribute via pip

For sharing plugins publicly, add an entry point to your Python package:

# pyproject.toml
[project.entry-points."hermes_agent.plugins"]
my-plugin = "my_plugin_package"
pip install hermes-plugin-calculator
# Plugin auto-discovered on next hermes startup

Common mistakes

Handler doesn't return JSON string:

# Wrong — returns a dict
def handler(args, **kwargs):
return {"result": 42}

# Right — returns a JSON string
def handler(args, **kwargs):
return json.dumps({"result": 42})

Missing **kwargs in handler signature:

# Wrong — will break if Hermes passes extra context
def handler(args):
...

# Right
def handler(args, **kwargs):
...

Handler raises exceptions:

# Wrong — exception propagates, tool call fails
def handler(args, **kwargs):
result = 1 / int(args["value"]) # ZeroDivisionError!
return json.dumps({"result": result})

# Right — catch and return error JSON
def handler(args, **kwargs):
try:
result = 1 / int(args.get("value", 0))
return json.dumps({"result": result})
except Exception as e:
return json.dumps({"error": str(e)})

Schema description too vague:

# Bad — model doesn't know when to use it
"description": "Does stuff"

# Good — model knows exactly when and how
"description": "Evaluate a mathematical expression. Use for arithmetic, trig, logarithms. Supports: +, -, *, /, **, sqrt, sin, cos, log, pi, e."