TL;DR
Claude Code hooks are shell commands that run automatically around tool calls — before a command executes, after a file is written, when the agent waits for input. They live in settings.json, they can block tool calls outright, and they're the one mechanism in the whole stack that the agent cannot bypass. If you're running Claude Code on anything production-adjacent and you haven't configured hooks yet, that's the single biggest reliability upgrade available to you.
This guide walks through the three hook types, the settings.json schema, the exit-code contract, and the three patterns I ship on every repo I touch.
Why hooks exist
Prompts are suggestions. Hooks are contracts.
You can put "always run pnpm format after editing code" in CLAUDE.md and the agent will comply most of the time. Most of the time isn't good enough when the cost of a slip is a broken main branch, a leaked secret, or a PR that silently bypasses your lint rules. Hooks close that gap — they shift behavior from model compliance to system enforcement.
I've watched teams spend weeks debating CLAUDE.md wording to nudge the model toward their coding conventions. A ten-line hook does the same job deterministically.
The three hook types
PreToolUse — runs before a tool executes
Gets the tool name and arguments. Can approve, deny, or let the call through with a warning. The single most important hook in your setup, because this is where you block the actions you actually care about.
Typical uses:
- Block
rm -rfon anything outside a sandbox dir - Block
git push --forcetomain/master - Reject
Bashcalls that contain obvious secrets or credentials - Enforce that
Writenever touchesmigrations/orsrc/auth/without an explicit approval flag
PostToolUse — runs after a tool executes
Gets the tool name, arguments, and the result. Can't undo the call, but can format, validate, or log. Where "agent can't forget to format" actually lives.
Typical uses:
- Auto-run
prettier --write(orblack,gofmt,rustfmt) on everyWrite - Bump a changelog entry after a package.json edit
- Emit a structured log line for auditing
- Trigger a fast lint check and report errors back to the agent
Notification — runs when the agent needs input
Gets the prompt the agent is waiting on. Ideal for "desktop notification when Claude is idle" and similar low-stakes automation.
Typical uses:
- Ping macOS notification center so you come back to an agent that's been waiting two minutes
- Post to a Slack channel if the agent asks for human review
- Beep — yes, an actual terminal bell is still the fastest way to get your attention
settings.json schema
Hooks are configured in .claude/settings.json (project-scoped) or ~/.claude/settings.json (user-scoped). Project settings win.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/block-dangerous-bash.sh"
}
]
}
],
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/format.sh"
}
]
}
]
}
}
Two things worth knowing:
matcheris a regex on tool name."Bash"matches only Bash."Write|Edit"matches either.".*"matches everything — useful, occasionally dangerous.commandreceives the tool input as JSON on stdin. Your script parses it, decides what to do, exits.
The exit-code contract
This is the part that trips people up most often.
- Exit 0 — approve (for PreToolUse) or no-op (for PostToolUse). The tool call proceeds.
- Exit 2 — block. The tool call is denied. Your script's stderr is shown to the agent as the block reason. This is how you turn "please don't" into "system won't let you."
- Any other non-zero — error. The tool call fails, but as an error rather than a deliberate block. Reserve this for actual hook failures.
Write clear stderr messages on exit 2. The agent reads them and adapts. "Blocked: force-push to main is not allowed; open a PR instead" teaches the agent what to do next. "Error" teaches it nothing.
The three patterns I ship on every repo
Pattern 1: block destructive Bash
#!/usr/bin/env bash
# .claude/hooks/block-dangerous-bash.sh
set -euo pipefail
input=$(cat)
command=$(echo "$input" | jq -r '.tool_input.command // ""')
if echo "$command" | grep -qE '(rm -rf /|git push.*--force.*main|git push.*-f.*main)'; then
echo "Blocked: destructive command detected" >&2
exit 2
fi
exit 0
Covers 90% of the "agent wrecks the repo" failure mode with ten lines. Expand the regex over time as new footguns appear.
Pattern 2: auto-format after writes
#!/usr/bin/env bash
# .claude/hooks/format.sh
set -euo pipefail
input=$(cat)
file_path=$(echo "$input" | jq -r '.tool_input.file_path // ""')
case "$file_path" in
*.ts|*.tsx|*.js|*.jsx|*.json|*.md|*.mdx)
pnpm prettier --write "$file_path" >/dev/null 2>&1 || true
;;
*.py)
ruff format "$file_path" >/dev/null 2>&1 || true
;;
*.go)
gofmt -w "$file_path" >/dev/null 2>&1 || true
;;
esac
exit 0
Notice the || true — a formatter failure shouldn't block the agent, it should just skip. The next lint run will surface real issues.
Pattern 3: require explicit approval for sensitive paths
#!/usr/bin/env bash
# .claude/hooks/guard-sensitive-paths.sh
set -euo pipefail
input=$(cat)
file_path=$(echo "$input" | jq -r '.tool_input.file_path // ""')
case "$file_path" in
*migrations/*|*src/auth/*|*.env*)
if [ ! -f ".claude/allow-sensitive.flag" ]; then
echo "Blocked: $file_path is sensitive. Create .claude/allow-sensitive.flag for this session if you really want to edit it." >&2
exit 2
fi
;;
esac
exit 0
The flag-file pattern gives you an explicit "I know what I'm doing" gate. The agent can't create the flag on its own — you do. After the session, you delete it. Paranoid, cheap, works.
Debugging hooks
Three things to check when a hook "doesn't work":
- Is
settings.jsonvalid JSON? Claude Code silently ignores broken config. Runjq . .claude/settings.jsonas a sanity check. - Is the script executable?
chmod +x .claude/hooks/*.sh. Missed this one more times than I want to admit. - Is stdin being read? If your script hangs, it's probably waiting on stdin. Always
input=$(cat)at the top.
Log verbosely while you develop:
echo "hook invoked: $(date) $0" >> .claude/hooks.log
echo "$input" >> .claude/hooks.log
Then delete the logging once the hook works.
Where hooks fit in the bigger picture
Hooks are one of six building blocks I cover in my extended post on AI adoption. CLAUDE.md gives the agent context, skills give it reusable recipes, hooks enforce invariants. Together they're the difference between an agent that occasionally surprises you and one that behaves like a reliable teammate.
If your team is rolling out Claude Code and this is the part that feels fuzzy, drop me a line — I run on-site trainings that walk through exactly this. me@jakubkontra.com