SSD Nodes Learn 🎉 VPS from $5.50/mo
Guides Matt ConnorBy Matt Connor

Claude Code Hooks Explained

Claude Code hooks run whether the model agrees or not. Where they live, which events fire, what exit code 2 does to a tool call, and the security cost.

What a Claude Code hook is

Claude Code hooks are shell commands that Claude Code runs by itself at fixed points in its own lifecycle. That is the whole difference between a hook and a rules file. An instruction in CLAUDE.md is advice, and the model weighs it against everything else in its context. A hook is code, and it runs whether the model agrees or not. If your agent keeps skipping the formatter you told it about twice, you do not need a firmer instruction. You need a hook.

The mechanism is small. You register a command in a settings file under an event name. When that event fires, Claude Code runs your command and writes the event data to its standard input (stdin) as JSON (JavaScript object notation). Your command reads that data, does its work, and answers with an exit status. Exit 2 from a PreToolUse hook cancels the tool call before it runs, and whatever your script wrote to standard error (stderr) is handed back to the model as the reason.

Event names and field names here come from the Claude Code hooks reference, checked in August 2026 against release 2.1.232. This surface moves quickly, so check the reference for your own version before you copy JSON out of any blog post, including this one. Print yours with claude --version.

Where hook configuration lives

A hook is a JSON block in a settings file. Six locations can hold one, and the scope of the file is the scope of the hook.

  • ~/.claude/settings.json: every project on your machine, and nobody else's.
  • .claude/settings.json: one project, committed to the repository, so everyone who clones it gets the hook.
  • .claude/settings.local.json: one project, your machine only.
  • Managed policy settings: organisation wide, set by an administrator.
  • hooks/hooks.json inside a plugin, live while that plugin is enabled.
  • Skill or subagent frontmatter, live while that component is active.

Hook entries from these files merge instead of overriding each other. A project settings file adds its hooks to the ones in your user settings rather than replacing them, so one event can hold several hooks from several files. Setting "disableAllHooks": true switches them off, with one exception: hooks from managed policy settings keep running unless that setting is applied in managed settings too.

Run /hooks inside a session to list every hook currently registered, grouped by event, with the source file and matcher for each. The menu is read only, so you change a hook by editing the settings file. The file watcher usually picks the edit up without a restart.

Which Claude Code hook events exist

Release 2.1.232 lists thirty one events, from SessionStart through to SessionEnd, covering compaction, subagents, worktrees and configuration files. Server work uses a handful of them.

  • PreToolUse: before a tool call executes. This is the one that can block.
  • PostToolUse: after a tool call succeeds. PostToolUseFailure fires when it fails instead, so a hook that must see every outcome needs both.
  • PermissionRequest: when a tool call needs a permission decision, which is the moment the approval prompt would appear.
  • UserPromptSubmit: when you submit a prompt, before Claude processes it. Whatever this hook prints to stdout is added to the model's context.
  • SessionStart and SessionEnd: at each end of a session. SessionStart also fires after compaction, under the matcher value compact.
  • Stop: when Claude finishes responding. That is once per turn, not once per finished task.

Every group carries a matcher that decides which occurrences run the hook. On the tool events it filters by tool name, so "Edit|Write" fires on file edits and on nothing else. Matchers are case sensitive. An empty matcher fires on every occurrence. Tools from an MCP (model context protocol) server are named mcp__<server>__<tool>, so a matcher of "mcp__github__.*" catches one server's tools and leaves the others alone.

Stop hooks carry a trap worth knowing before you write one. A Stop hook that blocks sends the model back to work, and Claude Code overrides the hook after eight consecutive blocks. Read the stop_hook_active field from the hook input and exit 0 when it is true, or your hook will loop until it hits that cap.

What a hook receives on stdin

When Claude is about to run npm test, a PreToolUse hook on Bash reads this on stdin:

{
  "session_id": "abc123",
  "cwd": "/home/deploy/myproject",
  "hook_event_name": "PreToolUse",
  "tool_name": "Bash",
  "tool_input": {
    "command": "npm test"
  }
}

Every event carries session_id, cwd, permission_mode, transcript_path and hook_event_name. The tool events add tool_name, tool_input and tool_use_id. Other events carry their own fields: UserPromptSubmit gets the prompt text, and SessionStart gets a source of startup, resume, clear, compact or fork.

