The Claude Code Hook System Explained
AI generated
Claude
>_
Claude Code · Hooks · Automation · Security Model
The Claude Code Hook System Explained
Deterministic control instead of a hopeful prompt instruction

Hooks in Claude Code are shell commands that run automatically at precisely defined events such as a tool call, regardless of whether the model's context happens to recall a CLAUDE.md rule at that moment or not. While a prompt instruction only raises a probability, a hook enforces behavior deterministically, at the operating system level, not at the language model level.

18 min read PreToolUse · PostToolUse · settings.json · Exit Codes Claude Code · Claude Sonnet 5 · Anthropic

1. What problem hooks solve

A hook in Claude Code is a shell command executed by the CLI itself, not by the language model, at a fixed point in the workflow. This solves a fundamental problem with prompt based instructions: a rule in CLAUDE.md such as "run the formatter after every change" is an instruction to a language model, which it follows with high but not guaranteed probability. A hook, by contrast, runs at the operating system level, regardless of whether the model happened to recall that rule at that moment or not.

The difference becomes especially visible with security relevant rules. A prompt note such as "never run rm -rf" lowers the risk but does not guarantee prevention, because a language model can, in rare cases, deviate from its own instructions. A hook that checks every bash call before execution and returns exit code 2 on a match against a dangerous pattern blocks the execution reliably, independent of model behavior. This shift from probability to determinism is the central value of the hook system.

2. Hook events at a glance

Claude Code provides several hook events at different points in the workflow. PreToolUse fires before a tool such as Bash, Edit, or Write is executed and can still prevent the execution. PostToolUse fires right after and is suited for follow up work like automatic formatting or linting. UserPromptSubmit fires as soon as a user submits input, before Claude processes it, and can inject additional context or block the input entirely.

Further events round out the picture: Stop fires when the main agent finishes its response, SubagentStop analogously for a subagent, Notification on system notifications, and SessionStart at the start of a new session, ideal for loading environment information once. Each of these hook events receives structured JSON data over stdin, for example the name of the invoked tool and its parameters for PreToolUse, and communicates its decision back to Claude Code through the exit code and optionally structured JSON output.


#!/usr/bin/env bash
# .claude/hooks/load-env-info.sh -- SessionStart hook, runs once per session
set -euo pipefail

echo "--- Environment info loaded at session start ---"
echo "PHP version: $(php -v | head -n1)"
echo "Git branch:  $(git branch --show-current 2>/dev/null || echo 'n/a')"
echo "Docker:      $(docker compose ps --status running --quiet 2>/dev/null | wc -l) containers running"

exit 0

3. Configuring hooks: settings.json and matchers

Hooks are configured in the settings.json file, either project wide under .claude/settings.json or personally under ~/.claude/settings.json. Each entry consists of an event name, an optional matcher pattern that captures only certain tools or file paths, and a list of commands to execute. The matcher uses simple patterns such as Bash, Edit|Write, or wildcard expressions to scope the hook to relevant cases instead of firing on every single tool call.

A common practice is separation by environment: project specific hooks, for instance a linter matching the concrete project, belong in .claude/settings.json and get versioned with the repository. Personal hooks, such as a desktop notification at session end, belong in the personal configuration. The example below shows a minimal settings.json with two hook entries for different events.


{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/check-dangerous-commands.sh" }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/format-changed-file.sh" }
        ]
      }
    ]
  }
}

4. A PreToolUse hook for security checks

The most practically important use case for PreToolUse hooks is blocking dangerous commands before they run. The hook receives a JSON object over stdin containing the tool's name and its input parameters, in the case of Bash the complete command string. A script can check this string against a list of critical patterns such as rm -rf /, DROP TABLE, or a write to an .env file, and on a match prevent execution with exit code 2.

Importantly, exit code 2 signals Claude Code that the tool call should not run, and the hook's stderr output is returned to the model as an error message so it can propose an alternative path. The example below shows a simple bash script that implements exactly this pattern for a security hook.


