# Hermes Agent hooks: shell, plugin and gateway reference

Hermes Agent hooks: where they live, pre_tool_call block/approve/modify, shell hook config, consent, headless runs, and what a hook can see.

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](https://github.com/NousResearch/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](https://github.com/NousResearch/hermes-agent/blob/main/website/docs/user-guide/features/hooks.md) and source unless linked otherwise. For approvals, the blocklist, the file-write guard and yolo, see [the Hermes control model](/controls/hermes).

## 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:

```python
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:

```json
{"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:

```yaml
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:

```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": "..."}
}
```

Plugin hooks live in a plugin directory:

```text
~/.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:

```yaml
hooks:
  pre_tool_call:
    - matcher: "terminal"
      command: "~/.hermes/agent-hooks/block-rm-rf.sh"
      timeout: 5
      fail_closed: true
```

```bash
#!/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**:

```python
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:

```python
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:

```yaml
hooks:
  post_tool_call:
    - command: "~/.hermes/agent-hooks/log.sh"
```

```bash
#!/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:

```python
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:

```bash
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:

```json
{"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](https://github.com/NousResearch/hermes-agent/blob/main/website/docs/user-guide/security.md), `approvals.cron_mode`, `single_query_mode` (`hermes chat -q`) and `unattended_mode` all default to `deny`.

## Troubleshooting

```bash
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](/integrations/hermes).
- **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](/blog/hermes-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](/integrations/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



## Where to read more

- [Hermes Event Hooks docs](https://github.com/NousResearch/hermes-agent/blob/main/website/docs/user-guide/features/hooks.md)
- [Hermes Plugins guide](https://github.com/NousResearch/hermes-agent/blob/main/website/docs/user-guide/features/plugins.md)
- [Hermes security docs](https://github.com/NousResearch/hermes-agent/blob/main/website/docs/user-guide/security.md)
- [Shell hook source: agent/shell_hooks.py](https://github.com/NousResearch/hermes-agent/blob/main/agent/shell_hooks.py)
- [The Hermes control model](/controls/hermes) and [Hermes install guide](/integrations/hermes)