jq is the usual way to read this inside a shell script, and a minimal server image does not have it. Install it first with sudo apt install -y jq on Ubuntu and Debian.

What the exit status does to the tool call in flight

There are three outcomes.

  • Exit 0 means your hook raises no objection. On PreToolUse that is not the same as approval, and the normal permission flow still runs. On UserPromptSubmit and SessionStart, stdout is added to the model's context.
  • Exit 2 blocks the action on the events that can be blocked, PreToolUse among them, and stderr becomes the reason the model is shown. On events that cannot be blocked, such as PostToolUse, the block is ignored, though stderr still reaches the model as feedback.
  • Any other exit code is a non-blocking error. The action proceeds. The transcript shows a hook error notice carrying the first line of stderr after the text Failed with non-blocking status code:.

For anything beyond block or stay quiet, exit 0 and print a JSON object to stdout instead. A PreToolUse hook decides with permissionDecision:

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Database drops go through a migration, not through the agent."
  }
}

"allow" skips the interactive prompt, "deny" cancels the call and sends the reason to the model, and "ask" shows the prompt as normal. Pick one style per hook. Mixing exit 2 with a JSON decision on stdout gives you a result you have to look up.

When several hooks match one event they run in parallel and every one of them runs to completion. A deny from one hook does not stop its siblings, so a logging hook still writes its line while a guardrail hook denies the same call. Claude Code then merges the answers and keeps the most restrictive, in the order deny, defer, ask, allow.

Example 1: block a destructive command before it runs

Save this as .claude/hooks/block-destructive.sh in your project:

#!/bin/bash
# Deny a Bash tool call whose command matches a banned pattern.
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')

for pattern in 'rm -rf /' 'mkfs' 'dd if=' 'DROP TABLE'; do
  if printf '%s' "$COMMAND" | grep -qiF -- "$pattern"; then
    echo "Blocked by policy: the command matches '$pattern'. A human runs this one." >&2
    exit 2
  fi
done

exit 0

Make it executable, then register it on PreToolUse in .claude/settings.json:

chmod +x .claude/hooks/block-destructive.sh
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/block-destructive.sh",
            "timeout": 10,
            "statusMessage": "Checking the command against policy"
          }
        ]
      }
    ]
  }
}

Test the script by hand before you trust it, because a hook that crashes on its own input fails open:

echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf /var/lib/postgresql"}}' \
  | .claude/hooks/block-destructive.sh
echo $?

You should see the Blocked by policy: line on stderr and an exit code of 2. Feed it a harmless command such as ls -la and you should see no output and an exit code of 0. In a session, the denied call appears in the transcript with your message as the reason, and the model reads that message and adapts.

One property makes this worth doing: PreToolUse hooks fire before the permission-mode check, in every permission mode, so a deny holds even under bypassPermissions. That is what makes a hook useful alongside Claude Code auto mode and its permission settings, where the prompts are turned down but the hook still fires.

Be honest about what this is. Pattern matching on a command string is a guardrail against an agent being careless, and it is not a boundary against an agent being clever, because the same command can be written in a form your grep never sees. Hard rules belong in the permission system and in the account the process runs under.

Example 2: format and lint after every edit

PostToolUse with an Edit|Write matcher runs after any file-editing tool. Save this as .claude/hooks/after-edit.sh:

#!/bin/bash
# Format the edited file, then report lint failures back to the model.
INPUT=$(cat)
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
[ -z "$FILE" ] && exit 0

case "$FILE" in
  *.py)
    ruff format "$FILE" >/dev/null 2>&1
    if ! ruff check "$FILE" >&2; then
      exit 2
    fi
    ;;
  *.sh)
    if ! shellcheck "$FILE" >&2; then
      exit 2
    fi
    ;;
esac

exit 0
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/after-edit.sh",
            "timeout": 60
          }
        ]
      }
    ]
  }
}

Ask Claude to add a badly indented function to a Python file, then open the file. It comes back formatted. That is your check that the hook ran, because a successful hook shows nothing in the conversation.

