Skip to content
Agentic Control Plane

Hermes Agent hooks: shell, plugin and gateway reference

David Crowe David Crowe · · Updated · 15 min read
hermes hooks reference
Share X HN LinkedIn

To control tools in Hermes Agent, use pre_tool_call. It runs before every tool call, and your hook can block the call, send it to Hermes’s human approval prompt, or rewrite its arguments. You write the hook as a shell script in ~/.hermes/config.yaml or as a Python plugin.

This page covers Hermes Agent by Nous Research, the open-source agent. Other projects share the Hermes name. Everything below comes from the project’s Event Hooks documentation and source unless linked otherwise. For approvals, the blocklist, the file-write guard and yolo, see the Hermes control model.

Where hooks live

Hermes has four hook systems:

System Declared in Runs in Can block a tool call
Shell hooks hooks: block in ~/.hermes/config.yaml; scripts by convention in ~/.hermes/agent-hooks/ CLI, gateway, Desktop, TUI, dashboard chat Yes
Plugin hooks ctx.register_hook() in a plugin under ~/.hermes/plugins/<name>/ CLI and gateway Yes
Gateway hooks HOOK.yaml + handler.py in ~/.hermes/hooks/<name>/ Gateway only No
Outbound webhooks hooks.outbound: in ~/.hermes/config.yaml CLI and gateway No

Shell and plugin hooks share one dispatcher and one event list. Python plugins register first, shell hooks second. Gateway hooks have their own events (gateway:startup, session:start, agent:start, agent:step, agent:end, command:*) and never load in the CLI, TUI, Desktop or cron.

Events for tool control

The full catalog runs to dozens of events. These are the ones you will use:

Event Kind What a hook can do
pre_tool_call Directive Block, escalate to approval, or modify args
post_tool_call Observer Read the result; return ignored
transform_tool_result Transform First string returned replaces the result
transform_terminal_output Transform Replace terminal output before the output limit
pre_llm_call Directive Inject context, or block the turn before inference
pre_approval_request / post_approval_response Observer See approval prompts and their answers
on_session_start / on_session_end Observer Lifecycle logging
subagent_start / subagent_stop Observer Delegation tracking
post_api_request Observer Model, provider, usage per request

Valid names come from hermes_cli.plugins.VALID_HOOKS. A typo in a shell hook gets a “Did you mean X?” warning and the hook is skipped.

The decision contract

pre_tool_call fires once per tool call, built-in and plugin tools alike, before the handler runs. Three parallel tool calls fire it three times.

A plugin callback returns one of:

return {"action": "block", "message": "Reason the tool call was blocked"}
return {"action": "approve", "message": "Why approval is required", "rule_key": "optional:scope"}
return {"action": "modify", "args": {"new_string": "fixed content"}}
# anything else: no directive, the call proceeds

A shell hook prints JSON to stdout. Both shapes are accepted:

{"decision": "block", "reason": "Forbidden: rm -rf"}
{"action": "block", "message": "Forbidden: rm -rf"}
{"decision": "modify", "tool_input": {"new_string": "fixed content"}}
{"action": "approve", "message": "Why approval is required"}

The rules:

  • Block beats approve, and approve beats nothing, whatever order the hooks registered in. Among several approves, the first wins.
  • block needs a non-empty message. The message goes back to the model as the tool error.
  • approve sends the call to Hermes’s human approval prompt. If the human denies it, the prompt times out, or the prompt errors, the call does not run.
  • modify is a shallow merge over the original args. Several hooks accumulate, and on the same key the later hook wins.
  • Claude Code’s {"decision": "approve"} is not mapped. In Claude Code it means auto-allow. Hermes uses action: approve for “ask a human.”
  • Exit code 2 blocks a shell pre_tool_call even with no JSON. The message comes from stdout JSON, then the first 400 characters of stderr, then "Blocked by shell hook."

Configuration

Shell hook schema:

hooks:
  pre_tool_call:
    - matcher: "terminal|write_file|patch"   # regex on tool name; pre/post_tool_call only
      command: "~/.hermes/agent-hooks/guard.sh"   # shlex.split, shell=False
      timeout: 10          # default 60, capped at 300
      fail_closed: true    # default false; pre_tool_call only

hooks_auto_accept: false

The script receives this on stdin:

{
  "hook_event_name": "pre_tool_call",
  "tool_name": "terminal",
  "tool_input": {"command": "rm -rf /"},
  "session_id": "sess_abc123",
  "cwd": "/home/user/project",
  "profile": "default",
  "extra": {"task_id": "...", "tool_call_id": "..."}
}

Plugin hooks live in a plugin directory:

~/.hermes/plugins/my-guard/
├── plugin.yaml      # name, version, provides_hooks: [pre_tool_call]
└── __init__.py      # def register(ctx): ctx.register_hook(...)

General plugins are off by default. hermes plugins enable my-guard adds the name to plugins.enabled. Plugin callbacks get keyword arguments, so always accept **kwargs.

Examples

Block destructive terminal commands (shell). Adapted from the docs’ own example, with fail-closed on:

hooks:
  pre_tool_call:
    - matcher: "terminal"
      command: "~/.hermes/agent-hooks/block-rm-rf.sh"
      timeout: 5
      fail_closed: true
#!/usr/bin/env bash
# ~/.hermes/agent-hooks/block-rm-rf.sh
cmd=$(jq -r '.tool_input.command // empty')
if echo "$cmd" | grep -qE 'rm[[:space:]]+-rf?[[:space:]]+/|git[[:space:]]+push.*--force'; then
  printf '{"decision": "block", "reason": "blocked by hook: destructive command"}\n'
else
  printf '{}\n'
fi

Require a human for writes outside the project (plugin). Returns approve:

import os

def guard_writes(tool_name, args, **kwargs):
    if tool_name in ("write_file", "patch"):
        path = os.path.abspath(os.path.expanduser(args.get("path", "")))
        if not path.startswith(os.getcwd()):
            return {"action": "approve",
                    "message": f"Write outside the project: {path}",
                    "rule_key": "guard:outside-cwd"}
    return None

def register(ctx):
    ctx.register_hook("pre_tool_call", guard_writes)

Redact secrets from any tool result (plugin). From the docs:

import re
SECRET = re.compile(r"sk-[A-Za-z0-9]{32,}")

def redact_secrets(tool_name, result, **kwargs):
    if SECRET.search(result):
        return SECRET.sub("[REDACTED]", result)
    return None

def register(ctx):
    ctx.register_hook("transform_tool_result", redact_secrets)

Log every tool call (shell). Returns nothing:

hooks:
  post_tool_call:
    - command: "~/.hermes/agent-hooks/log.sh"
#!/usr/bin/env bash
# ~/.hermes/agent-hooks/log.sh
jq -c '{ts: (now | todate), tool: .tool_name, input: .tool_input, session: .session_id}' \
  >> ~/.hermes/logs/tools.jsonl
printf '{}\n'

Get a desktop notification when Hermes waits on approval (plugin). pre_approval_request is the documented place for this:

import subprocess

def notify(command, description, **kwargs):
    subprocess.Popen(["osascript", "-e",
        f'display notification "{description}" with title "Hermes needs approval"'])

def register(ctx):
    ctx.register_hook("pre_approval_request", notify)

Headless runs

Consent decides whether a shell hook runs at all. Each unique (event, command) pair prompts once, and the answer is saved to ~/.hermes/shell-hooks-allowlist.json. Only a foreground hermes chat (or hermes rl) asks. The gateway, cron, hermes acp, hermes mcp-serve and CI never prompt. There, a new hook stays unregistered, with a warning naming the allowlist file, unless one of these is set:

hermes --accept-hooks chat           # flag
export HERMES_ACCEPT_HOOKS=1         # env var
# or hooks_auto_accept: true in ~/.hermes/config.yaml

For service accounts, write the allowlist by hand. The command must match the configured string exactly:

{"approvals": [{"event": "pre_tool_call", "command": "~/.hermes/agent-hooks/block-rm-rf.sh"}]}

HERMES_SAFE_MODE=1 skips plugins, shell hooks and outbound webhooks. It does not skip gateway hooks.

Prompts for dangerous commands in unattended runs are a separate setting. Per the security docs, approvals.cron_mode, single_query_mode (hermes chat -q) and unattended_mode all default to deny.

Troubleshooting

