🤖HermesBlog
Hermes Feature Guides · Part 168/9/2026

Event Hooks: Make Hermes React to What Happens

Event Hooks — Auto-Trigger at Key Moments — easy-to-understand guide based on official docs (updated 2026-08-09)

Event hooks: doorbells for the agent

Event Hooks: Make Hermes React to What Happens

If you’ve ever wanted Hermes to ping you when a task finishes, log every command, or send a webhook to your dashboard — event hooks are exactly what you need. Think of them as little “if this happens, do that” triggers that live inside your agent.

In this post, we’ll walk through the four hook systems Hermes offers, starting with the most flexible one: Gateway Event Hooks.

The Four Hook Systems at a Glance

System Where it runs Best for
Gateway hooks Gateway only (Telegram, Discord, etc.) Logging, alerts, webhooks
Plugin hooks CLI + Gateway Tool interception, metrics, guardrails
Shell hooks CLI + Gateway + Desktop/TUI/dashboard chat Drop-in scripts for blocking, formatting, context injection
Outbound webhooks CLI + Gateway Pushing events to external HTTP endpoints

The great news? By default, if a hook fails, Hermes just logs the warning and keeps going. Your agent won’t crash because a hook had a hiccup. That “fail open” default covers malformed JSON, ordinary non-zero exit codes, and timeouts. The one exception is a pre_tool_call hook exiting with code 2, which blocks the tool call. And if you want stricter behavior, a fail_closed hook changes the rules — see the table below.

Hooks aren’t all passive, though: directive/control hooks can change flow, transforms can replace content, and a shell pre_tool_call hook can block or fail closed.


Gateway Event Hooks: Your First Hook in 5 Minutes

Gateway hooks fire automatically when your agent runs on messaging platforms like Telegram, Discord, Slack, WhatsApp, or Teams. They don’t block the main pipeline — they just observe and react.

How Gateway Hooks Get Loaded (Trust Model)

The hooks directory is a trusted-by-placement extension point — the documented contract since 3988c3c245f (April 2026), when the comparison table below first recorded its consent model as “Implicit (dir trust)”. There’s no enable list, and plugins.enabled / plugins.disabled don’t apply here — gateway hooks are not plugins. Here’s exactly what loads:

  • When: once, at gateway startup (HookRegistry.discover_and_load(), called from gateway/run_startup.py). Under multi-profile gateways, each served profile’s own hooks/ is loaded the first time an event fires inside that profile. The CLI, TUI, Desktop and cron never load gateway hooks.
  • What: every subdirectory of <profile home>/hooks/ (~/.hermes/hooks/ for the default profile) that contains both a HOOK.yaml parsing to a mapping with a non-empty events list and a handler.py. Directories missing either file are skipped silently; an invalid manifest or an empty events list is skipped with a [hooks] Skipping … log line.
  • How: handler.py is imported in-process — its module body runs at import, and its handle function is registered for the declared events. It runs as the gateway process with the same access as the gateway itself (loaded credentials, tools, plugin state). There is no sandbox, no first-use prompt, and HERMES_SAFE_MODE does not skip this loader.

Dropping the two files into the directory is the opt-in; removing (or renaming) HOOK.yaml or the directory is the opt-out. Anyone who can write into your profile home can already run code as you through config.yaml shell hooks or plugins.enabled, so the directory sits inside the same trust envelope as the rest of ~/.hermes/. Review a hook’s handler.py before you place it, exactly as you would a plugin before enabling it.

Step 1: Create the Hook Folder

Each hook is a directory under ~/.hermes/hooks/ with two files:

~/.hermes/hooks/
└── my-hook/
    ├── HOOK.yaml      # Tells Hermes which events to listen for
    └── handler.py     # Your Python code

Step 2: Declare Events in HOOK.yaml

name: my-hook
description: Log all agent activity to a file
events:
  - agent:start
  - agent:end
  - agent:step

The events list decides when your handler runs. You can subscribe to any combination — even wildcards like command:* to catch every slash command.

Step 3: Write Your Handler

import json
from datetime import datetime
from pathlib import Path

LOG_FILE = Path.home() / ".hermes" / "hooks" / "my-hook" / "activity.log"