The exit 2 here does not undo anything. PostToolUse fires after the tool has already executed, so the edit is on disk either way. What exit 2 buys you is that the ruff check output reaches the model as feedback, so it fixes the error it just introduced instead of moving on. That is the difference between a lint failure you find at commit time and one the agent repairs in the same turn.

Two matcher limits matter here. Edit|Write does not see files changed by a shell command, and Claude writes files through Bash often enough for that gap to be real. For per-call coverage, match Bash as well and have the script list changed files with git status --porcelain. For once-per-turn coverage, put the scan in a Stop hook instead.

Example 3: log every tool call for audit

An empty matcher on PostToolUse fires on every tool. Sending the record to the system journal rather than to a file in the home directory keeps it out of reach of the agent's own shell:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "jq -c '{time: now|todate, session: .session_id, cwd: .cwd, tool: .tool_name, input: .tool_input}' | logger -t claude-code -p local0.info"
          }
        ]
      }
    ]
  }
}

Read it back with journalctl -t claude-code -o cat | tail -n 5. You should see one JSON line per tool call, newest last. Nothing appearing means the hook did not run, and the troubleshooting section below covers that.

Add the same block under PostToolUseFailure to capture calls that failed, because PostToolUse only fires on success and a failed command is usually the interesting one. The reason for logger rather than an append to a file in your home directory is ownership: a hook runs as the same user as the agent's shell, so anything that user can append to, that user can also truncate. The journal is written by systemd-journald under its own account.

How long a hook may run

ChartDefault hook timeout in seconds, by hook type and event
The data behind this chart
[
  {
    "label": "command, http or mcp_tool hook",
    "default_timeout_seconds": 600
  },
  {
    "label": "agent hook",
    "default_timeout_seconds": 60
  },
  {
    "label": "prompt hook",
    "default_timeout_seconds": 30
  },
  {
    "label": "command hook on UserPromptSubmit",
    "default_timeout_seconds": 30
  },
  {
    "label": "command hook on MessageDisplay",
    "default_timeout_seconds": 10
  },
  {
    "label": "any hook on SessionEnd",
    "default_timeout_seconds": 1.5
  }
]

A command hook gets 600 seconds by default, which is ten minutes. Some events cut that down hard. SessionEnd hooks share a budget of 1.5 seconds between all of them, so end-of-session cleanup has to be quick, though setting a longer timeout on the hook raises that shared budget to match, up to 60 seconds.

A hook that hits its timeout is cancelled and renders no decision. For a PreToolUse guardrail that means it does not block: the tool call carries on into the normal permission flow. Keep guardrail scripts small for that reason. For slow work nobody is waiting on, such as shipping a log somewhere, set "async": true and the hook runs in the background without holding up the tool call.

Hooks, rules files, skills and MCP servers

Four things get confused with each other because they all change what an agent does. Only one of them stops being a suggestion.

A rules file (CLAUDE.md, or a file under .claude/rules/) is text loaded into the model's context. It shapes behaviour and it enforces nothing. Set against a long conversation, a large diff and a fresh user request, one line of it can lose. That is the ordinary mechanism behind agents ignoring the instructions you wrote down.

A skill is a folder of instructions and scripts that the model loads when it judges the skill relevant. That judgement is the point of a skill, and it is also the limit: the model still decides.

An MCP (model context protocol) server gives the model new tools to call. It widens what the agent can reach. It does not make the agent reach for anything, and it is a separate process you have to operate, which is a job of its own: see running MCP servers on a VPS.

A hook is the only one of the four that runs without the model choosing it. Use a rules file for a preference and a skill for a procedure the model should follow when it applies. Use a hook for the step that must happen every time, or for the thing that must never happen. The deeper comparison, including when a skill beats a rules file, is in the skills, MCP and rules files comparison.

A plugin is packaging rather than a fifth mechanism. It bundles hooks together with skills into one installable unit, which is how a team ships the same guardrail to every machine: see how Claude Code plugins work.

The security decision on a shared VPS

A hook is code the agent triggers, and it runs as the user who started Claude Code. It inherits that user's environment and file permissions. On a laptop that is a workflow question. On a VPS where an agent runs unattended, it is a security question with four practical parts.

A hook in a repository is code you did not write. .claude/settings.json is committed, so cloning a repository and starting a session inside it can register hooks that came with the repository. Claude Code gates project hooks behind the workspace trust dialog for that folder, which means accepting trust is the moment you decide to run them. Read the hooks block first.

