Why they exist
Memory, rules, skills and output styles are all requests. The model reads them and generally complies, but compliance is probabilistic. Hooks are deterministic: shell commands that fire on defined events, outside the model, and can block an action outright.
If a rule must hold 100% of the time — never commit to main, never edit generated files, always run the formatter — it belongs in a hook.
flowchart LR
A["Claude decides<br/>to run a tool"] --> B["PreToolUse hook fires"]
B --> C{"Exit code"}
C -->|"0"| D["Tool executes"]
C -->|"non-zero"| E["Tool <b>blocked</b><br/>stderr fed back to Claude"]
D --> F["PostToolUse hook fires"]
F --> G["Result returns to Claude"]
E --> G
Exit 0 lets the call through. A non-zero exit blocks it; exit code 2 is the
one that feeds your stderr back to Claude as an explanation, which is almost
always what you want, because a blocked call with no message just looks broken.
Common events
| Event | Fires | Typical use |
|---|---|---|
PreToolUse |
Before a tool call; can block | Reject writes to protected paths, forbid git push --force |
PostToolUse |
After a tool call | Run the formatter, run the linter, regenerate mocks |
SessionStart |
New session | Inject dynamic context, warm caches |
UserPromptSubmit |
Each prompt | Attach ticket context, enforce prompt policy |
Stop |
Claude finishes a turn | Run tests, report status |
Where they are defined
- Inline in
settings.jsonunder ahookskey - In a plugin’s
hooks/hooks.json
The shape is the same in both places: an event name, a matcher regex against
the tool name, and a list of commands.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/format.sh"
}
]
}
]
}
}
The hook receives a JSON payload on stdin describing the tool call, so it can decide based on the actual file path rather than firing blindly.
Worked examples
Format whatever was just written
The single highest-value hook. It removes an entire category of “please run gofmt” from your prompts, and it is more reliable than asking.
#!/usr/bin/env bash
# .claude/hooks/format.sh
set -euo pipefail
path=$(jq -r '.tool_input.file_path // empty')
[[ -z "$path" || ! -f "$path" ]] && exit 0
case "$path" in
*.go) gofmt -w "$path" ;;
*.ts|*.tsx) npx --no-install prettier --write "$path" ;;
*.py) ruff format "$path" ;;
*.tf) terraform fmt "$path" ;;
*.java) ./gradlew -q spotlessApply -PspotlessFiles="$path" ;;
esac
exit 0
Note the exit 0 at the end and the early exit on a missing path. A formatter
hook that fails loudly on an unrelated file type turns every edit into a blocked
call.
Protect generated code and migrations
#!/usr/bin/env bash
# .claude/hooks/guard-paths.sh
set -euo pipefail
path=$(jq -r '.tool_input.file_path // empty')
case "$path" in
*/generated/*|*.pb.go|*_gen.go)
echo "Refusing: $path is generated. Edit the source schema in proto/ and run 'make generate'." >&2
exit 2
;;
*/migrations/*)
if [[ -f "$path" ]]; then
echo "Refusing: $path is an applied migration. Migrations are forward-only; add a new one." >&2
exit 2
fi
;;
esac
exit 0
The migration guard is the interesting one: it blocks edits to existing migrations but allows new ones, which is a rule prose cannot express reliably because it depends on the filesystem.
Wire both up:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit",
"hooks": [{ "type": "command", "command": ".claude/hooks/guard-paths.sh" }]
}
],
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [{ "type": "command", "command": ".claude/hooks/format.sh" }]
}
]
}
}
Refuse to work on the default branch
#!/usr/bin/env bash
# .claude/hooks/branch-guard.sh
branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo detached)
if [[ "$branch" == "main" || "$branch" == "master" ]]; then
echo "On $branch. Create a feature branch before editing." >&2
exit 2
fi
exit 0
permissions.deny can stop git commit, but it cannot express “only when the
branch is main”. Anything conditional on runtime state is a hook.
Inject the current ticket at session start
#!/usr/bin/env bash
# .claude/hooks/session-context.sh
branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown)
echo "Branch: $branch"
if [[ "$branch" =~ ([A-Z]+-[0-9]+) ]]; then
gh issue view "${BASH_REMATCH[1]}" --json title,body -q '"Ticket: \(.title)\n\n\(.body)"' 2>/dev/null || true
fi
SessionStart output is added to the context, so the session opens already
knowing which ticket the branch belongs to.
Run the fast tests when Claude stops
{
"hooks": {
"Stop": [
{
"hooks": [{ "type": "command", "command": "make test-unit 2>&1 | tail -30" }]
}
]
}
}
Keep this to the fast suite. A Stop hook that takes four minutes makes every
turn feel broken, and you will disable it within a day.
Everybody runs
Hooks are additive: all matching hooks from all sources run. There is no
“most specific wins”. If two PostToolUse hooks both format Go files, both run.
Consequences:
- Order is not guaranteed. Never write two hooks that depend on running in a particular sequence.
- Any one of them can block. A single non-zero exit is enough. If Claude reports a blocked tool call you didn’t expect, check plugin hooks too.
- They compose badly if they mutate the same file. Two
PostToolUseformatters on the same path will fight.
Keeping hooks from being a nuisance
| Practice | Why |
|---|---|
exit 0 on anything you do not explicitly handle |
A hook that fails by default blocks unrelated work |
Write the reason to stderr before a blocking exit |
Claude reads it and can route around the block; a silent block just looks like a bug |
Keep PreToolUse hooks under ~200ms |
They run on every matching call |
| Never let a hook rewrite a file it did not just receive | Two hooks fighting over one path is very hard to diagnose |
Check the hook in to .claude/hooks/ and commit it |
A hook referenced from committed settings but absent from the repo fails for everyone else |
The prose-vs-hook decision
flowchart TD
Q["I want Claude to always do X"] --> A{"Does a violation<br/>cause real damage?"}
A -->|"No — it is a style preference"| P["CLAUDE.md or a rule"]
A -->|"Yes"| B{"Can it be checked<br/>by a shell command?"}
B -->|"Yes"| H["<b>Hook</b> — deterministic"]
B -->|"No — needs judgement"| C{"Should it fail loudly?"}
C -->|"Yes"| SA["Subagent with a<br/>restricted tool list"]
C -->|"No"| P2["CLAUDE.md, and accept<br/>occasional misses"]
There is one more branch worth adding in practice: if the rule is simply “never
touch these paths” with no conditional logic, permissions.deny in
settings is simpler than a hook and needs no
script to maintain.