Tools & toolsets
In Module 2 you watched the loop call a tool, see the result, and decide what to do next. Now we open the toolbox. A tool is the single most concrete thing you can add to Hermes — a capability the model is allowed to reach for — and by the end you'll know exactly what one is made of and how to add it.
In this module — what a tool actually is, the self-registering registry, a minimal real example, the check_fn availability gate, composable toolsets, the command-approval safety model, and a step-by-step recipe for adding your own.
What a tool actually is
Strip away the jargon and a tool is just four things bundled together:
- A name — like
web_searchorread_file. This is what the model "calls." - A JSON schema — a description of the tool and its inputs, written in OpenAI function-calling format. This is the only thing the model sees about your tool; it reads this to decide when and how to use it.
- A handler — a plain Python function that does the actual work and returns a JSON string as the result.
- Some metadata — which toolset it belongs to, an emoji for the UI, and an optional availability check (more on that below).
Mental model — A tool's schema is a labeled button with a tooltip. The label and tooltip (name + description + parameters) are what the model reads to decide whether to press it. The handler is the wiring behind the button — what physically happens when it's pressed. The model only ever sees the label; it never sees your Python. So a vague, sloppy description is a button with no tooltip — the model won't know when to press it.
The registry: tools that register themselves
Here's the elegant part. There is no central list of tools to edit. Every tool lives as its own file in tools/, and at the moment Python imports that file, the file calls registry.register(...) to announce itself. Drop a file in, and it's on the team.
This machinery lives in tools/registry.py. On startup, discover_builtin_tools() scans the tools/ folder, finds every module that contains a top-level registry.register(...) call, and imports them. Each registration creates a ToolEntry — a small record holding the name, schema, handler, and metadata. From then on the rest of the agent just asks the registry, "what tools do I have, and what are their schemas?"
Why self-registration is a gift to you — It means adding a tool never requires editing some giant shared file that everyone else also edits (and that causes merge conflicts). Your new ability is fully described in one new file. This is the "narrow waist, capability at the edges" rule from Module 1, made literal.
Anatomy of a tool (a real, minimal example)
Let's build the smallest possible tool so the four pieces are concrete. It greets someone by name. Notice the three sections — handler, schema, registration — and the two helpers tool_result() / tool_error() imported from the registry (they save you writing json.dumps(...) by hand):
tools/hello_tool.py
from tools.registry import registry, tool_error, tool_result
# 1. THE HANDLER — does the work, returns a JSON *string*.
def handler(args, **kwargs) -> str:
name = (args or {}).get("name", "").strip()
if not name:
return tool_error("name is required")
return tool_result(greeting=f"Hello, {name}!")
# 2. THE SCHEMA — the labeled button the model reads.
SCHEMA = {
"name": "hello_world",
"description": "Greet a person by name. Use when asked to say hello.",
"parameters": {
"type": "object",
"properties": {"name": {"type": "string", "description": "Who to greet"}},
"required": ["name"],
},
}
# 3. THE REGISTRATION — runs at import time; this *is* "joining the team".
registry.register(
name="hello_world",
toolset="hello",
schema=SCHEMA,
handler=handler,
check_fn=None, # always available (see below)
emoji="👋",
)
That's a complete, working tool. The handler signature def handler(args, **kwargs) -> str is the contract: args is the dict of inputs the model filled in (matching your schema), and you must hand back a JSON string. The **kwargs catches context the runtime may pass in; you can ignore it for simple tools.
The one rule that trips up beginners — Your handler must return a string (specifically JSON), never a raw dict or a Python object. That's whytool_result(...)andtool_error(...)exist — they serialize for you and keep the error shape consistent ({"error": "..."}), which is exactly what the loop expects to read back.
check_fn: the availability gate
That check_fn argument is quietly one of the most important ideas in the whole tool system. It's an optional function that returns True or False, and it decides whether a tool is even offered to the model right now.
Examples from the real codebase: the terminal tool's check probes whether Docker is installed; browser tools check for a Playwright binary; the smart-home tools only appear if a HASS_TOKEN is set. If the check returns False, the tool simply isn't in the schema the model receives — so the model can't try (and fail) to use something that isn't configured.
For performance, registry.py caches each check_fn result for about 30 seconds (probing Docker or a binary on every single turn would be wasteful). The upshot for you: when you flip something on with hermes tools or drop in an API key, your toolbox updates within a turn or two — no restart needed.
Foreshadow — This "the toolbox changes based on what's configured" behavior is rung 3 of the Footprint Ladder we'll climb in Module 7. For now, just know: a tool can be present in the code but absent from the model's view, and check_fn is the switch.
Toolsets: bundles of tools
A toolset is just a named bundle of tools — and crucially, a bundle can include other bundles. They're defined in toolsets.py. When the agent needs the actual list of tools for a bundle, resolve_toolset() flattens it: it follows every includes reference recursively and returns the full, de-duplicated set of tool names.
This composition is why you can say "give me the coding bundle" instead of listing twenty tools by hand. Some you'll meet often:
| Toolset | What's in it |
|---|---|
web | Web search + content extraction (web_search, web_extract). |
file | Read, write, patch, and search files. |
terminal | Run shell commands and manage processes. |
browser | Headless-browser automation (navigate, click, type, snapshot). |
coding | The composite a coding assistant wants — files + terminal + search, bundled. |
safe | A constrained bundle that deliberately leaves out the terminal. |
debugging | Tools geared toward inspecting and diagnosing. |
hermes-cli, hermes-telegram | Platform bundles — the right toolset for the terminal UI vs. a Telegram bot. |
all / * | The special "everything registered" bundle. |
You choose what's enabled with hermes tools at the command line, or /tools inside a session. Your choice persists to config.yaml in ~/.hermes/, so it sticks across restarts. (Reference: toolsets reference.)
The safety model: approvals before destruction
An agent that can run terminal can, in principle, run rm -rf or DROP TABLE. Hermes does not just trust the model here. Genuinely dangerous commands are gated by an approval system in tools/approval.py:
- In the CLI, it pauses and asks you to approve before the command runs.
- In the gateway (Telegram, Discord, etc.), it queues an approval request rather than silently executing.
- Once you approve a pattern, it's remembered via an allowlist, so you're not nagged about the same safe command forever.
Safety — There is an environment variable, HERMES_YOLO_MODE, that bypasses approvals entirely — every dangerous command runs without asking. It exists for sandboxes and automation (for example, the one-shot runner sets it because there's no human to prompt). It is read once at startup and frozen, specifically so prompt-injected text can't turn it on mid-conversation. On a real machine with your real files, turning this on is genuinely dangerous — an unlucky command can delete data with no confirmation. Treat it as "I am in a disposable container," nothing less.
How to add your own tool
Putting it all together, here's the full path from nothing to an enabled tool. (Official walkthrough: adding tools.)
- Create the file. Add
tools/my_tool.py. That's the whole "where does it go" question — answered. - Write the handler. A
def handler(args, **kwargs) -> strthat does the work and returns a JSON string (usetool_result()/tool_error()). - Define the schema. A dict with
name, a crispdescription(this is your tooltip — make it earn its place), andparametersdescribing the inputs. - Add a
check_fn(optional). If your tool needs a key or a binary, gate it so it only appears when usable. - Register it. Call
registry.register(name=..., toolset=..., schema=..., handler=..., check_fn=..., emoji=...)at the top level of the file. - Put it in a toolset. Add the tool name to the relevant bundle in
toolsets.pyso it ships with a bundle people actually enable. - Enable it. Run
hermes tools(or/tools) to turn its toolset on, then ask the agent to use it and watch the loop call it.
An honest caveat: should you even add a tool?
Now the grown-up advice. Adding a core tool is the most powerful extension point — and, per the project's own guidance, usually not the right one. The architecture doc is blunt that new core tools are rarely the answer. Most new capability should be a skill (Module 4), a small CLI command + skill, a plugin, or an MCP server — all of which live at the edges without touching the core.
When a tool genuinely is the right answer — Reach for a real tool when you're adding a fundamentally new kind of action the agent simply can't do today — a new I/O channel, a new external system it must call directly in the loop. And, honestly, building one is the best way to understand the system. Just don't reach for it reflexively when a skill would do. We give you the full decision tree (the Footprint Ladder) in Module 7.
Key takeaways
- A tool = name + JSON schema + handler + metadata. The model only ever sees the schema.
- Tools self-register at import time via
registry.register(...)— no central list to edit. - The handler must return a JSON string; use
tool_result()/tool_error(). check_fngates availability — that's why your toolbox changes with what's configured.- Toolsets are composable bundles;
resolve_toolset()flattens them. Enable withhermes tools. - Dangerous commands go through
tools/approval.py;HERMES_YOLO_MODEbypasses it and is dangerous on real machines. - Adding a core tool is rarely the right move — prefer a skill, plugin, or MCP server (Module 7).
Quick check — You add a perfectly workinghandlerand callregistry.register(...), but the model never uses your tool. What are the two most likely causes?
Answer: (1) Its toolset isn't enabled — registering a tool only makes it available; you still have to turn its toolset on withhermes tools//tools(and add it to a bundle intoolsets.py). (2) Yourcheck_fnis returningFalse(a missing key/binary), so the tool is filtered out of the schema the model sees. Bonus third cause: a thin, vaguedescriptionmeans the model doesn't realize when to press the button.
Pair with your AI — make it concrete in the real repo. Try: "In the hermes-agent repo, open tools/registry.py and one simple tool file (like tools/memory_tool.py). Walk me through, for that tool, exactly what its name, schema, handler, and check_fn are — and show me the line where it calls registry.register(). I'm learning how tools are wired, keep it concrete and point at line numbers."