A hook sees the full tool input. An audit hook that logs tool_input writes every argument of every command into a file, including any token that happened to sit on a command line. That log then needs the same protection as the secret does, which is part of the wider problem of keeping secrets out of an AI agent's reach.

A hook can write into the model's context. Whatever a SessionStart or UserPromptSubmit hook prints to stdout is added to the conversation. A hook that pipes in text from outside, an issue tracker or a log file, is handing untrusted text to the model as though you had typed it yourself. Treat that stdout as input rather than as output.

Privilege is the real control. Run the agent as a dedicated unprivileged user with only the sudo rules it needs. A PreToolUse deny is worth having, and it is best effort by design: the reference says the same about the if filter and tells you to use the permission system when you need a hard deny. The permission rules and the account the process runs under are the parts that hold under pressure.

One property does hold in every configuration. PreToolUse hooks fire before the permission-mode check in every permission mode, so a hook returning deny blocks the tool even under bypassPermissions. Hooks can tighten what the permission rules allow. They cannot loosen it.

Why is my hook not firing?

Work through this in order. Each step names the symptom you will actually see.

  • Run /hooks and check the hook appears under the event you expected. A hook missing from the menu usually means the settings file has a JSON syntax error, since trailing commas and comments are not allowed, or the file is not in one of the six locations above.
  • Compare the matcher against the tool name exactly. Matchers are case sensitive, so "bash" never matches the Bash tool.
  • Run the script by hand with sample input, as in example 1 above. An exit code you did not expect is a bug in your script, and Claude Code reports it as a hook error rather than as a decision.
  • A notice reading jq: command not found means jq is missing on that machine. A command not found for your own script means the path did not resolve, so use ${CLAUDE_PROJECT_DIR} or an absolute path. If the script never runs at all, it is probably not executable.
  • The hook prints valid JSON and nothing happens. A shell-form hook runs through sh -c, and if your shell profile prints a banner, that banner is prepended to your JSON. Stdout no longer starts with {, so Claude Code reads the whole thing as plain text and ignores the decision. On exit 0 nothing is reported anywhere except the debug log. Wrap any echo in your profile so it only runs in interactive shells.
  • Still stuck: start the session with claude --debug-file /tmp/claude.log and run tail -f /tmp/claude.log in a second terminal. The debug log records which hooks matched, what exit code each one returned, and everything they wrote to stdout and stderr.

FAQ

What is the difference between a Claude Code hook and a CLAUDE.md instruction?

A CLAUDE.md instruction is text in the model's context, so it competes for attention with the conversation and with the current request, and the model can weigh it against them. A hook is a shell command that Claude Code runs at a fixed point in its lifecycle, so it executes on every occurrence of its event regardless of what the model decided. Use an instruction for a preference. Use a hook for a step that must always happen or an action that must never happen.

How do I stop Claude Code from running a specific shell command?

Register a PreToolUse hook with a Bash matcher that reads the command from .tool_input.command, writes a reason to stderr and exits 2. Claude Code cancels the call and shows the model your reason, and this happens before the permission-mode check, so the deny holds even in bypassPermissions mode. Pattern matching on a command string is a guardrail rather than a security boundary, because the same command can be written in a form the pattern misses, so back it with permission rules and with an unprivileged account.

My hook prints valid JSON but nothing happens. Why?

The most common cause is your shell profile. A hook without an args field runs through sh -c, and some profiles print a banner on every shell, which lands on stdout ahead of your JSON. Because the output no longer starts with {, Claude Code treats all of it as plain text and ignores the decision, and on exit 0 nothing is reported in the transcript at all. Guard any echo in your profile with an interactive-shell test, then confirm the fix by reading the debug log from claude --debug-file /tmp/claude.log.

Is it safe to run Claude Code hooks on a shared server?

Hooks run as the user who started Claude Code, with that user's file permissions, so a hook can do whatever that account can do. Two habits cover most of the risk: run the agent as a dedicated unprivileged account with a narrow sudo policy, and read the hooks block of any repository before you accept its workspace trust dialog, because project hooks ship inside .claude/settings.json. Set "disableAllHooks": true in your settings file when you want none of them to run.