async def handle(event_type: str, context: dict):
    """Called for each subscribed event. Must be named 'handle'."""
    entry = {
        "timestamp": datetime.now().isoformat(),
        "event": event_type,
        **context,
    }
    with open(LOG_FILE, "a") as f:
        f.write(json.dumps(entry) + "\n")

Handler rules to remember:

  • The function must be named handle
  • It receives event_type (string) and context (dict)
  • async def or regular def both work
  • Errors are caught and logged — never crash the agent

Key Events You’ll Want to Know

Here are the most useful events and what they give you:

Event When it fires What you get
gateway:startup Gateway starts List of active platforms
session:start New conversation begins Platform, user ID, session ID, session key
session:end Session ended (before reset) Platform, user ID, session key
session:reset User ran /new or /reset Platform, user ID, session key
session:compress Context compression completed for a session Platform, session ID, old session ID, in-place flag, compression count
agent:start Agent starts processing Platform, user ID, chat ID, thread ID, chat type, session ID, message (truncated to 500 chars)
agent:step Each tool-calling loop iteration Iteration number, tool names
agent:end Agent finishes Everything from agent:start plus the response
reaction:added An emoji reaction was added to a message the bot can see (Slack adapter currently) Platform, reaction, user ID, item user ID, item type, channel ID, message timestamp, team ID, event timestamp, raw event
reaction:removed An emoji reaction was removed from a message the bot can see Same shape as reaction:added
command:* Any slash command Command name and arguments

Pro tip: Use wildcards! A handler registered for command:* fires for command:model, command:reset, and all the rest — one subscription to monitor everything.


Directive Hooks: Consuming a Message Before the Agent Runs

Two gateway hooks can change flow instead of just observing it.

pre_gateway_dispatch fires on an incoming non-internal message before auth, pairing and dispatch. The first valid skip, rewrite, or allow result controls the flow. It is extremely privileged: the payload hands your callback the event, the gateway, and the live session_store, so treat it as in-process code with access to inbound user and routing data.

post_gateway_admission fires once per admitted, non-internal message, after authorization, bot admission, pause/drain, pending-reply intercepts (clarify, confirmations), the running-session lane (steering, busy commands) and idle slash-command dispatch. Rejected, ignored, internal and control traffic never reaches it. It runs inside the claimed session slot, so concurrent messages for the same chat queue behind it, and under the message’s routed profile scope: only that profile’s plugins run.

The payload is session_key, platform, source (a SessionSource.to_dict() snapshot), message_id, and text. Inbound text is untrusted user data, and no runner or session-store handles are passed.

Return {"action": "handled", "reply": "..."} to consume the message: the agent turn is skipped and reply (when a non-empty string) is delivered through the normal route. Omit reply to consume silently. Any other return value runs the ordinary agent turn.

def consume(session_key, platform, source, message_id, text, **kwargs):
    if not text.startswith("follow up:"):
        return None                       # ordinary agent turn
    enqueue_follow_up(source, text)       # commit your own durable state first
    return {"action": "handled", "reply": "Got it - queued."}

def register(ctx):
    ctx.register_hook("post_gateway_admission", consume)

Fail-open. A callback that raises, times out (plugins.hook_callback_timeout) or returns something else is logged and the message proceeds to the agent, so one buggy plugin cannot block a profile’s traffic. A plugin that needs fail-closed behaviour catches its own errors and returns handled with its own failure reply. Do not send through adapters directly — return reply instead. Dedupe and durable acceptance are the plugin’s responsibility.


Watching the Human in the Loop

Two observer hooks cover every moment Hermes blocks on a person, so a notifier (“Hermes is waiting on you”) doesn’t have to subscribe to a separate hook per prompt type or pattern-match tool arguments.

on_human_input_request fires right before the agent blocks waiting for someone to type or click something, whatever the prompt is and wherever it shows up. Both hooks return nothing — the return value is ignored.

kind Prompt Surfaces
"sudo" Masked sudo password prompt before a sudo command runs Interactive CLI, Ink TUI / Desktop (sudo.request card), /dev/tty fallback
"clarify" A clarify tool question CLI, Ink TUI / Desktop, gateway platforms
"approval" Dangerous-command / write approval, MCP elicitation consent, plugin approval transports CLI, Ink TUI / Desktop, ACP, gateway platforms, ctx.register_approval_transport plugins