#!/usr/bin/env bash
# .claude/hooks/check-dangerous-commands.sh
# Reads PreToolUse hook input (JSON) from stdin, blocks dangerous bash commands
set -euo pipefail

input=$(cat)
command=$(echo "$input" | jq -r '.tool_input.command // empty')

dangerous_patterns=("rm -rf /" "DROP TABLE" "> /dev/sda" ":(){ :|:& };:")

for pattern in "${dangerous_patterns[@]}"; do
  if [[ "$command" == *"$pattern"* ]]; then
    echo "Blocked: command matches dangerous pattern '$pattern'" >&2
    exit 2   # exit code 2 blocks the tool call in Claude Code
  fi
done

exit 0   # allow the tool call to proceed

5. A PostToolUse hook for automatic formatting

PostToolUse hooks are well suited for follow up work that should run reliably after every file change, without Claude needing to "remember" it. A typical example is automatic formatting: after every Edit or Write call, the hook checks which file was changed and, depending on the extension, invokes the matching formatter, such as php-cs-fixer for PHP files or prettier for JavaScript and TypeScript.

The decisive advantage over a prompt instruction like "format after every change" is consistency: a hook is guaranteed to run on every matching file change, regardless of how long the conversation has already been or how much other context has already accumulated. Especially in long sessions with many tool calls, this reliability is a measurable difference compared to purely prompt based steering.


#!/usr/bin/env bash
# .claude/hooks/format-changed-file.sh
# Reads PostToolUse hook input (JSON) from stdin, formats the touched file
set -euo pipefail

input=$(cat)
file_path=$(echo "$input" | jq -r '.tool_input.file_path // empty')

[[ -z "$file_path" || ! -f "$file_path" ]] && exit 0

case "$file_path" in
  *.php)
    vendor/bin/php-cs-fixer fix "$file_path" --quiet
    ;;
  *.js|*.ts|*.tsx)
    npx prettier --write "$file_path" --loglevel silent
    ;;
esac

exit 0

6. Understanding hook input and exit codes

Every hook communicates with Claude Code through exactly two channels: the process's exit code and optionally structured JSON output on stdout. Exit code 0 means success, execution continues normally. Exit code 2 on PreToolUse blocks the tool call, the stderr message is passed to the model as context. Other exit codes are treated as a non blocking error, the user sees a warning but execution is not stopped.

For finer grained control, a hook can output a JSON object on stdout instead of a simple exit code, containing fields such as decision and reason. That allows differentiated responses, such as "allow, but with a warning to the model" instead of only "allow or block". This structure makes the hook system flexible enough for simple yes no decisions as well as more complex policy logic with several gradations.


# Manually testing a hook script with a synthetic JSON payload on stdin
echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf /"}}' \
  | .claude/hooks/check-dangerous-commands.sh
echo "Exit code: $?"   # expect 2, command blocked

echo '{"tool_name":"Bash","tool_input":{"command":"npm test"}}' \
  | .claude/hooks/check-dangerous-commands.sh
echo "Exit code: $?"   # expect 0, command allowed

7. Hooks versus CLAUDE.md: deterministic instead of a suggestion

The central conceptual difference between hooks and a rule in CLAUDE.md is the level at which the rule gets enforced. CLAUDE.md becomes part of the model's context and influences what the model is likely to do next, but it remains an instruction that the model can follow, not one it must follow. A hook runs outside the model, at the operating system level, and is guaranteed to run regardless of the current state of the conversation.

A clear rule of thumb follows: anything that must guaranteed happen, such as blocking destructive commands or enforced formatting before every commit, belongs in a hook. Anything that is a recommendation or contextual knowledge, such as "this project uses Repository instead of Model directly", belongs in CLAUDE.md. Many productive setups combine both: CLAUDE.md for knowledge and style guidance, hooks for hard security and quality boundaries that must never be skipped.

8. Security aspects and common pitfalls

