Skip to content

Topic · A1

The Claude Code Hooks Cookbook: 12 Copy-Paste Recipes

Twelve production-ready hooks for PreToolUse, PostToolUse, Stop, and SubagentStop events. Each recipe: when to use it, what it does, the JSON snippet to drop into settings.json.

# The Claude Code Hooks Cookbook: 12 Copy-Paste Recipes Skills tell Claude what to do. Hooks make sure it does. The difference matters. A skill is a probabilistic suggestion the model may or may not follow. A hook is deterministic — it runs on a specific event, every time, with no model in the loop. HN's mrmincent put it best: hooks are "the shift from hand tools to power tools." Below are 12 recipes we run in production. Each has a single purpose, a JSON snippet for settings.json, and an explanation of when it fires. All have been tested against Claude Code as of May 2026.

How hooks work in 30 seconds

Hooks live in settings.json under hooks, keyed by event. Each event takes an array of matchers — patterns that decide whether the hook should fire for this specific tool/event. When a matcher fires, the harness runs the command; exit code controls whether Claude proceeds (for PreToolUse) or just logs (for PostToolUse). The full event list: | Event | When it fires | |---|---| | PreToolUse | Before Claude executes a tool. Can block. | | PostToolUse | After a tool executes. Can't block; informational. | | Stop | When Claude tries to end a turn. Can force more work. | | SubagentStop | When a dispatched subagent finishes. | | SessionStart | At session boot. Setup hook. | | SessionEnd | At session shutdown. Cleanup hook. | | UserPromptSubmit | When the user sends a prompt. Can rewrite. | | PreCompact | Before context auto-compaction. | | Notification | When the harness sends a notification. | Read the official hooks reference for the full schema. The recipes below assume you know the structure.

The 12 recipes

1. Block destructive git commands

