Lifecycle helpers for hermes-agent plugins — convention-correct tools, hooks, skills, validation, and safe logging, baked in.
hermes-plugin-kit is a tiny, dependency-free helper for authoring plugins for
hermes-agent. Decorate a tool
with @tool or a lifecycle callback with @hook, then use register_plugin to
register tools, hooks, and plugin-owned skills together. Existing tool-only
plugins can keep using register_all; the LLM-facing schema,
argument validation, structured logging, and the JSON result envelope are all
generated for you — correctly, every time.
This began as a one-line fix. A hermes plugin had shipped its tool arguments at the
top level of the schema instead of under parameters; the model received a tool with
no arguments and couldn't call it until someone opened the plugin's source to find the
field names. The fix was trivial — but the same mistake was latent in every other
plugin that hand-rolls its schemas, envelopes, and logging.
Fixing them one at a time and hoping the next author remembers the rules doesn't scale.
So we pulled the conventions out into a single, reusable kit. Get them right once, here,
and every plugin that reaches for @tool inherits them — and the next person reading a
tool's logs can actually see what went wrong.
Hermes turns each tool into an OpenAI-style function: {"type": "function", "function": {**schema, "name": ...}}. That means a tool's arguments must live
under a parameters key. It's a small detail with an outsized failure mode, and
hand-written plugins keep tripping over the same things:
- Empty
{}arguments. Putpropertiesat the top level instead of underparametersand the model receives a tool with no arguments — it can't tell what to pass, and silently guesses wrong field names until it gives up. TypeError: unhashable type: 'slice'. Return adictinstead of a JSON string and the framework crashes downstream.- Silent failures. A terse error with no log leaves operators staring at "invalid input" with no idea what the model actually sent.
These aren't exotic — they're the default mistakes when every plugin re-implements
the same boilerplate. hermes-plugin-kit makes them structurally impossible:
- Schema convention — arguments are always nested under
parameters. - Self-documenting — required argument names and examples are appended to the description, the one field a model reliably sees.
- Validation + instructive errors — a missing or blank required argument returns an error that names the argument and its example.
- Explicit tool namespacing — build names with
tool_name(namespace, verb, noun)and reject Hermes agent-loop names such asmemory. - Logging —
DEBUGwhen a tool is invoked,WARNINGon rejected calls and exceptions (including tracebacks), andINFOon success with elapsed time and result mode. Arguments are truncated and nested secret-looking values are recursively redacted.register_allalso logs the registered tool inventory. - Envelope + safety — return a plain
dict(or raise); the kit encodes the JSON string, catches exceptions, and always returnsstrfrom an(args, **kwargs)handler. - Host invocation — call non-registry Hermes capabilities such as
send_messagewithout bypassing plugin guard and audit hooks. - Typed media delivery — declare
MediaPayloadasauto,voice, ordocument; resolve task-localorigininside the kit; and receive a privacy-safeMediaDeliveryResultafter the real Hermes-agent host send. These types encode Hermes-agent'ssend_messagecontract; they are not an OpenClaw compatibility layer.
Anyone writing or maintaining a hermes-agent plugin who wants their tools to be correct and debuggable without copy-pasting the same schema/envelope/logging scaffolding into every file. It pairs naturally with the hermes plugin conventions and adds nothing to your runtime footprint — pure standard library.
The package is consumed straight from Git (works great with uv):
uv add git+https://github.com/offendingcommit/hermes-plugin-kit.git
# or
pip install git+https://github.com/offendingcommit/hermes-plugin-kit.gitWith uv, pin it as a source in your plugin's pyproject.toml:
dependencies = ["hermes-plugin-kit"]
[tool.uv.sources]
hermes-plugin-kit = { git = "https://github.com/offendingcommit/hermes-plugin-kit.git", branch = "main" }tools.py:
from hermes_plugin_kit import tool, tool_name, register_all, str_arg, int_arg
@tool(
toolset="messaging",
namespace="discord",
name=tool_name("discord", "read", "thread"),
requires_env=["DISCORD_BOT_TOKEN"],
params={
"thread_id_or_url": str_arg(
"Discord thread link or numeric ID",
required=True, example="123456789012345678",
),
"limit": int_arg("Messages to return", minimum=1, maximum=100),
},
)
def discord_read_thread(args, **kwargs):
"""Read recent messages from a Discord thread the bot can already access."""
return {"messages": read_thread(args["thread_id_or_url"], args.get("limit", 100))}__init__.py:
from hermes_plugin_kit import register_all
from . import tools
def register(ctx):
register_all(ctx, tools.__name__)That's it. discord_read_thread is registered with a parameters-wrapped schema, a
self-documenting description, required-argument validation, logging, and the JSON
envelope — none of which you had to write.
Use the lifecycle entrypoint when a plugin provides more than tools:
from pathlib import Path
from hermes_plugin_kit import hook, plugin_skill, register_plugin
@hook("pre_llm_call")
def inject_context(**kwargs):
return {"context": build_context(kwargs)}
SKILLS = (
plugin_skill(
"temporal-awareness",
Path(__file__).with_name("SKILL.md"),
"Calibrate responses against local time and message gaps.",
optional=True,
),
)
def register(ctx):
return register_plugin(ctx, __name__, skills=SKILLS)@hook forwards Hermes keyword arguments and return values unchanged. It logs
only the hook name, elapsed time, result type, and supplied session_id or
task_id; callback payloads and exception messages are never logged. Exceptions
are re-raised so Hermes retains its normal per-plugin isolation behavior.
plugin_skill validates the bare skill name, SKILL.md path, and description.
Required missing skills fail registration; optional missing skills warn and are
reported in the returned RegistrationSummary. Hermes supplies the plugin
namespace, so a declared temporal-awareness skill from plugin
temporal-awareness resolves as temporal-awareness:temporal-awareness.
Hermes uses one global tool registry, and the agent loop intercepts core names before registry dispatch. Plugin tools should use an explicit domain namespace and an action verb:
name=tool_name("discord", "read", "thread") # discord_read_thread
name=tool_name("workspace", "write", "diary") # workspace_write_diary
name=tool_name("workspace", "patch", "text") # workspace_patch_textDo not register plugin tools with agent-loop names such as memory, todo,
session_search, or delegate_task. The kit also rejects the reserved
memory_ prefix so plugin tools cannot be confused with Hermes' built-in
persistent memory tool.
str_arg(description, *, required=False, example=None, enum=None, min_length=None, **extra)int_arg(description, *, required=False, example=None, minimum=None, maximum=None, **extra)bool_arg(description, *, required=False, example=None, **extra)arg(type, description, *, required=False, example=None, enum=None, **extra)— generic
required and example are kit metadata (stripped from the emitted JSON Schema, used
for validation, error text, and the self-documenting description). Any other keyword
passes through verbatim into the JSON Schema for that property.
A handler returns a dict (becomes the success data), or raises (becomes a tool
error), or returns a str as an escape hatch (treated as already-encoded JSON). It must
accept (args, **kwargs) — runtime keys like task_id/session_id arrive as kwargs.
Not every Hermes capability lives in tools.registry. In particular,
send_message is a host-managed runtime service, so calling
registry.dispatch("send_message", ...) from inside a plugin returns an unknown-tool
error. Use the kit's typed media seam instead:
from hermes_plugin_kit import MediaPayload, MediaType, deliver_media
def deliver_voice_memo(path: str, **runtime_context):
return deliver_media(
MediaPayload(path, MediaType.VOICE),
target="origin",
**runtime_context,
)MediaType.VOICE accepts only .ogg/.opus and emits Hermes'
[[audio_as_voice]] directive. MediaType.DOCUMENT emits [[as_document]];
MediaType.AUTO lets Hermes choose from the extension. origin resolves
through Hermes' task-local platform/chat/thread context inside the kit, so a
plugin never imports gateway internals or exposes raw group IDs to the model.
The returned MediaDeliveryResult carries success, media type, path, requested
route, a privacy-safe display route, spoiler state, and a redacted host result.
Telegram spoiler photos are available without patching Hermes core:
deliver_media(
MediaPayload("/opt/data/avatars/generated/reveal.png", spoiler=True),
target="origin",
**runtime_context,
)The ordinary path remains Hermes' host-managed send_message. Because that
host contract does not currently expose Telegram's has_spoiler, only
spoiler=True uses the kit's narrow Telegram extension. The extension accepts
JPG, JPEG, PNG, and WebP photos, resolves the same Hermes current-chat/home
routes (including group topics), runs the normal Hermes pre_tool_call and
post_tool_call hooks, forwards has_spoiler=True, closes its one-shot Bot
client, and returns the same privacy-safe typed result. Voice, document,
non-Telegram, and unsupported-image requests are rejected rather than silently
losing spoiler intent. The Telegram token remains runtime-owned and is never a
model argument or result field.
Direct delivery and final response delivery are separate stages in Hermes. A
consumer that calls deliver_media must register the kit's matching Hermes
lifecycle hooks so a successful direct send cannot be followed by model-authored
text or a duplicate MEDIA: directive:
from hermes_plugin_kit import (
clear_media_delivery_state,
transform_media_delivery_output,
)
def register(ctx):
ctx.register_hook("transform_llm_output", transform_media_delivery_output)
ctx.register_hook("on_session_end", clear_media_delivery_state)Successful delivery arms a one-turn marker for the current Hermes session.
transform_media_delivery_output consumes it and returns Hermes' canonical
NO_REPLY response before the gateway sees the final text. Failed delivery does
not arm suppression, and on_session_end clears any unconsumed marker. These are
Hermes-agent lifecycle and response contracts; they are not OpenClaw shapes.
For non-media host calls, invoke_host_tool remains the lower-level seam.
invoke_host_tool resolves the supported direct host handler and wraps the nested
operation with Hermes pre_tool_call and post_tool_call hooks. A blocking hook
prevents the handler from running. If the guard API is unavailable, invocation is
refused rather than sending without policy checks. send_message is the currently
supported host tool; unknown names fail explicitly.
The upstream Hermes contract suite runs image and typed voice payloads through
the real send_message target parser, media extractor, and Telegram formatter.
It mocks only the final Bot API client and asserts that Hermes calls send_photo
and send_voice with the expected files, without separate text messages. It
also runs the kit-owned spoiler extension against Hermes' real config, session,
async bridge, and Telegram library shapes while mocking only Bot network calls.
The kit logs under the decorated handler's module logger, so each plugin can control verbosity with normal Python logging configuration. Tool lifecycle logs include:
DEBUG: invocation with truncated, recursively redacted arguments and safesession_id/task_idcontext when supplied by Hermes.WARNING: required-argument rejection or a handler exception. Exceptions uselogger.exception, preserving the traceback for runtime diagnosis.INFO: successful completion withelapsed_msand whether the handler returned a dictionary-like result or an already-encoded string.INFO: a registration summary fromregister_all, including count and names.
The kit never logs handler result payloads. Keys containing token, secret,
password, passwd, api_key, apikey, or auth are replaced with *** at
any nesting depth before arguments are logged.
Uses uv. Install it with brew install uv (macOS) or
curl -LsSf https://astral.sh/uv/install.sh | sh.
make install # uv sync — create/sync the dev environment
make test # uv run python -m unittest discover -s tests
make test-one T=tests.test_kit.SchemaConventionTests
make build # uv build — wheel + sdistCI runs make test on actions/checkout@v6 + astral-sh/setup-uv@v8.2.0 (Python 3.11).
MIT