Because hooks run with the full permissions of the current user, they carry real risk when written carelessly. A hook script that inserts unvalidated input from the hook input directly into a further shell command without checking can open a command injection vulnerability, exactly the problem the hook was supposed to prevent. Input from the JSON input must therefore always be treated as untrusted and properly quoted.

A second common pitfall is performance: a hook that starts a slow, full test run on every PostToolUse event noticeably slows down every single file change. A targeted, fast formatter is preferable to a full test run, while complete tests remain reserved for a separate Stop hook at the end of a response. A third pitfall: hooks without a timeout can block an entire session, an explicit timeout inside the script itself prevents a hanging process from freezing the whole interaction.

9. Hook events compared directly

Choosing the right hook event decides whether an automation acts preventively, reactively, or informatively. The overview below arranges the most important events by typical use case.

Event Timing Can block Typical use
PreToolUse Before the tool call Yes Security checks, forbidden commands
PostToolUse After the tool call No Formatting, linting, logging
UserPromptSubmit On user input Yes Injecting context, validating input
Stop After the response ends Partially Full test runs, summaries
SessionStart At session start No Loading environment info once

Using this table as a starting point avoids the most common configuration mistake: placing expensive, blocking logic in an event meant for fast, non blocking follow up work, or conversely placing a security check in an event that can no longer block anything at all.

Mironsoft

Claude Code setup, security models and Magento/Hyva development with AI

Binding rules instead of hopeful prompts?

We set up hooks for formatting, security checks, and test automation for your team, so critical rules are enforced deterministically instead of merely probably followed.

Security hooks

Reliably blocking dangerous commands before they execute

Format automation

Consistent formatting after every file change without manual intervention

CI integration

Mirroring hook logic in pipelines for consistent quality assurance

10. Summary

The Claude Code hook system solves a problem pure prompt instructions cannot: guaranteed, deterministic enforcement of rules instead of merely probable compliance. PreToolUse hooks block dangerous commands before execution, PostToolUse hooks enforce consistent follow up work such as formatting, and further events like UserPromptSubmit and SessionStart cover additional control points. All hooks are configured centrally in settings.json with event, matcher, and command.

The hook script's exit code decides success, blocking, or a non blocking error, structured JSON output enables finer grained decisions. It remains important to never insert hook input into further shell commands unchecked and to avoid placing expensive logic in performance critical events. Anyone who uses hooks for hard security and quality boundaries and CLAUDE.md for knowledge and style combines both strengths sensibly.

The Claude Code Hook System — The Essentials at a Glance

Core idea

Shell commands that run deterministically at defined events, independent of model behavior.

Key events

PreToolUse can block, PostToolUse for follow up work, UserPromptSubmit for context.

Configuration

Centrally in settings.json with event, matcher pattern, and command to execute.

Exit codes

0 allows, 2 blocks on PreToolUse, other codes are treated as a non blocking error.

11. FAQ: The Claude Code Hook System

1What is a hook?
A shell command the CLI itself executes at a defined event, independent of the model state.
2Difference from CLAUDE.md?
CLAUDE.md is an instruction to the model, a hook runs at the operating system level and always executes.
3What events exist?
PreToolUse, PostToolUse, UserPromptSubmit, Stop, SubagentStop, Notification, and SessionStart.
4Where do I configure hooks?
In settings.json, project wide under .claude/settings.json or personally under ~/.claude/settings.json.
5What does exit code 2 mean?
The tool call is blocked, stderr is returned to the model as an error message.
6Can PostToolUse undo something?
No, it runs afterward. PreToolUse handles blocking.
7What is the matcher?
A pattern like Bash or Edit|Write that determines which tools trigger the hook.
8What risk do bad hooks carry?
Unchecked hook input inserted into further shell commands can open a command injection vulnerability.
9Should tests run in PostToolUse?
Rather not, it slows down every file change. A Stop hook is better suited.
10What if a hook hangs without a timeout?
The session can get blocked. An explicit timeout in the script prevents this.