Claude Code hooks are commands that run automatically at fixed points in a session, set under a hooks key in settings.json. This gallery gives 15 of them with the event each binds to and the JSON to paste: format on save, blocked destructive commands, protected files, test gates before commit and before Claude stops, context after compaction, notifications and a cost warning before model switches.
Claude Code hooks are shell commands, HTTP calls or model prompts that Claude Code runs automatically at fixed points in a session, configured under a hooks key in settings.json. The 15 examples below cover the jobs most setups need, each with the event it binds to and JSON you can paste: formatting, blocking, test gates, context, notifications and cost.
Checked October 2026 against Anthropic's hooks guide and hooks reference. Ten of the fifteen configs are the documented examples as written. The other five (4, 6, 8, 9 and 15) are assembled from documented events, fields and exit codes, and their shell and jq logic was run against sample hook input. This is a documentation-based gallery, not a log of our own sessions. Some fields need a recent Claude Code 2.1.x release; those are flagged.
Hooks are one of five ways to extend Claude Code. Our Claude Code complete guide covers where they sit beside CLAUDE.md, skills, subagents and MCP. The short version: an instruction in CLAUDE.md is a request the model usually follows; a hook is code that always runs.
What are Claude Code hooks, and when do they fire?
Each hook binds to an event. Claude Code passes the event's details to your command as JSON on stdin, and your command answers with an exit code and, optionally, JSON on stdout. The reference lists more than 30 events; six carry most of the useful work.
- 01SessionStart
A session begins, resumes or compacts. Add context here.
- 02UserPromptSubmit
You send a prompt. Can add context or block it.
- 03PreToolUse
Before each tool call. The only place to stop an action.
- 04PostToolUse
After a tool call succeeds. Format, test, log.
- 05Stop
Claude finishes responding. Can send it back to work.
- 06SessionEnd
The session closes. Clean up and log.
Claude Code hooks examples: all 15 at a glance
Scan the table for the job you need, then jump to its config. The last column matters most: only some events can stop what Claude is about to do.
| # | Example | Event | Matcher or filter | Can it block? |
|---|---|---|---|---|
| 1 | Format on save | PostToolUse | Edit|Write | No, the edit already happened |
| 2 | Background tests after edits | PostToolUse | Write|Edit, async | No |
| 3 | Log every Bash command | PostToolUse | Bash | No |
| 4 | Block destructive commands | PreToolUse | Bash | Yes |
| 5 | Protect .env and lock files | PreToolUse | Edit|Write | Yes |
| 6 | Test gate before commit | PreToolUse | Bash, if: Bash(git commit *) | Yes |
| 7 | Fail closed when a policy script breaks | PreToolUse | Bash, onFailure: block | Yes |
| 8 | Steer Claude to a better tool | PreToolUse | Bash, if: Bash(grep *) | Yes, with a reason |
| 9 | Test gate before Claude stops | Stop | None | Yes, Claude keeps working |
| 10 | Ask a model whether the task is done | Stop | None, type: prompt | Yes |
| 11 | Re-inject context after compaction | SessionStart | compact | No |
| 12 | Reload environment per directory | SessionStart, CwdChanged | None | No |
| 13 | Desktop notification | Notification | All types | No |
| 14 | Auto-approve leaving plan mode | PermissionRequest | ExitPlanMode | Answers the prompt for you |
| 15 | Warn before a costly model switch | PreModelSwitch | None | Asks you to confirm |
Every block below is a complete settings object. If your file already has a hooks key, add the event as a sibling of the ones already there instead of replacing the object. The command examples use jq to read the JSON input, so install it first.
After every edit: format, test and log (examples 1 to 3)
1. Format on save
Why it works: the matcher limits the hook to file-editing tools, and the command pulls the edited path out of the input and hands it to Prettier. Adapt it: swap Prettier for ruff format, gofmt -w or your own formatter.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write" }
]
}
]
}
}2. Run tests in the background after edits
Why it works: async: true lets Claude keep working while the suite runs, and the script reports the result back through additionalContext on the next turn. Adapt it: change the file extensions and the test command.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/run-tests-async.sh",
"args": [],
"async": true
}
]
}
]
}
}#!/bin/bash
# .claude/hooks/run-tests-async.sh
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# Only run tests for source files
if [[ "$FILE_PATH" != *.ts && "$FILE_PATH" != *.js ]]; then
exit 0
fi
RESULT=$(npm test 2>&1)
EXIT_CODE=$?
if [ $EXIT_CODE -eq 0 ]; then
MSG="Tests passed after editing $FILE_PATH"
else
MSG="Tests failed after editing $FILE_PATH: $RESULT"
fi
jq -nc --arg msg "$MSG" '{hookSpecificOutput: {hookEventName: "PostToolUse", additionalContext: $msg}}'3. Log every Bash command
Why it works: PostToolUse fires after the command completes, so the log holds what really ran. Adapt it: match mcp__github__.* instead to log calls to one MCP server; our MCP guide explains that tool naming.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "jq -r '.tool_input.command' >> ~/.claude/command-log.txt" }
]
}
]
}
}Before it runs: block risky actions (examples 4 to 8)
4. Block destructive commands
Why it works: exit code 2 on PreToolUse cancels the tool call, and the stderr line tells Claude why, so it changes approach instead of retrying. The docs note that a PreToolUse deny holds even in bypassPermissions mode. Adapt it: edit the pattern list.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-dangerous.sh" }
]
}
]
}
}#!/bin/bash
# .claude/hooks/block-dangerous.sh
COMMAND=$(jq -r '.tool_input.command // empty')
if echo "$COMMAND" | grep -qiE 'rm -rf|git push .*--force|git reset --hard|drop table'; then
echo "Blocked: destructive command. Ask the user to run it themselves." >&2
exit 2
fi
exit 05. Protect .env and lock files
Why it works: the same pattern as example 4, bound to Edit|Write and checking tool_input.file_path. Adapt it: add migrations, generated files or anything else Claude should leave alone.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh" }
]
}
]
}
}#!/bin/bash
# .claude/hooks/protect-files.sh
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# Normalize Windows backslash separators so the patterns below match
FILE_PATH="${FILE_PATH//\\//}"
PROTECTED_PATTERNS=(".env" "package-lock.json" ".git/")
for pattern in "${PROTECTED_PATTERNS[@]}"; do
if [[ "$FILE_PATH" == *"$pattern"* ]]; then
echo "Blocked: $FILE_PATH matches protected pattern '$pattern'" >&2
exit 2
fi
done
exit 06. Test gate before commit
Why it works: the if field uses permission-rule syntax, so the hook process only starts when the Bash command is a git commit. Failing tests exit 2 and the commit never runs. Adapt it: use your own test command, or a faster lint. The docs call the if filter best-effort, so treat it as a filter, not a security boundary.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(git commit *)",
"command": "npm test > /dev/null 2>&1 || { echo 'Tests are failing. Fix them before committing.' >&2; exit 2; }"
}
]
}
]
}
}7. Fail closed when the policy script breaks
Why it works: by default a hook that crashes or has a wrong path lets everything through. onFailure: "block" turns a failed hook into a block. It needs Claude Code 2.1.295 or later. Adapt it: add the field to any guard you rely on.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "node",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/check-command.js"],
"onFailure": "block"
}
]
}
]
}
}8. Steer Claude to a better tool
Why it works: instead of an exit code, the hook prints a JSON decision. permissionDecision: "deny" cancels the call and the reason goes straight to Claude, which then uses the tool you named. Adapt it: change the if pattern and the reason, for example to push npm calls towards pnpm.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(grep *)",
"command": "echo '{\"hookSpecificOutput\": {\"hookEventName\": \"PreToolUse\", \"permissionDecision\": \"deny\", \"permissionDecisionReason\": \"Use rg instead of grep for better performance\"}}'"
}
]
}
]
}
}At the finish line: gate the stop (examples 9 and 10)
9. Test gate before Claude stops
Why it works: a Stop hook that exits 2 sends Claude back to work with your stderr as the instruction. The stop_hook_active check prevents a loop; Claude Code also overrides a Stop hook after eight consecutive blocks. Adapt it: swap the test command for a build or type check.
{
"hooks": {
"Stop": [
{
"hooks": [
{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/tests-before-stop.sh" }
]
}
]
}
}#!/bin/bash
# .claude/hooks/tests-before-stop.sh
INPUT=$(cat)
# Already continuing because of this hook: let Claude stop
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
exit 0
fi
if ! npm test > /dev/null 2>&1; then
echo "Tests are failing. Fix them before finishing." >&2
exit 2
fi
exit 010. Ask a model whether the task is done
Why it works: a prompt hook sends the hook input to a Claude model, which answers ok: true or ok: false with a reason; a false sends Claude back with that reason. Adapt it: list your own completion criteria in the prompt. For checks that need to read files or run commands, the docs offer type: "agent" hooks, marked experimental.
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "prompt",
"prompt": "Check if all tasks are complete. If not, respond with {\"ok\": false, \"reason\": \"what remains to be done\"}."
}
]
}
]
}
}Context and environment (examples 11 and 12)
11. Re-inject context after compaction
Why it works: SessionStart fires again after a compaction with the matcher value compact, and plain text on stdout is added to Claude's context. Adapt it: replace the echo with git log --oneline -5 or any command that prints current state.
{
"hooks": {
"SessionStart": [
{
"matcher": "compact",
"hooks": [
{
"type": "command",
"command": "echo 'Reminder: use Bun, not npm. Run bun test before committing. Current sprint: auth refactor.'"
}
]
}
]
}
}12. Reload environment variables per directory
Why it works: both events write to CLAUDE_ENV_FILE, which Claude Code runs before each Bash command, so variables follow Claude as it changes directory. Adapt it: use devbox shellenv in place of direnv.
{
"hooks": {
"SessionStart": [
{ "hooks": [{ "type": "command", "command": "direnv export bash > \"$CLAUDE_ENV_FILE\"" }] }
],
"CwdChanged": [
{ "hooks": [{ "type": "command", "command": "direnv export bash > \"$CLAUDE_ENV_FILE\"" }] }
]
}
}Notifications, permissions and cost (examples 13 to 15)
13. Desktop notification when Claude needs you
Why it works: the Notification event fires when Claude is waiting for input or permission. This is the macOS version; on Linux use notify-send 'Claude Code' 'Claude Code needs your attention'. Adapt it: set the matcher to permission_prompt or idle_prompt to narrow it. If you would rather answer from your phone, see our guide to Claude Code remote control.
{
"hooks": {
"Notification": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification \"Claude Code needs your attention\" with title \"Claude Code\"'"
}
]
}
]
}
}14. Auto-approve leaving plan mode
Why it works: a PermissionRequest hook that returns behavior: "allow" answers the dialog for you, and the matcher keeps it to one tool. Adapt it: carefully. An empty matcher here would approve every permission prompt, including shell commands.
{
"hooks": {
"PermissionRequest": [
{
"matcher": "ExitPlanMode",
"hooks": [
{
"type": "command",
"command": "echo '{\"hookSpecificOutput\": {\"hookEventName\": \"PermissionRequest\", \"decision\": {\"behavior\": \"allow\"}}}'"
}
]
}
]
}
}15. Warn before a costly model switch
Why it works: PreModelSwitch receives context_tokens, the size of the conversation that would be re-sent to the new model, and "ask" turns it into a confirmation when you run /model. It needs Claude Code 2.1.251 or later, and only /model can show the prompt: switches made from /config or by an SDK client are refused while the hook answers "ask". Adapt it: quote estimated_cache_write_usd instead to show a dollar estimate. This is as close as hooks get to a cost logger, because hook input carries no running token total.
{
"hooks": {
"PreModelSwitch": [
{
"hooks": [
{
"type": "command",
"command": "jq -c '{hookSpecificOutput: {hookEventName: \"PreModelSwitch\", permissionDecision: \"ask\", permissionDecisionReason: (\"Switching re-sends about \" + (.context_tokens | tostring) + \" tokens to the new model. Continue?\")}}'"
}
]
}
]
}
}Claude Code hooks reference: exit codes and where config lives
Two tables answer most questions before you open the full hooks reference. First, what your exit code means:
| Exit code | Effect | Watch for |
|---|---|---|
| 0 | No objection. On PreToolUse the normal permission flow still applies | Stdout becomes context on SessionStart and UserPromptSubmit |
| 2 | Block. Stderr goes to Claude or to you, depending on the event | Cannot block what already happened, such as PostToolUse |
| Anything else | Non-blocking error. The action goes ahead | Exit 1 does not block. Use 2, or set onFailure to block |
Second, where the config goes. Hooks from every level merge rather than replace each other, and /hooks shows the combined list.
| Location | Scope | Good for |
|---|---|---|
| ~/.claude/settings.json | All your projects | Personal hooks: notifications, logging |
| .claude/settings.json | One project, committed | Team rules: formatters, guards, gates |
| .claude/settings.local.json | One project, not shared | Experiments before you commit them |
| Subagent or skill frontmatter | While that subagent or skill is active | Rules for one role only |
Hooks in settings files also run inside subagents, and a subagent can carry its own hooks in frontmatter. Our guide to Claude Code subagents covers the agent files those hooks attach to. Hooks can also post to a URL with type: "http"; if the receiving end is a workflow tool, the n8n webhook trigger guide covers catching the request.
Claude Code hooks best practices
Hooks run with your full user permissions, so a careless one is a risk in its own right. The difference between a hook that helps and one that fails silently is usually one of these five things.
- Exits 1 and expects the command to be blocked
- Empty matcher on a PermissionRequest hook
- Builds JSON output by string concatenation
- Stop hook with no stop_hook_active check
- Relative script path that breaks after a cd
- Exits 2 with a one-line reason on stderr
- Narrowest matcher plus an if filter
- Builds JSON with jq so quotes are escaped
- Exits 0 early when stop_hook_active is true
- Path starts with "$CLAUDE_PROJECT_DIR"
- It appears under the right event in /hooks
- You piped sample JSON into the script and checked the exit code
- Shell variables are quoted: "$VAR", not $VAR
- Guards exit 2, and the important ones set onFailure to block
- The script is executable and uses an absolute or project-rooted path
- It skips or protects .env, .git/ and key files
- It finishes fast, or runs with async: true
- A teammate reviewed it; hooks in a repo run for everyone who trusts the folder
One rule from the security section deserves its own line. In an interactive session Claude Code holds hooks back until you trust the folder, but a claude -p run treats the folder as trusted. Before scripting Claude over a repository you did not write, read its .claude/ settings, or pass --settings '{"disableAllHooks": true}' for that run.
Build your own hook in six steps
- 1Name the moment
Before a tool call, after one, at the stop or at session start. That picks the event.
- 2Narrow the matcher
One tool name, or a short list. Add an if filter for specific commands.
- 3Write the script
Read stdin with jq, decide, exit 0 or 2. Keep it under 20 lines.
- 4Test it by hand
Pipe sample JSON in and check the exit code before Claude ever runs it.
- 5Register and verify
Add it to settings.json, open /hooks and trigger it once.
- 6Watch the first runs
Press Ctrl+O for the transcript view, or start with claude --debug.
Hooks are the enforcement layer of a larger workflow. Our AI SaaS Builder program covers the rest of it: how specs, subagents, hooks and review fit together when you are shipping a product with Claude Code rather than tidying a repo.
Claude Code hooks: FAQ
What are Claude Code hooks?
Hooks are actions Claude Code runs automatically at fixed points in a session, such as before a tool call, after a file edit or when Claude finishes responding. Most are shell commands, though a hook can also call an HTTP endpoint, an MCP tool or a Claude model. Because Claude Code runs them itself, they happen every time, instead of depending on the model remembering an instruction.
Where do I put Claude Code hooks?
Add a hooks object to a settings file. Use ~/.claude/settings.json for hooks that apply to all your projects, .claude/settings.json for project hooks you commit and share, and .claude/settings.local.json for project hooks you keep to yourself. Hooks can also live in plugin, skill and subagent files. Type /hooks inside Claude Code to see every hook that is loaded and where it comes from.
How do I block a command with a Claude Code hook?
Register a PreToolUse hook with a matcher for the tool, read the JSON input from stdin, and exit with code 2 when the command should not run. Whatever you write to stderr is sent back to Claude as the reason. Exit code 1 does not block; Claude Code treats it as a non-blocking error and runs the command anyway.
What is the difference between PreToolUse and PostToolUse hooks?
PreToolUse fires before a tool call executes and can block it, allow it, ask for confirmation or rewrite its input. PostToolUse fires after the call succeeded, so it cannot undo anything; use it for formatting, logging, running tests or adding context for Claude. If you need to stop an action, it has to be a PreToolUse hook.
Why is my Claude Code hook not firing?
Run /hooks and check that it appears under the right event. Then check the matcher: it is case-sensitive and must match the tool name exactly. Make sure the settings file is valid JSON with no trailing commas, the script is executable, and paths are absolute or use CLAUDE_PROJECT_DIR. Starting Claude Code with claude --debug writes each hook run to the debug log.
Can a Claude Code hook log token usage or cost?
Not per turn. Hook input does not carry a running token or cost total. Two events include estimates: PreModelSwitch, and SessionStart on a resumed session, both pass context_tokens and estimated_cache_write_usd, which is enough to warn before an expensive switch or resume. For actual usage, run /usage inside the session.
Do Claude Code hooks work on Windows?
Yes. Command hooks run in Bash by default when Git Bash is installed, and you can set "shell": "powershell" on a hook to run it in PowerShell instead. In PowerShell hooks, reference the project root as ${CLAUDE_PROJECT_DIR} or $env:CLAUDE_PROJECT_DIR, not the bare $CLAUDE_PROJECT_DIR spelling, which PowerShell reads as an undefined variable.
Your guardrails are in. Now build something behind them.
AI SaaS Builder, included in All Access, takes a Claude Code setup like this one through a full product build: specs, review loops, Supabase, Next.js and payments, with the other three programs, live coaching and the private community in one subscription.
Got a hook worth sharing?
Join the free Discord to swap hooks, subagent files and CLAUDE.md setups with other builders.