When. You want Claude to never run git push --force, git reset --hard, or git branch -D without explicit user approval. Single most common safety hook. Hook. PreToolUse on Bash. ``json { "PreToolUse": [ { "matcher": "Bash", "hooks": [{ "type": "command", "command": "node -e 'const i=JSON.parse(process.argv[1]); const cmd=i.tool_input.command||\"\"; const bad=[/git\\s+push.--force/,/git\\s+reset\\s+--hard/,/git\\s+branch\\s+-D/,/git\\s+clean\\s+-f/,/rm\\s+-rf\\s+\\//,/git\\s+checkout\\s+--/]; if (bad.some(r=>r.test(cmd))) { console.error(\"BLOCKED: destructive git command. Ask user explicitly.\"); process.exit(1); }' \"$CLAUDE_HOOK_INPUT\"" }] } ] } ` Adapted from sgasser's security gist. The pattern list is yours to extend.

2. Auto-format TypeScript/JavaScript after every edit

When. You want Prettier (or Biome) to run on every Edit/Write, so your repo stays clean even if Claude forgets. Hook.
PostToolUse on Edit and Write. `json { "PostToolUse": [ { "matcher": "Edit|Write", "hooks": [{ "type": "command", "command": "node -e 'const i=JSON.parse(process.argv[1]); const p=i.tool_input.file_path; if(/\\.(ts|tsx|js|jsx)$/.test(p)) require(\"child_process\").execSync(bunx prettier --write \"${p}\", {stdio:\"inherit\"});' \"$CLAUDE_HOOK_INPUT\"" }] } ] } ` Drop bunx to npx if you don't use Bun. Switch prettier to biome format if you use Biome.

3. Block writes to lockfiles

When. You want Claude to never hand-edit
package-lock.json, bun.lockb, yarn.lock, Cargo.lock, etc. Hook. PreToolUse on Edit and Write. `json { "PreToolUse": [ { "matcher": "Edit|Write", "hooks": [{ "type": "command", "command": "node -e 'const i=JSON.parse(process.argv[1]); const p=i.tool_input.file_path||\"\"; if(/(package-lock\\.json|bun\\.lockb|yarn\\.lock|pnpm-lock\\.yaml|Cargo\\.lock|go\\.sum)$/.test(p)){ console.error(\"BLOCKED: never edit lockfiles. Use the package manager.\"); process.exit(1); }' \"$CLAUDE_HOOK_INPUT\"" }] } ] } `

4. Run TypeScript check before commit

When. You want
tsc --noEmit to pass before any git commit Claude tries. Hook. PreToolUse on Bash, matching commit commands. `json { "PreToolUse": [ { "matcher": "Bash", "hooks": [{ "type": "command", "command": "node -e 'const i=JSON.parse(process.argv[1]); const c=i.tool_input.command||\"\"; if(/git\\s+commit/.test(c)){ try{require(\"child_process\").execSync(\"bunx tsc --noEmit\",{stdio:\"inherit\"})}catch(e){console.error(\"BLOCKED: tsc failed. Fix types first.\");process.exit(1)} }' \"$CLAUDE_HOOK_INPUT\"" }] } ] } ` This is npx tsc --noEmit under the hood. It fails the commit if types don't check.

5. Block .env reads and writes

When. You never want Claude to read or write
.env, .env.local, etc. — the secrets-don't-belong-in-context rule. Hook. PreToolUse on Read, Edit, Write. `json { "PreToolUse": [ { "matcher": "Read|Edit|Write", "hooks": [{ "type": "command", "command": "node -e 'const i=JSON.parse(process.argv[1]); const p=i.tool_input.file_path||\"\"; if(/\\.env(\\.|$)/.test(p) && !/\\.example$/.test(p)){ console.error(\"BLOCKED: .env files are out of bounds. Use .env.example for templates.\");process.exit(1) }' \"$CLAUDE_HOOK_INPUT\"" }] } ] } ` .env.example is allowed; everything else is blocked.

6. Log every Bash command to a project audit file

When. You want a per-session record of every shell command Claude ran. Useful for incident postmortems. Hook.
PostToolUse on Bash. `json { "PostToolUse": [ { "matcher": "Bash", "hooks": [{ "type": "command", "command": "node -e 'const i=JSON.parse(process.argv[1]); const fs=require(\"fs\"); fs.appendFileSync(\".claude/audit.log\", new Date().toISOString()+\" \"+i.tool_input.command+\"\\n\")' \"$CLAUDE_HOOK_INPUT\"" }] } ] } ` Add .claude/audit.log to .gitignore.

7. Inject "today's date" into every UserPromptSubmit

When. Claude's training cutoff is in the past. You want the current date injected into every prompt so date-sensitive tasks don't fail. Hook.
UserPromptSubmit. `json { "UserPromptSubmit": [ { "hooks": [{ "type": "command", "command": "node -e 'const out={hookSpecificOutput:{hookEventName:\"UserPromptSubmit\",additionalContext:Today is ${new Date().toISOString().slice(0,10)}.}};console.log(JSON.stringify(out))'" }] } ] } ` This uses the JSON-stdout pattern documented in the hooks reference to inject context.

8. Run tests after Edit on src/ files

When. You want tests to run automatically after Claude edits source code, so test breaks surface immediately. Hook.
PostToolUse on Edit/Write, only for src/. `json { "PostToolUse": [ { "matcher": "Edit|Write", "hooks": [{ "type": "command", "command": "node -e 'const i=JSON.parse(process.argv[1]); const p=i.tool_input.file_path||\"\"; if(p.includes(\"/src/\")&&!p.includes(\"/node_modules/\")){ require(\"child_process\").exec(\"bun test --bail\",{stdio:\"inherit\"}) }' \"$CLAUDE_HOOK_INPUT\"" }] } ] } ` Async — doesn't block Claude. The test result lands in the terminal for you to see.

9. Block npm install in a Bun project

When. Your project uses Bun. Claude occasionally tries npm install. You want that blocked. Hook. PreToolUse on Bash. `json { "PreToolUse": [ { "matcher": "Bash", "hooks": [{ "type": "command", "command": "node -e 'const i=JSON.parse(process.argv[1]); const c=i.tool_input.command||\"\"; if(/^npm\\s+(install|i|add)/.test(c)){ console.error(\"BLOCKED: this project uses Bun. Run bun install or bun add.\");process.exit(1) }' \"$CLAUDE_HOOK_INPUT\"" }] } ] } `

10. SubagentStop: persist subagent findings to disk

When. You dispatch subagents and want their final report appended to a notes file so the parent session has it after compaction. Hook. SubagentStop. `json { "SubagentStop": [ { "hooks": [{ "type": "command", "command": "node -e 'const i=JSON.parse(process.argv[1]); const fs=require(\"fs\"); const note=## Subagent ${i.subagent_id||\"unknown\"} — ${new Date().toISOString()}\\n${i.transcript||\"\"}\\n\\n; fs.appendFileSync(\".claude/subagent-notes.md\", note)' \"$CLAUDE_HOOK_INPUT\"" }] } ] } ` This survives compaction. The parent session can grep .claude/subagent-notes.md later.

11. Stop hook with circuit breaker (force task completion safely)

When. You want Claude to keep working until a goal file exists, but you also want to avoid infinite loops. Hook. Stop. `json { "Stop": [ { "hooks": [{ "type": "command", "command": "node -e 'const fs=require(\"fs\"); const C=\".claude/stop-counter\"; const N=(fs.existsSync(C)?parseInt(fs.readFileSync(C,\"utf8\")):0)+1; fs.writeFileSync(C,String(N)); if(N>5){fs.writeFileSync(C,\"0\");process.exit(0)} if(!fs.existsSync(\"DONE.md\")){console.log(JSON.stringify({decision:\"block\",reason:\"DONE.md does not exist yet. Continue.\"}))}'" }] } ] } ` Counter caps the loop at 5 iterations. Pattern from claudefa.st's stop-hook playbook.

12. SessionStart: print active CLAUDE.md and skill summary

When. You want to see at session start exactly what context is loaded. Sanity check. Hook. SessionStart. `json { "SessionStart": [ { "hooks": [{ "type": "command", "command": "node -e 'const fs=require(\"fs\"); const path=require(\"path\"); const claudeMd=fs.existsSync(\"./CLAUDE.md\")?fs.readFileSync(\"./CLAUDE.md\",\"utf8\").split(\"\\n\").length:0; const skills=fs.existsSync(\".claude/skills\")?fs.readdirSync(\".claude/skills\").length:0; console.error([session-start] CLAUDE.md: ${claudeMd} lines | local skills: ${skills})'" }] } ] } ` Prints to stderr so it appears in the terminal but not in Claude's context.

Where this fails

1. Cross-platform brittleness. Every recipe above is bash/Node. On Windows without WSL or git-bash, several of these break. We test on macOS and Linux. PowerShell equivalents are uglier but possible. 2. JSON-in-CLI escaping. The recipes inline JavaScript through node -e. If you need anything more complex, write the hook as a script and reference it: "command": ".claude/hooks/block-destructive.js". The cookbook here optimizes for "paste into settings.json and go." 3. Hooks can mask bugs. A PostToolUse formatter that silently fixes Claude's output means Claude never learns to format correctly. We've seen teams add 12 hooks, mask all the model's bad habits, then wonder why a junior dev pairing with the same harness gets messy output. Use hooks for safety; use CLAUDE.md and skills for guidance. 4. The matcher syntax is undocumented for edge cases.
* Anthropic's reference lists the common matchers but not the regex precedence, glob behavior in nested paths, or how matchers compose with multiple hooks on the same event. We've filed issues; until they're resolved, test your matcher with a console.error("matched")` line.

