Claude code hooks saved my production database on a Thursday afternoon. I had been pairing with Claude Code on a migration script, the kind where you are moving user records between tables and one wrong WHERE clause means you are explaining to your CEO why 40,000 accounts just vanished. Claude generated a DROP TABLE command as part of a cleanup step. Reasonable in context. Catastrophic in production. But a PreToolUse hook I had configured three weeks earlier caught it, printed BLOCKED: Dangerous command in bright red, and stopped execution cold.
I sat there for a moment, staring at the terminal, genuinely grateful for a five-line JSON config I had almost not bothered to set up. That is the thing about claude code hooks. They are not glamorous. They are not the feature anyone talks about at conferences. They are the safety net stretched beneath the trapeze artist, invisible until the moment you slip, and then suddenly the only thing that matters.
Omar and I have been building with Claude Code daily since it launched, and hooks have quietly become the most valuable part of our setup. Not because any single hook is revolutionary, but because the accumulation of small automations compounds into something that fundamentally changes how you work. If you want the full picture of Claude Code capabilities, start with our beginner's tutorial and our 50 tips guide. This post is the deep dive on hooks specifically, with every example ready to copy-paste into your .claude/settings.json.
What hooks actually are (and why most guides get them wrong)
Most articles about Claude Code hooks describe three event types: PreToolUse, PostToolUse, and Stop. That was true six months ago. It is not true now. The hook system has expanded to 25 distinct events per the official hooks guide, and understanding the full surface area is the difference between using hooks as a novelty and using them as infrastructure.
Here is the complete event list as of January 2026:
- PreToolUse and PostToolUse: before and after any tool call (Edit, Write, Bash, Grep, etc.)
- Stop: when Claude finishes a response and is about to return control to you
- SessionStart: when a new session begins
- CwdChanged: when the working directory changes
- FileChanged: when a file is modified outside Claude's tools
- PreCompact and PostCompact: before and after context compaction
- WorktreeCreate: when a new git worktree is created
- TeammateIdle: in multi-agent setups, when another agent is waiting
- And more being added with each release
Each hook supports four handler types:
- command: runs a shell command (the workhorse, covers 80% of use cases)
- prompt: injects text back into Claude's context
- agent: spawns a sub-agent with its own prompt and timeout
- http: sends a webhook to an external service
The exit code matrix is simple but critical, and the full technical reference covers every edge case. Exit 0 means the hook passed. Exit 2 means block the action (deny). Exit 1 means warn but continue. Context output from your hook is capped at 10,000 characters, which is plenty for error messages but worth knowing if you are tempted to pipe an entire log file back.
Hooks live in your .claude/settings.json file. The structure looks like this:
{
"hooks": {
"EventName": [
{
"matcher": "ToolName",
"hooks": [
{
"type": "command",
"command": "your-script.sh"
}
]
}
]
}
}The matcher field uses regex to filter which tools trigger the hook. "Edit|Write" fires on edits and writes. "Bash" fires on shell commands. Omit it to match everything.
Now, some napkin math to motivate what follows. If a PreToolUse hook catches one bug per week that would have taken 2 hours to debug in production, that is 104 hours per year. Two and a half work weeks saved by a 5-line JSON config. If a PostToolUse formatting hook saves you 30 seconds of manual formatting per edit, and you make 50 edits per day, that is 25 minutes daily, or roughly 108 hours per year. These are not theoretical numbers. They are what Omar and I tracked over three months of actual usage.
Let us get into the recipes.
Category 1: Auto-formatting hooks
Think of these as the kitchen timer that goes off the moment your bread comes out of the oven. You do not have to remember to format. It just happens.
Recipe 1: Prettier after every edit
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
}
]
}
]
}
}This is the single most popular hook in the Claude Code ecosystem, and for good reason. Every time Claude edits or creates a file, Prettier runs on it automatically. No more reviewing diffs that are 90% whitespace changes. The tool input arrives as JSON on stdin, so jq extracts the file path and pipes it to Prettier.
Recipe 2: ESLint autofix after edits
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx eslint --fix --quiet"
}
]
}
]
}
}Same pattern as Prettier, but catching lint violations instead of formatting issues. The --quiet flag suppresses warnings so you only see actual errors in the hook output. I pair this with Recipe 1, running Prettier first and ESLint second.
Recipe 3: Gofmt for Go projects
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | grep '\\.go$' | xargs -I{} gofmt -w {}"
}
]
}
]
}
}For Go projects, gofmt is non-negotiable. The grep filter ensures we only run it on .go files. If Claude edits a Makefile or a README, the hook exits cleanly without touching it.
Recipe 4: Rustfmt for Rust projects
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | grep '\\.rs$' | xargs -I{} rustfmt {}"
}
]
}
]
}
}Same principle, different language. The pattern is universal: extract file path from tool input, filter by extension, run the formatter. You could adapt this for Black (Python), swift-format (Swift), or any language-specific formatter.
Category 2: Security gates
These are the safety nets. Blake Crosley's production hooks tutorial puts it well: "prompting alone achieved only 80% compliance; hooks provide 100% enforcement." The trapeze artist does not think about the net during the performance. But the net is always there, and it is the reason the artist can attempt the triple somersault without hesitation. Security hooks give you that same confidence.
Recipe 5: Block dangerous Bash commands
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash -c 'INPUT=$(cat); CMD=$(echo \"$INPUT\" | jq -r \".tool_input.command\"); if echo \"$CMD\" | grep -qE \"rm\\\\s+-rf\\\\s+/|git\\\\s+push\\\\s+(-f|--force)\\\\s+(origin\\\\s+)?main|DROP\\\\s+TABLE\"; then echo \"BLOCKED: Dangerous command\" >&2; exit 2; fi'"
}
]
}
]
}
}This is the hook that saved my database. It intercepts every Bash command before execution and checks for patterns like rm -rf /, force-pushing to main, or DROP TABLE. Exit code 2 blocks the action entirely. Claude sees the block message and adjusts its approach. You can extend the regex pattern to catch anything else that makes you nervous. I have added sudo, chmod 777, and curl | bash to my personal version.
Recipe 6: Protect critical files from edits
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "bash -c 'INPUT=$(cat); FILE=$(echo \"$INPUT\" | jq -r \".tool_input.file_path\"); PROTECTED=\".env .env.local production.yml secrets.json\"; for p in $PROTECTED; do if [ \"$(basename \"$FILE\")\" = \"$p\" ]; then echo \"BLOCKED: $p is a protected file\" >&2; exit 2; fi; done'"
}
]
}
]
}
}Some files should never be touched by an AI agent. Your .env with production credentials. Your secrets.json. Your Kubernetes production config. This hook maintains a list of protected filenames and blocks any edit or write attempt targeting them. You could also use a .claude/hooks/protect-files.sh script for more complex logic:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh"
}
]
}
]
}
}Recipe 7: Block secrets from being written into code
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "bash -c 'INPUT=$(cat); FILE=$(echo \"$INPUT\" | jq -r \".tool_input.file_path\"); if grep -qE \"(sk-[a-zA-Z0-9]{20,}|AKIA[A-Z0-9]{16}|ghp_[a-zA-Z0-9]{36})\" \"$FILE\" 2>/dev/null; then echo \"WARNING: Possible secret detected in $FILE\" >&2; exit 1; fi'"
}
]
}
]
}
}This runs after an edit completes and scans the modified file for patterns that look like API keys: OpenAI keys (sk-), AWS access keys (AKIA), GitHub tokens (ghp_). Exit code 1 warns rather than blocks, since there might be false positives. But the warning is usually enough to make you look twice.
Recipe 8: Git branch protection
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(git *)",
"command": "bash -c 'INPUT=$(cat); CMD=$(echo \"$INPUT\" | jq -r \".tool_input.command\"); BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null); if [ \"$BRANCH\" = \"main\" ] || [ \"$BRANCH\" = \"master\" ]; then if echo \"$CMD\" | grep -qE \"git\\\\s+(push|commit|reset|rebase)\"; then echo \"BLOCKED: Cannot $CMD on $BRANCH branch\" >&2; exit 2; fi; fi'"
}
]
}
]
}
}Notice the if field here. Added in v2.1.85, if lets you filter hooks by tool arguments without running the full command. "Bash(git *)" means this hook only fires when the Bash command starts with git. The hook itself checks whether you are on main or master and blocks commits, pushes, resets, and rebases. Claude should be working on feature branches, always.
Category 3: Quality enforcement
These hooks enforce standards that are easy to forget in the flow of development. They are the recipe card taped to the cabinet door, the checklist that keeps you from forgetting the salt.
Recipe 9: TypeScript strict mode check
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "bash -c 'INPUT=$(cat); FILE=$(echo \"$INPUT\" | jq -r \".tool_input.file_path\"); if echo \"$FILE\" | grep -qE \"\\.(ts|tsx)$\"; then npx tsc --noEmit --strict \"$FILE\" 2>&1 | head -20; fi'"
}
]
}
]
}
}Every TypeScript file Claude touches gets an immediate type check. Errors surface right away, before they cascade into five other files. The head -20 cap keeps the output manageable within the 10,000-character limit.
Recipe 10: Enforce file size limits
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "bash -c 'INPUT=$(cat); FILE=$(echo \"$INPUT\" | jq -r \".tool_input.file_path\"); LINES=$(wc -l < \"$FILE\" 2>/dev/null || echo 0); if [ \"$LINES\" -gt 500 ]; then echo \"WARNING: $FILE has $LINES lines. Consider splitting into smaller modules.\" >&2; exit 1; fi'"
}
]
}
]
}
}Claude has a tendency to write everything in one file if you let it. This hook warns when any file exceeds 500 lines. It does not block, just nudges. The nudge is usually enough. Claude will respond by offering to extract utilities or split the module.
Recipe 11: Require test files for new source files
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write",
"hooks": [
{
"type": "command",
"command": "bash -c 'INPUT=$(cat); FILE=$(echo \"$INPUT\" | jq -r \".tool_input.file_path\"); if echo \"$FILE\" | grep -qE \"src/.*\\.(ts|js)$\" && ! echo \"$FILE\" | grep -qE \"\\.(test|spec)\\.\"; then TEST_FILE=$(echo \"$FILE\" | sed \"s/\\(\\.[^.]*\\)$/.test\\1/\"); if [ ! -f \"$TEST_FILE\" ]; then echo \"NOTE: No test file found at $TEST_FILE. Consider writing tests.\" >&2; exit 1; fi; fi'"
}
]
}
]
}
}When Claude creates a new source file, this hook checks whether a corresponding test file exists. If not, it emits a warning. This is not about blocking progress. It is about making the absence of tests visible, which is often all you need to remember to write them. For a deeper dive on how we structure our CLAUDE.md rules files, that guide covers the testing conventions we encode alongside hooks.
Recipe 12: Ban console.log in production code
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "bash -c 'INPUT=$(cat); FILE=$(echo \"$INPUT\" | jq -r \".tool_input.file_path\"); if echo \"$FILE\" | grep -qE \"\\.(ts|tsx|js|jsx)$\" && ! echo \"$FILE\" | grep -qE \"\\.(test|spec)\\.\"; then COUNT=$(grep -c \"console\\.log\" \"$FILE\" 2>/dev/null || echo 0); if [ \"$COUNT\" -gt 0 ]; then echo \"WARNING: $COUNT console.log statement(s) found in $FILE\" >&2; exit 1; fi; fi'"
}
]
}
]
}
}Claude loves console.log for debugging. So do I, honestly. But they should not ship to production. This hook scans edited files for console.log and warns if any are present. Test files are excluded because logging in tests is fine.
Category 4: Notification and logging
Recipe 13: Log all Claude tool usage
{
"hooks": {
"PostToolUse": [
{
"hooks": [
{
"type": "command",
"command": "bash -c 'INPUT=$(cat); TOOL=$(echo \"$INPUT\" | jq -r \".tool_name\"); FILE=$(echo \"$INPUT\" | jq -r \".tool_input.file_path // .tool_input.command // \"N/A\"\" 2>/dev/null); echo \"$(date +%Y-%m-%dT%H:%M:%S) | $TOOL | $FILE\" >> \"$CLAUDE_PROJECT_DIR/.claude/tool-usage.log\"'"
}
]
}
]
}
}No matcher here, so this fires on every tool use. It appends a timestamped line to a log file with the tool name and target. At the end of a session, you have a complete record of everything Claude touched. I review this log weekly to understand my own patterns and catch anything unexpected.
Recipe 14: Desktop notification on task completion
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification \"Claude Code has finished the task\" with title \"Claude Code\" sound name \"Glass\"'"
}
]
}
]
}
}This is macOS-specific (using osascript), but the concept is universal. When Claude finishes and stops, you get a desktop notification with a sound. Perfect for when you kick off a long task, switch to another window, and want to know when it is done. On Linux, swap osascript for notify-send.
Recipe 15: Slack webhook on session end
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "http",
"url": "https://hooks.slack.com/services/YOUR/WEBHOOK/URL",
"method": "POST",
"headers": {
"Content-Type": "application/json"
},
"body": "{\"text\": \"Claude Code session completed.\"}"
}
]
}
]
}
}The http handler type is underused. This sends a Slack message when Claude finishes work. For team setups where one person kicks off a Claude session and others need to know when it is done, this is invaluable. You can replace the Slack webhook URL with any service: Discord, Teams, or a custom endpoint.
Recipe 16: Track file change frequency
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "bash -c 'INPUT=$(cat); FILE=$(echo \"$INPUT\" | jq -r \".tool_input.file_path\"); echo \"$FILE\" >> \"$CLAUDE_PROJECT_DIR/.claude/edit-frequency.log\"'"
}
]
}
]
}
}Simple but revealing. After a week, run sort .claude/edit-frequency.log | uniq -c | sort -rn | head -20 to see which files Claude touches most. If one file keeps getting edited over and over, that is a signal. Either the file is too large, the requirements are unclear, or there is a structural problem worth addressing. I discovered that Claude was editing my utils.ts file 15 times per session. That file needed to be split. The log made the problem obvious.
Category 5: Context management
Context is everything in Claude Code. These hooks help you manage what Claude knows, what it remembers, and what it forgets.
Recipe 17: Re-inject project rules after compaction
{
"hooks": {
"PostCompact": [
{
"hooks": [
{
"type": "command",
"command": "echo 'Reminder: use Bun, not npm. Run bun test before committing. Never modify files in the /legacy directory.'"
}
]
}
]
}
}Context compaction is when Claude summarizes the conversation to free up space. The problem is that important instructions can get lost in the summary. This hook fires after every compaction and re-injects your most critical rules. Think of it as pinning a note to the top of Claude's memory. Whatever you put here survives compaction, guaranteed.
Recipe 18: Session startup checklist
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "bash -c 'echo \"Session started at $(date)\"; echo \"Branch: $(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo N/A)\"; echo \"Node: $(node -v 2>/dev/null || echo N/A)\"; echo \"Last commit: $(git log --oneline -1 2>/dev/null || echo N/A)\"'"
}
]
}
]
}
}Every time you start a new Claude Code session, this hook prints the current branch, Node version, and last commit. It is context that Claude uses to orient itself. Like walking into a kitchen and seeing which burners are on, what is in the oven, and what the last cook left on the counter. You know exactly where things stand before you start working.
Recipe 19: Inject architecture context on directory change
{
"hooks": {
"CwdChanged": [
{
"hooks": [
{
"type": "command",
"command": "bash -c 'if [ -f \"$(pwd)/ARCHITECTURE.md\" ]; then cat \"$(pwd)/ARCHITECTURE.md\" | head -50; fi'"
}
]
}
]
}
}If you maintain ARCHITECTURE.md files in different parts of your monorepo, this hook automatically feeds them to Claude when you cd into that directory. Claude gets the local context without you having to explain anything. The head -50 cap keeps it within the 10,000-character output limit.
Recipe 20: Prompt-based context injection
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "prompt",
"prompt": "Before starting any work, review the project's CLAUDE.md and check the current git branch. If on main, create a new feature branch first."
}
]
}
]
}
}The prompt handler type injects text directly into Claude's context as if you had typed it. Unlike command hooks that return stdout, prompt hooks speak to Claude as the user. This is powerful for establishing workflows. Here, every session begins with Claude reviewing the rules and checking the branch, automatically.
Category 6: Testing and CI
Recipe 21: Run tests after implementation changes
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "bash -c 'INPUT=$(cat); FILE=$(echo \"$INPUT\" | jq -r \".tool_input.file_path\"); if echo \"$FILE\" | grep -qE \"src/.*\\.(ts|tsx|js|jsx)$\" && ! echo \"$FILE\" | grep -qE \"\\.(test|spec)\\.\"; then echo \"Source file modified: $FILE. Remember to run tests.\"; fi'"
}
]
}
]
}
}A gentle reminder, not a gate. Every time a source file (not a test file) is modified, Claude gets a nudge to run the test suite. Sometimes Claude runs tests anyway. Sometimes it does not. This makes sure the thought is always present.
Recipe 22: Agent-based test verification at Stop
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "agent",
"prompt": "Verify that all unit tests pass. Run the test suite and report results.",
"timeout": 120
}
]
}
]
}
}This is one of the most powerful hooks in the entire system. The agent handler spawns a separate Claude sub-agent that runs your test suite before the session ends. If tests fail, you see the failures immediately instead of discovering them the next morning. The 120-second timeout prevents runaway test suites from hanging your session. Omar calls this his "no surprises" hook. I call it the best hook I have ever written.
Recipe 23: Lint check before committing
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(git commit*)",
"command": "bash -c 'LINT_OUTPUT=$(npx eslint src/ --quiet 2>&1); if [ $? -ne 0 ]; then echo \"BLOCKED: Lint errors must be fixed before committing:\" >&2; echo \"$LINT_OUTPUT\" | head -30 >&2; exit 2; fi'"
}
]
}
]
}
}The if field targets this hook specifically at git commit commands. No other Bash commands trigger it. When Claude tries to commit, ESLint runs first. If there are errors, the commit is blocked and Claude sees the lint output. It will typically fix the issues and try again.
Recipe 24: Verify build before push
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(git push*)",
"command": "bash -c 'BUILD_OUTPUT=$(npm run build 2>&1); if [ $? -ne 0 ]; then echo \"BLOCKED: Build failed. Fix errors before pushing:\" >&2; echo \"$BUILD_OUTPUT\" | tail -30 >&2; exit 2; fi'"
}
]
}
]
}
}Same pattern as Recipe 23 but for pushes. The full build runs before any push goes through. Yes, this adds time. But it is infinitely better than pushing broken code and finding out from your CI pipeline five minutes later. Or worse, from a teammate.
Category 7: Advanced patterns
These recipes use features that most tutorials do not cover. The disler/claude-code-hooks-mastery repo has more examples if you want to explore further, but the recipes below cover patterns nobody else has documented.
Recipe 25: Async background verification with asyncRewake
{
"hooks": {
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash -c 'INPUT=$(cat); CMD=$(echo \"$INPUT\" | jq -r \".tool_input.command\"); if echo \"$CMD\" | grep -qE \"^(npm|yarn|pnpm)\\s+install\"; then npm audit --audit-level=high 2>&1 | tail -10; fi'",
"asyncRewake": true
}
]
}
]
}
}The asyncRewake field is new and extremely useful. When set to true, the hook runs in the background. Claude continues working without waiting for it. But if the hook fails (non-zero exit), Claude gets woken up and informed of the failure. This is perfect for slow checks that should not block the workflow but whose failures matter. Here, every npm install triggers a background security audit. If high-severity vulnerabilities are found, Claude is notified and can address them.
Recipe 26: HTTP webhook for deployment tracking
{
"hooks": {
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "http",
"url": "https://your-api.example.com/deployments",
"method": "POST",
"headers": {
"Content-Type": "application/json",
"Authorization": "Bearer ${DEPLOY_TRACKING_TOKEN}"
},
"body": "{\"event\": \"tool_use\", \"timestamp\": \"${ISO_TIMESTAMP}\", \"project\": \"${CLAUDE_PROJECT_DIR}\"}"
}
]
}
]
}
}HTTP hooks let you integrate Claude Code with any external system. Deployment trackers, analytics platforms, custom dashboards. Every tool use sends a structured payload to your API. You could build a dashboard that shows Claude's activity across your entire team in real time.
Recipe 27: Agent-based code review before commit
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "agent",
"if": "Bash(git commit*)",
"prompt": "Review the staged changes using git diff --cached. Check for: security issues, performance problems, missing error handling, and hardcoded values. If you find critical issues, report them clearly.",
"timeout": 90
}
]
}
]
}
}This is the most sophisticated hook I use. Before every commit, a sub-agent reviews the staged diff. It is like having a second pair of eyes on every commit, but those eyes never get tired, never skim, and never let a hardcoded API key slip through because it is Friday afternoon and everyone wants to go home. The 90-second timeout is tight enough to keep things moving but generous enough for the agent to actually review the diff.
Recipe 28: Modified input transformation with updatedInput
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash -c 'INPUT=$(cat); CMD=$(echo \"$INPUT\" | jq -r \".tool_input.command\"); MODIFIED=$(echo \"$CMD\" | sed \"s/npm /bun /g\"); echo \"{\\\"updatedInput\\\": {\\\"command\\\": \\\"$MODIFIED\\\"}}\"'"
}
]
}
]
}
}The updatedInput feature lets a PreToolUse hook modify the tool's input before it executes. Here, every npm command gets transparently rewritten to bun. Claude thinks it is running npm install. What actually runs is bun install. This is useful for teams migrating between tools without having to retrain Claude's habits.
Recipe 29: Multi-hook pipeline on a single event
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
},
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx eslint --fix --quiet"
},
{
"type": "command",
"command": "bash -c 'INPUT=$(cat); FILE=$(echo \"$INPUT\" | jq -r \".tool_input.file_path\"); if echo \"$FILE\" | grep -qE \"\\.(ts|tsx)$\"; then npx tsc --noEmit \"$FILE\" 2>&1 | head -10; fi'"
}
]
}
]
}
}You can chain multiple hooks on the same event. They run in order. Here, every edit goes through Prettier, then ESLint autofix, then TypeScript type checking. If any hook exits with code 2, the chain stops. Think of it as a pipeline: format, lint, type-check. Three quality gates in one config block.
Recipe 30: Decision precedence with multiple hooks
When multiple hooks fire on the same event, Claude Code follows a strict precedence order: deny > defer > ask > allow. If one hook says "block" (exit 2) and another says "allow" (exit 0), the block wins. Always.
This matters when you have layered configs. Your project .claude/settings.json might allow edits to most files, but a team-level config blocks edits to *.lock files. The block takes precedence. Here is an example:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "bash -c 'INPUT=$(cat); FILE=$(echo \"$INPUT\" | jq -r \".tool_input.file_path\"); if echo \"$FILE\" | grep -qE \"\\.(lock|lockb)$\"; then echo \"BLOCKED: Lock files should not be edited manually\" >&2; exit 2; fi'"
}
]
},
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "echo 'Edit allowed'"
}
]
}
]
}
}The first hook blocks lock files. The second allows everything else. Because deny takes precedence, lock files are always protected regardless of order.
Putting it all together: a production-ready config
Here is what my actual .claude/settings.json hooks section looks like, trimmed to the essentials:
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "bash -c 'echo \"Branch: $(git rev-parse --abbrev-ref HEAD)\"; echo \"Last commit: $(git log --oneline -1)\"'"
}
]
}
],
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash -c 'INPUT=$(cat); CMD=$(echo \"$INPUT\" | jq -r \".tool_input.command\"); if echo \"$CMD\" | grep -qE \"rm\\\\s+-rf\\\\s+/|git\\\\s+push\\\\s+(-f|--force)\\\\s+(origin\\\\s+)?main|DROP\\\\s+TABLE\"; then echo \"BLOCKED: Dangerous command\" >&2; exit 2; fi'"
}
]
},
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "bash -c 'INPUT=$(cat); FILE=$(echo \"$INPUT\" | jq -r \".tool_input.file_path\"); if echo \"$FILE\" | grep -qE \"(\\.env|\\.env\\.local|secrets\\.json)$\"; then echo \"BLOCKED: Protected file\" >&2; exit 2; fi'"
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
}
]
}
],
"PostCompact": [
{
"hooks": [
{
"type": "command",
"command": "echo 'Reminder: use feature branches. Run tests before committing.'"
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "agent",
"prompt": "Run the test suite and verify all tests pass.",
"timeout": 120
}
]
}
]
}
}This is not the maximal setup. It is the practical one. Five events, six hooks, covering the scenarios that actually matter day to day: orientation, safety, formatting, memory, and verification. Start here. Add more as you discover your own pain points.
Common mistakes and how to avoid them
Mistake 1: Over-blocking. If your hooks exit with code 2 on too many things, Claude spends its entire session getting blocked and reattempting. Use exit 1 (warn) for anything that is not genuinely dangerous. Reserve exit 2 for the things that would actually hurt you.
Mistake 2: Forgetting the 10,000-character cap. Your hook's stdout gets injected into Claude's context, but only the first 10,000 characters. If you pipe a full test suite output into a hook, Claude only sees the beginning. Use tail or head to surface the relevant portion.
Mistake 3: Slow hooks without asyncRewake. A hook that takes 30 seconds blocks Claude for 30 seconds on every trigger. If the check is not time-critical, use asyncRewake: true to let it run in the background.
Mistake 4: Not testing hooks locally. Before adding a hook to your config, test the command manually. Pipe some sample JSON into it and verify the exit codes. A hook with a syntax error will fire on every tool use and make your session miserable.
Mistake 5: Hardcoding paths. Use $CLAUDE_PROJECT_DIR instead of absolute paths. Your hooks should work on any machine, not just yours.
The philosophy of hooks
I want to end with something that is not technical, because I think the technical details obscure a deeper point.
Hooks are not about controlling Claude. They are about trusting Claude. It sounds paradoxical, but it is true. When I know that a hook will catch dangerous commands, I give Claude more autonomy. When I know that formatting is automatic, I stop micromanaging whitespace. When I know that tests run at the end of every session, I stop anxiously checking the test suite every five minutes.
The trapeze artist does not perform worse because the safety net is there. The artist performs better, because the net removes the fear of falling and allows full commitment to the craft.
If you are new to Claude Code, start with three hooks: the dangerous command blocker (Recipe 5), the Prettier formatter (Recipe 1), and the session startup context (Recipe 18). You can set those up in under five minutes. Live with them for a week. Then come back here and add the ones that solve the problems you actually encounter.
The 30 recipes in this cookbook are not prescriptions. They are starting points. The best hook is always the one you write yourself, for the specific problem that keeps biting you on Thursday afternoons.
What hooks have you built? We are genuinely curious. The Claude Code hooks ecosystem is still young, and the most creative patterns are coming from practitioners, not documentation. If you have built something interesting, the conversation continues in the community.