Claude Code hooks cookbook
The lifecycle event map plus five copy-paste settings.json recipes — format on save, block dangerous commands, run tests, notify on stop — and why hooks silently fail to fire.
Claude Code hooks are shell commands that run automatically at fixed points in the agent's lifecycle — before a tool runs, after a file is written, when you submit a prompt, when the agent stops. You configure them in settings.json under a hooks key, matched to the tool or event you care about. This cookbook gives you the lifecycle event map and five copy-paste recipes: auto-formatting edits, blocking dangerous shell and secret writes, running tests on change, and desktop notifications — plus why hooks silently fail to fire and how to fix that.
Every recipe below is a settings.json fragment. Merge the hooks block into .claude/settings.json (project scope, checked into the repo) or ~/.claude/settings.json (user scope); the two are combined. A hook receives a JSON payload on stdin — the tool name, the tool input, the working directory — and talks back through its exit code.
Worth being clear up front: hooks are automatic and deterministic. They fire on the event, every time, whether or not Claude decides they are relevant. If you instead want a check you invoke on demand by typing, that is a slash command with arguments, not a hook. Hooks are for guarantees; commands are for shortcuts.
The lifecycle event map
There are nine events you can hook. The two you will use most are PreToolUse (which can veto a tool call) and PostToolUse (which reacts after a tool succeeds). The rest let you attach behaviour to the edges of a session and a prompt.
| Event | Fires | Common use |
|---|---|---|
| PreToolUse | Before a tool runs — can block it | Guard bash, deny secret writes |
| PostToolUse | After a tool succeeds | Format, lint, run tests |
| UserPromptSubmit | When you send a prompt | Inject context, block on policy |
| Notification | When Claude raises a notification | Route alerts to Slack or desktop |
| Stop | When the main agent finishes a turn | Ping you that it is done |
| SubagentStop | When a subagent finishes | Log or chain a delegated result |
| PreCompact | Before the context is compacted | Save the transcript first |
| SessionStart | When a session starts or resumes | Load env, print reminders |
| SessionEnd | When a session ends | Cleanup, usage logging |
PreToolUse and PostToolUse take a matcher — a regex against the tool name (Write, Edit, MultiEdit, Bash, Read, and so on). Events that are not tied to a tool omit the matcher. The exit code is the contract: 0 is success, 2 is a blocking error whose stderr is fed back to Claude, and any other non-zero is a non-blocking warning. Only PreToolUse can actually stop an action; a 2 from PostToolUse just surfaces the message.
.claude/settings.json so your whole team runs the same guards; personal ones go in ~/.claude/settings.json. Keep any scripts a hook calls under .claude/hooks/ in the repo, and commit them — a guard only your machine has is not a guard.Recipe 1 — auto-format on Write and Edit
Formatting belongs in PostToolUse: the file has already been written, so you run your formatter over the path Claude just touched. The hook pulls file_path out of the tool input with jq and pipes it to the formatter.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs -r prettier --write --ignore-unknown"
}
]
}
]
}
}Swap prettier for gofmt, black, rustfmt, or whatever your stack uses; --ignore-unknown stops it erroring on file types it does not recognise. Because this is PostToolUse, a formatting failure will not block the edit — it just prints a warning and the run continues.
Recipe 2 — block secrets and dangerous bash
This is the highest-value hook and the one to get right. A PreToolUse hook can veto a tool call before it runs by exiting 2. Point a matcher at Bash to catch destructive shell, and mirror the pattern against Write|Edit if you also want to stop Claude committing secrets to disk.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/guard-bash.sh"
}
]
}
]
}
}The script reads the command from stdin and blocks on a denylist. Using shell case globs keeps it readable and avoids fighting with regex escaping:
#!/usr/bin/env bash
cmd=$(jq -r '.tool_input.command')
case "$cmd" in
*"rm -rf /"* | *"chmod -R 777"* | *"git push"*"--force"* | *"| sh"* | *"| bash"* )
echo "guard-bash: refused destructive command" >&2
exit 2 ;;
esac
exit 0Exit 2 blocks the call and hands your stderr message back to Claude, which then explains the refusal instead of silently retrying. For catching secrets in file writes, apply the same shape to .tool_input.content and match known key prefixes and high-entropy strings. A denylist is a starting point, not a security boundary — for the layered approach (allowlists, path fencing, and why one regex is not enough), see the Claude Code guardrails checklist and the guide on hardening Claude Code against prompt injection.
Recipe 3 — run tests or lint on change
The temptation is to run the whole suite in PostToolUse on every Write. Do not — a multi-minute suite on every edit makes the agent crawl. Run a fast check instead: a type-check, a linter, or just the tests for the file that changed.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "npm run -s typecheck 2>&1 | tail -20"
}
]
}
]
}
}Because this is PostToolUse, a non-zero exit is a non-blocking signal: the output — a type error, a failing test — is surfaced to Claude, which can fix it on the next turn without the edit being reverted. Keep the command fast and the output short; piping through tail stops a wall of errors flooding the context window.
Recipe 4 — notify when Claude stops
Long runs mean you walk away. A Stop hook fires when the main agent finishes its turn, so you can ping yourself. It takes no matcher. Point it at a small script rather than an inline command so the platform specifics stay out of settings.json:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/notify.sh"
}
]
}
]
}
}#!/usr/bin/env bash
# macOS
osascript -e 'display notification "Claude Code is done" with title "Claude Code"'
# Linux: notify-send "Claude Code" "done"
# Slack: curl -s -X POST -d '{"text":"Claude Code is done"}' "$SLACK_WEBHOOK_URL"Use SubagentStop instead of Stop if you want the ping when a delegated subagent finishes rather than when the whole run does — useful when you have kicked off a long background sweep and want to know the moment it lands.
Hooks not firing — a troubleshooting checklist
The most common hook problem is that nothing happens, with no error. Hooks fail quietly by design, so work this list in order.
- Restart or re-review after editing. For safety, Claude Code captures hooks at the start of a session; editing
settings.jsonmid-session does not re-arm them. Run/hooksto review and confirm the new configuration, or restart the session. A widely-referenced bug report traces many 'my hook will not run' cases back to exactly this. - Validate the JSON. A single trailing comma or missing brace disables the entire
hooksblock silently. Runjq . ~/.claude/settings.json— ifjqerrors, so does the hook. - Match the exact tool name. Matchers are case-sensitive regex:
Write,Edit,MultiEdit,Bash,Read— notwriteorbash. An empty matcher or*matches everything. - Use absolute paths. A
~in a command is not expanded, and relative paths resolve against the current working directory, which may not be your project root. Use$CLAUDE_PROJECT_DIRor a full path, andchmod +xthe script. - Check the exit code. Only
2blocks aPreToolUsecall. If your guard 'is not blocking', it is probably exiting1, which is a non-blocking warning. - Run with `--debug`. Start Claude Code as
claude --debugto see each hook's command, its stdin, and its exit status as it runs — the fastest way to find out what actually fired.
Where to start
If you add nothing else, add two: the PreToolUse bash guard from Recipe 2, because it is the one that prevents damage, and the PostToolUse formatter from Recipe 1, because it removes a whole class of trivial follow-up edits. Everything else is convenience. Keep each hook fast, keep its output short, and keep the scripts in version control under .claude/hooks/ so your team runs the same guards you do. The curation discipline that keeps a .claude/ directory lean applies to hooks too: a few that earn their place beat a dozen you have forgotten are running.