With Hermes agent hooks, your own scripts run at set moments in every turn, so you can block a tool call, stop a prompt cold, inject context or log what the agent did.
The newest one landed on main on 1 Nov 2026: a pre_llm_call hook can now end a turn before Hermes sends a single inference request.
Below is the order I'd set hooks up in, with the exact config from the docs, the timeouts that decide whether a broken hook blocks or lets things through, and the traps.
If you want my team to map which guardrails your business actually needs, book a free SEO strategy session and we'll walk it with you.
The Four Hook Systems And Which One I Reach For First
The official Event Hooks page lists four separate systems.
| System | Where you declare it | Where it runs | Can it block? |
|---|---|---|---|
| Shell hooks | hooks: block in your profile config.yaml | CLI, gateway, Desktop, TUI, dashboard chat | Yes: tool calls and, now, whole turns |
| Plugin hooks | ctx.register_hook() in a plugin's register() | CLI and gateway | Yes: tool calls and whole turns |
| Gateway hooks | HOOK.yaml + handler.py in ~/.hermes/hooks/ | Gateway only | No |
| Outbound webhooks | hooks.outbound: list in config.yaml | CLI and gateway | No, notify-only |
I start with shell hooks.
They take any language with a shebang, they run as a separate subprocess, and you never touch Python plugin code.
Plugin hooks come in when you need state across calls, like counters or a memory lookup.
Step 1: Pick The Event Before You Write A Line
Shell hooks fire on the same event names as plugin hooks, and a typo gets a "Did you mean X?" warning and is skipped.
These are the events I use most:
pre_tool_call: fires before every tool runs; can block, rewrite the arguments or send the call to human approval.post_tool_call: fires after the tool returns; good for formatting files and logging.pre_llm_call: fires once per user turn before the tool loop; can inject context or block the turn.subagent_stop: fires when a child agent exits.on_session_startandon_session_end: lifecycle markers for logs and dashboards.
Write down which event you need and what it should return before you open an editor.
Observers ignore whatever you return, transforms swap in a replacement string, and directive hooks like pre_tool_call and pre_llm_call read documented return shapes.
The full catalog is much longer, covering streaming, approvals, compaction and Kanban events, and it's all on the docs page.
Step 2: Write The Shell Hook Entry In config.yaml
This is the full schema the docs give for a shell hook entry:
hooks:
<event_name>: # Must be in VALID_HOOKS
- matcher: "<regex>" # Optional; used for pre/post_tool_call only
command: "<shell command>" # Required; runs via shlex.split, shell=False
timeout: <seconds> # Optional; default 60, capped at 300
fail_closed: <bool> # Optional; default false. pre_tool_call only.
# `failClosed` also accepted (Cursor/Claude Code compat)
hooks_auto_accept: false # See "Consent model" below
Only command is required.
matcher is a regex and only applies to pre_tool_call and post_tool_call.
A timeout above 300 seconds is clamped with a warning.
Keep every script in ~/.hermes/agent-hooks/ so you can audit them in one place.
Step 3: Learn The JSON Wire Protocol
Each time the event fires, Hermes pipes a JSON payload to the script's stdin and reads stdout back as JSON.
{
"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": "..."}
}
tool_name and tool_input are null for non-tool events like pre_llm_call.
Everything event-specific, such as user_message or duration_ms, sits inside extra.
To block a tool call, print either shape on stdout:
// Block a pre_tool_call (both shapes accepted; normalised internally):
{"decision": "block", "reason": "Forbidden: rm -rf"} // Claude-Code style
{"action": "block", "message": "Forbidden: rm -rf"} // Hermes-canonical
Empty output or {} is a silent no-op.
Step 4: Block Destructive Terminal Commands
This is the docs example I copy first on any new machine.
hooks:
pre_tool_call:
- matcher: "terminal"
command: "~/.hermes/agent-hooks/block-rm-rf.sh"
timeout: 5
#!/usr/bin/env bash
# ~/.hermes/agent-hooks/block-rm-rf.sh
payload="$(cat -)"
cmd=$(echo "$payload" | jq -r '.tool_input.command // empty')
if echo "$cmd" | grep -qE 'rm[[:space:]]+-rf?[[:space:]]+/'; then
printf '{"decision": "block", "reason": "blocked: rm -rf / is not permitted"}\n'
else
printf '{}\n'
fi
The matcher means the script only runs for the terminal tool.
The five-second timeout keeps a slow script from holding up every command.
There's an even shorter version, borrowed from Claude Code and Cursor: exit with code 2 and the tool call is blocked.
#!/usr/bin/env bash
echo "policy violation: rm -rf is not permitted" >&2
exit 2
With exit 2, the block message comes from stdout JSON first, then the first 400 characters of stderr, then a generic default.
🔥 Want hook-ready guardrails without writing them from scratch? Inside the AI Profit Boardroom you get step-by-step video trainings, the installable Agent OS with Hermes wired in, four coaching calls a week and 3,400+ members sharing the guardrails they run. → Join the AI Profit Boardroom
Step 5: Make Security Gates Fail Closed
By default a shell hook fails open.
A spawn error, a timeout or junk on stdout logs a warning, and the tool call goes ahead.
That's fine for a logger and wrong for a secret scanner.
Flip it with fail_closed: true:
hooks:
pre_tool_call:
- matcher: "terminal|write_file|patch"
command: "~/.hermes/agent-hooks/secret-scan.sh"
timeout: 10
fail_closed: true
Now a missing script, a timeout, a non-zero exit with no directive, or a stack trace on stdout all block the call.
A clean exit with {} still lets it through.
The setting only works on pre_tool_call; anywhere else it logs a warning and is ignored.
Step 6: Approve Each Hook Once, Then Audit It
Every new (event, command) pair asks for approval the first time Hermes sees it.
The answer is saved to ~/.hermes/shell-hooks-allowlist.json.
Only a foreground hermes chat or hermes rl ever asks.
The gateway, cron and CI never prompt, so on a server you need one of three escape hatches:
- the
--accept-hooksflag, as inhermes --accept-hooks chat; - the
HERMES_ACCEPT_HOOKS=1environment variable; hooks_auto_accept: truein~/.hermes/config.yaml.
Or write the allowlist by hand, keyed on the exact command string:
{
"approvals": [
{
"event": "post_llm_call",
"command": "/home/hermes/.hermes/hooks/my-hook.py"
}
]
}
Then run the four audit commands:
hermes hooks listshows matcher, timeout and consent status.hermes hooks test <event>fires matching hooks against a synthetic payload.hermes hooks revoke <command>removes allowlist entries.hermes hooks doctorchecks exec bits, consent, mtime drift, JSON output and run time.
New On Main: pre_llm_call Can Now Stop The Whole Turn
Commit ad9229c843 was authored on 31 Oct and merged to main on 1 Nov 2026.
It lets any pre_llm_call callback, Python or shell, return {"action": "block", "message": "..."}.
The turn then makes zero model calls.
The message is shown in place of a model reply and saved as the assistant reply, so the transcript keeps a normal user-then-assistant pair and --resume still works.
The result carries turn_exit_reason: "blocked_by_plugin_pre_llm_call", which is what I'd grep for in logs.
The point, per the commit: data-loss and policy plugins no longer have to patch Hermes core to stop a prompt.
A shell hook takes either shape on stdout:
// Block the turn before any inference request (pre_llm_call); both shapes accepted:
{"action": "block", "message": "Blocked by policy: prompt contains a customer ID"}
{"decision": "block", "reason": "Blocked by policy: prompt contains a customer ID"}
It isn't in a tagged release yet; the latest on the releases page is v0.22.5 from 23 Oct, so you need a build from main or the next release.
What Hermes Stores After A Blocked Turn
A 1 Nov follow-up changed what the session keeps.
The blocked prompt is now stored only as [Message withheld: a pre_llm_call policy hook blocked it.].
That placeholder is what later turns replay, and it's what compaction summaries, session_search, external memory providers and the background memory review read.
So the prompt never reaches a model in that turn or any later one.
Before this fix, the typed text stayed in the row's content and the placeholder lived only in a sidecar that just the main-loop replay reads.
That meant the next compaction or search could still quote the vetoed prompt to a model, which is exactly what a privacy hook exists to stop.
What you typed survives only as display metadata, so you still see it in your own resume recap and session history screens.
The hook also runs before turn-start compaction now, and a blocked turn skips compaction, titling and external-memory prefetch and sync.
The Other 1 Nov Fixes: Compaction, /retry And /undo
Two more commits closed the gaps.
Commit c0bcfe2bb2 stops post-turn micro-compaction and the prepare-ahead summary from running after a block, because with micro_compact on, a turn reported as zero API calls was still making one auxiliary model call.
Commit 4061818385 makes /retry re-submit what you actually typed, so the hook decides on it again, and makes /undo restore your text instead of the placeholder.
That covers the CLI, TUI, gateway, ACP and Desktop.
Timeouts: The Two Clocks That Decide Fail-Open
This is the part people get wrong.
Python plugin callbacks on bounded hooks use plugins.hook_callback_timeout: 30 seconds by default, 0 disables it, 600 is the max.
A timed-out pre_tool_call plugin fails closed and blocks the tool.
A pre_llm_call callback that raises or runs past that timeout is logged and skipped, and the turn goes ahead.
Shell hooks ignore that setting and use their own per-entry timeout, 60 seconds by default.
And fail_closed only covers pre_tool_call, so a crashed shell pre_llm_call script never blocks a turn.
The docs' advice is blunt: a policy hook should catch its own errors and return a block when it can't decide.
📞 Not sure which prompts should never reach a model? Book a free SEO strategy session and my team will go through the data your agents touch. → Get your free strategy session
Plugin Hooks When A Shell Script Isn't Enough
Plugins register hooks in their register() function:
def register(ctx):
ctx.register_hook("pre_tool_call", my_tool_observer)
ctx.register_hook("post_tool_call", my_tool_logger)
ctx.register_hook("pre_llm_call", my_memory_callback)
ctx.register_hook("post_llm_call", my_sync_callback)
ctx.register_hook("on_session_start", my_init_callback)
ctx.register_hook("on_session_end", my_cleanup_callback)
# Kanban board lifecycle (each fires after its board write commits):
ctx.register_hook("kanban_task_claimed", my_claim_callback) # dispatcher process
ctx.register_hook("kanban_task_completed", my_done_callback) # worker process
ctx.register_hook("kanban_task_blocked", my_blocked_callback) # worker process
Callbacks receive keyword arguments, so always accept **kwargs.
A pre_tool_call plugin can block or escalate to approval:
return {"action": "block", "message": "Reason the tool call was blocked"}
# or
return {"action": "approve", "message": "Why approval is required", "rule_key": "optional:scope"}
Block always beats approve, whatever the registration order.
It can also rewrite arguments, and the returned dict is shallow-merged over the originals:
return {"action": "modify", "args": {"new_string": "fixed content"}}
Python plugins are registered before shell hooks, so a Python decision wins a tie.
If you're choosing which plugins to install alongside your hooks, start with the best Hermes Agent plugins.
Context Injection And Post-Write Formatting
Blocking is half the job.
The other half is feeding the model what it needs, like the repo's uncommitted changes on every turn:
hooks:
pre_llm_call:
- command: "~/.hermes/agent-hooks/inject-cwd-context.sh"
#!/usr/bin/env bash
# ~/.hermes/agent-hooks/inject-cwd-context.sh
cat - >/dev/null # discard stdin payload
if status=$(git status --porcelain 2>/dev/null) && [[ -n "$status" ]]; then
jq --null-input --arg s "$status" \
'{context: ("Uncommitted changes in cwd:\n" + $s)}'
else
printf '{}\n'
fi
Injected context always goes into the user message, never the system prompt, which keeps the prompt cache intact.
And this one formats Python files right after the agent writes them:
# ~/.hermes/config.yaml
hooks:
post_tool_call:
- matcher: "write_file|patch"
command: "~/.hermes/agent-hooks/auto-format.sh"
The reformat only changes the file on disk; the agent sees it on its next read_file.
Gotchas I'd Check Before Shipping A Hook
- Editing an approved script does not re-prompt, because the allowlist keys on the command string, not a hash; run
hermes hooks doctorto spot mtime drift. - Shell hooks run with your full user credentials, so only reference scripts you wrote or fully reviewed.
- A
pre_llm_callblock without a message is ignored. - A fresh session may still do a model metadata lookup ahead of the hook; the docs say no message content goes with it.
- Gateway hooks load anything placed in
~/.hermes/hooks/with no prompt and no sandbox. - Outbound webhooks can't block anything; they only report.
Get these right and you can hand the observer hooks reference to anyone else on the team without a walkthrough.
When you want the full Agent OS with these guardrails pre-wired, join the AI Profit Boardroom, and if you'd rather we plan it with you, grab a free strategy call with my team.
Also On Our Network
- 🌐 How a shop, a service firm and an agency use hooks to protect client data
- 🌐 A nine-criterion scorecard on control, safe defaults, consent and privacy
- 🌐 Five copy-paste prompts that set up hooks inside your Agent OS
- 🌐 Which AI model to pair with each hook setup, by real GoldieBench scores
- 🌐 The complete Hermes Agent Hooks guide on the AI Profit Boardroom blog
FAQ: Hermes Agent Hooks
Where do Hermes shell hooks live?
You declare them in the hooks: block of your profile config.yaml. By convention the scripts sit in ~/.hermes/agent-hooks/ so they are easy to audit.
How do I block a tool call with a shell hook?
Print {"action": "block", "message": "..."} or the Claude Code style {"decision": "block", "reason": "..."} on stdout from a pre_tool_call hook, or exit with code 2.
Can a pre_llm_call hook stop a turn?
Yes, on main since 1 Nov 2026. Return {"action": "block", "message": "..."} and the turn ends with zero model calls; the message becomes the reply. It is not in a tagged release yet.
Why did my shell hook not run on the gateway?
The gateway never shows the first-use prompt. Approve the hook in a foreground hermes chat, add it to shell-hooks-allowlist.json, or use --accept-hooks, HERMES_ACCEPT_HOOKS=1 or hooks_auto_accept.
What is the default shell hook timeout?
60 seconds per entry, capped at 300. Python plugin callbacks use plugins.hook_callback_timeout instead, which defaults to 30 seconds.
📺 Video notes + links to the tools 👉
🎥 Learn how I make these videos 👉
🆓 Get a FREE AI Course + Community + 1,000 AI Agents 👉
About Julian
I'm Julian Goldie, an SEO entrepreneur, author and founder of the AI Profit Boardroom, which has 3,400+ members.
I help business owners scale with AI agents, automation and SEO.
- I've built a 7-figure SEO and link building agency, Goldie Agency, with a team of 70+ SEO experts.
- I've grown a YouTube channel to 400,000+ subscribers.
- I wrote "Link Building Mastery".
- My Udemy courses have taught over 29,000 students.
→ Get my best AI training inside the AI Profit Boardroom
Add the rm -rf guard tonight, test it with hermes hooks test, and if you want my team to pick the policies that matter for your business, book a free strategy session and bring your Hermes agent hooks config with you.











