hermes atlas
168·repos hermes·v0.15.2 ★ star this repo
dev tutorial · module 3 of 7 · the extension points

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.

Hermes Atlas dev tutorial · ~14 min · for builders, no CS degree assumed

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:

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 why tool_result(...) and tool_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:

ToolsetWhat's in it
webWeb search + content extraction (web_search, web_extract).
fileRead, write, patch, and search files.
terminalRun shell commands and manage processes.
browserHeadless-browser automation (navigate, click, type, snapshot).
codingThe composite a coding assistant wants — files + terminal + search, bundled.
safeA constrained bundle that deliberately leaves out the terminal.
debuggingTools geared toward inspecting and diagnosing.
hermes-cli, hermes-telegramPlatform 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:

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.)

  1. Create the file. Add tools/my_tool.py. That's the whole "where does it go" question — answered.
  2. Write the handler. A def handler(args, **kwargs) -> str that does the work and returns a JSON string (use tool_result() / tool_error()).
  3. Define the schema. A dict with name, a crisp description (this is your tooltip — make it earn its place), and parameters describing the inputs.
  4. Add a check_fn (optional). If your tool needs a key or a binary, gate it so it only appears when usable.
  5. Register it. Call registry.register(name=..., toolset=..., schema=..., handler=..., check_fn=..., emoji=...) at the top level of the file.
  6. Put it in a toolset. Add the tool name to the relevant bundle in toolsets.py so it ships with a bundle people actually enable.
  7. 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

Quick check — You add a perfectly working handler and call registry.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 with hermes tools / /tools (and add it to a bundle in toolsets.py). (2) Your check_fn is returning False (a missing key/binary), so the tool is filtered out of the schema the model sees. Bonus third cause: a thin, vague description means 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."

← Module 2 · The agent loop

Module 4 · Skills — the self-improvement loop →

↩ Dev tutorial index