hermes hooks list                                    # configured hooks, matcher, timeout, consent
hermes hooks test pre_tool_call --for-tool terminal  # fire matching hooks on a synthetic payload
hermes hooks doctor                                  # exec bit, allowlist, mtime drift, JSON validity
hermes hooks revoke <command>                        # drop allowlist entries
  • Matcher never matches. The regex must match the whole tool name, so term does not match terminal. Write terminal|write_file, not a fragment.
  • Hook runs but a failure changes nothing. Shell hooks fail open by default. Spawn errors, timeouts, non-zero exits and non-JSON stdout log a warning and the call goes ahead. Set fail_closed: true on security gates.
  • Plugin hook silent. Check hermes plugins list. A newly registered plugin takes effect on the next session, per the ACP Hermes guide.
  • Every tool call blocked with a timeout message. A plugin pre_tool_call callback ran past plugins.hook_callback_timeout (default 30s). That path blocks the call.
  • One WARNING about missing fields. Your callback names an argument the hook does not send, such as tool_data instead of tool_name and args.
  • Edited script still trusted. Consent keys on the command string, not a hash. hermes hooks doctor flags mtime drift.

What a hook can and cannot see

A pre_tool_call hook sees the tool name, the raw arguments, and IDs: session_id, task_id, conversation_id, tool_call_id, turn_id. A shell hook also gets cwd and profile. Post-call hooks add result, duration_ms and status. pre_llm_call sees the full user message and history. post_api_request sees usage, which is how local cost tracking works.

What it cannot do:

  • post_tool_call cannot change anything. Its return is ignored. Use a transform hook.
  • pre_approval_request cannot veto or pre-answer an approval. Block in pre_tool_call instead.
  • Gateway hooks cannot block. They only watch gateway lifecycle events.
  • A hook sees the tool call, not what runs inside it. An execute_code script is one call. The subprocess and os calls inside it never pass through the dangerous-command patterns, per the security docs.
  • Payloads carry no verified user identity. profile and the IDs say which session, not which person.

Limits of hooks on one machine

Hooks run on the machine and are configured by whoever owns ~/.hermes/. Shell hooks fail open unless you say otherwise. Consent trusts a script after you edit it. Gateway hooks load by sitting in the folder, with no prompt. A regex on terminal catches obvious commands and nothing clever. Logs a hook writes sit where the agent can reach them. And each machine’s hooks and allowlists drift on their own, so there is no fleet view.

If you want Hermes tool calls checked and logged in one place alongside your other agents, the ACP guide for Hermes covers it. A deny blocks the call with a message, and an ask goes to Hermes’s own approval prompt. ACP cannot rewrite tool output on Hermes today. The install box below sets it up in one line.

Frequently asked questions

hermes agent hooks: what kinds are there?

Four. Shell hooks are scripts declared under hooks: in ~/.hermes/config.yaml. Plugin hooks are Python callbacks registered with ctx.register_hook(). Gateway hooks are HOOK.yaml plus handler.py directories in ~/.hermes/hooks/ that run only in the messaging gateway. Outbound webhooks POST signed events to a URL.

How does a Hermes hook block a tool call?

From pre_tool_call, return {“action”: “block”, “message”: “…”}. Shell hooks can also print the Claude Code shape {“decision”: “block”, “reason”: “…”} or exit with code 2. Any valid block wins over any approve.

Why is my Hermes shell hook not running?

Most often consent. Each (event, command) pair needs a first-use approval, and only a foreground hermes chat asks. The gateway, cron and CI skip unapproved hooks unless you pass –accept-hooks, set HERMES_ACCEPT_HOOKS=1, set hooks_auto_accept: true, or add an allowlist entry. Run hermes hooks doctor.

Can a Hermes hook change a tool's output?

Yes, but not from post_tool_call, whose return is ignored. Use transform_tool_result, which replaces any tool’s result string before the model reads it, or transform_terminal_output for the terminal tool only.

Where to read more

Running Hermes? One command adds a hook. Each tool call it sees is recorded, and a force-push, destructive SQL or curl | sh is blocked until you approve it on the console or from the email.

macOS · Linux · WSL
curl -fsSL https://agenticcontrolplane.com/install.sh | bash
Windows · PowerShell
irm https://agenticcontrolplane.com/install.ps1 | iex

What the installer writes  ·  Hermes guide  ·  free up to 5 agents

Share X HN LinkedIn
Get the next data drop
What agents actually cost, new tool-surface captures, and the occasional incident post-mortem — sent when we publish something worth your inbox, not on a schedule. Unsubscribe anytime.
Share: Twitter LinkedIn
Related posts

← back to blog