What to read next

Sources

  • Hacker News. "Claude Code now supports hooks". Launch thread. nojs: "set up complex concrete rules about commands CC is allowed to run...rather than trying to coax these via CLAUDE.md." — the value-prop quote.

Related GitHub projects

Frequently asked

Where do Claude Code hooks live?
In `settings.json` — at `~/.claude/settings.json` for user-global hooks, or `.claude/settings.json` at the project root for per-project hooks. Anthropic's settings reference confirms the merge order: enterprise → user → project, with later scopes overriding earlier. Hooks fire on specific events: `PreToolUse`, `PostToolUse`, `Stop`, `SubagentStop`, `SessionStart`, `SessionEnd`, `UserPromptSubmit`, `PreCompact`, and `Notification`. ([source: code.claude.com/docs/en/hooks](https://code.claude.com/docs/en/hooks))
What's the difference between a hook and a skill?
A hook is deterministic — it runs a command on a specific event, every time, with no model in the loop. A skill is probabilistic — Claude decides whether to invoke it based on the description. Hooks are right for safety rails (block dangerous commands, force formatting); skills are right for guidance (review style, suggest tests). The HN comment that nailed it: hooks are the shift 'from hand tools to power tools' for Claude Code. ([source: HN 44477756](https://news.ycombinator.com/item?id=44477756))
Can a hook block a tool call?
Yes. `PreToolUse` hooks can return a non-zero exit code to block the tool invocation entirely. This is how the destructive-command hooks below work — they grep the proposed command, decide if it's dangerous, and exit 1 if so. Claude sees the block and routes around it.
Will my hook cause an infinite loop?
Stop hooks can. If your Stop hook triggers Claude to do more work (which then triggers Stop again), you'll loop forever. The fix is a circuit-breaker: write a counter file, increment on each Stop fire, abort after N. claudefa.st published the canonical pattern; recipe #11 below implements it. ([source: claudefa.st](https://claudefa.st/blog/tools/hooks/stop-hook-task-enforcement))
Do hooks work in subagents?
Most hooks fire in the parent session only. `SubagentStop` is the one designed to fire when a subagent completes its task. If you need a `PreToolUse` to fire inside a subagent's context, that's a known limitation — file against [claude-code](https://github.com/anthropics/claude-code) for status.
Can I write a hook in any language?
Yes. Hooks are just commands the harness invokes. Bash, Python, Node, Go, anything — as long as it returns the right exit code and (for advanced hooks) the right JSON on stdout. We use bash for the simple recipes and Node for the complex ones below.

Related topics