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.
Table of Contents
- 1. What problem hooks solve
- 2. Hook events at a glance
- 3. Configuring hooks: settings.json and matchers
- 4. A PreToolUse hook for security checks
- 5. A PostToolUse hook for automatic formatting
- 6. Understanding hook input and exit codes
- 7. Hooks versus CLAUDE.md: deterministic instead of a suggestion
- 8. Security aspects and common pitfalls
- 9. Hook events compared directly
- 10. Summary
- 11. FAQ
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.