Alongside kind you get request_id, session_id, session_key, platform, and prompt. The prompt is always force-redacted, and the typed password or answer is never passed.

on_human_input_resolved fires exactly once per request, when the wait ends — answered, skipped, timed out, withdrawn, or failed. It carries the same fields plus outcome. You get the outcome only, never the answer itself.

Two caveats: approvals.mode=smart decisions made by the auxiliary LLM do not fire these hooks, because no person is asked; and gateway approvals that join an identical prompt already pending fire the pair once, since the person sees one prompt. New kinds can be added later, so ignore kinds you don’t handle.


When Hooks Fail: Fail Open vs. Fail Closed

By default, hooks fail open. Malformed JSON, ordinary non-zero exit codes, and timeouts all just log a warning and let the agent loop continue. The only built-in exception is a pre_tool_call hook exiting with code 2, which blocks the tool call.

If you want a hook to stop the agent instead of just warning, mark it fail_closed. Here’s how the two behaviors compare:

Situation Default (fail open) fail_closed hook
Non-zero exit with no recognized directive warn, proceed block

So a fail_closed hook turns a plain non-zero exit into a hard stop — useful for guardrails where “something went wrong” should never be silently ignored.


Showing the User Something When a Session Ends

on_session_finalize fires after every turn, so despite its name it can’t show the user anything by itself. To show a message when a session actually ends, return a non-empty str, or a dict with a "message" string, and Hermes shows it to the user as a notice. It never becomes a model turn and never edits the assistant’s reply. Return None to show nothing.

Surface Shown as
CLI /new Printed in the terminal before the new session starts.
CLI quit Printed above the exit summary (hermes --resume …).
hermes chat -q / -z one-shot Printed to stderr, so stdout still holds only the answer.
TUI /new A system line in the fresh session’s transcript.
Messaging gateway /new or /reset Sent to the chat that owned the session, as its own message.
Gateway shutdown / restart Sent to each active chat before the adapters disconnect.
def digest(session_id, **kwargs):
    count = _edits.pop(session_id, 0)
    return {"message": f"📝 This session edited {count} file(s)."}

def register(ctx):
    ctx.register_hook("on_session_finalize", digest)

It is not delivered when nobody is there to see it: idle-expiry and cache eviction in the gateway, quitting the TUI, TUI/Desktop sessions closed by the reaper or a disconnect, and Desktop new-chat or chat delete. It is also not delivered from cron jobs, which don’t finalize sessions. In those cases the message is dropped and the hook still runs.


Real-World Example: Alert Yourself on Long Tasks

Want a Telegram message when your agent takes more than 10 steps? Here’s the whole hook:

# ~/.hermes/hooks/long-task-alert/HOOK.yaml
name: long-task-alert
description: Alert when agent is taking many steps
events:
  - agent:step
# ~/.hermes/hooks/long-task-alert/handler.py
import os
import httpx

THRESHOLD = 10
BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN")
CHAT_ID = os.getenv("TELEGRAM_HOME_CHANNEL")

async def handle(event_type: str, context: dict):
    iteration = context.get("iteration", 0)
    if iteration == THRESHOLD and BOT_TOKEN and CHAT_ID:
        tools = ", ".join(context.get("tool_names", []))
        text = f"⚠️ Agent has been running for {iteration} steps. Last tools: {tools}"
        async with httpx.AsyncClient() as client:
            await client.post(
                f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage",
                json={"chat_id": CHAT_ID, "text": text},
            )

That’s it. Save the files, restart your gateway, and you’ll get pinged when things run long.


One More Tip: Threaded Replies

If you’re posting follow-up messages into a Telegram forum topic, include message_thread_id=int(thread_id) when chat_type == "forum" and thread_id is non-empty. Otherwise your message might land in the wrong place.


What’s Next?

Gateway hooks are just one of the four systems. In upcoming posts, we’ll dig into plugin hooks (for tool interception), shell hooks (for drop-in scripts), and outbound webhooks (for pushing events to CI, dashboards, or other agents).

For now, try building a simple logger or alert hook. It’s the fastest way to see how much visibility hooks give you into your agent’s inner workings.

📖 Official Docs

This article is based on the official Hermes Agent documentation:Official docs › user-guide/features/hooks