Most developers use Claude Code the same way they use a chat app. They type a prompt, read the response, and manually decide what to do next. That approach works, but it leaves most of Claude Code's power sitting idle.
Claude Code hooks are the feature that changes this entirely. Hooks are shell commands that fire automatically at specific moments in Claude Code's lifecycle: before a tool runs, after a file is written, when you submit a prompt, when Claude finishes a task, or when a notification is triggered. They require no prompt, no manual intervention, and no AI judgment. They simply execute, every single time, without exception.
Related Course on Vibe Coding Academy

That determinism is the point. According to a February 2026 survey of 15,000 developers by The Pragmatic Engineer, 73% of engineering teams now use AI coding tools daily. But adoption alone does not translate into consistent output quality. Hooks are the mechanism that closes the gap between "Claude usually formats the code correctly" and "the code is always formatted, without exception."
This guide covers everything: what hooks are, every hook event and when it fires, the JSON contract hooks use to talk to Claude Code, a cookbook of 18 ready-to-copy recipes, and a full troubleshooting section for when a hook refuses to fire. Every event name, field, and exit code in this guide is verified against the official Claude Code documentation.
Key Takeaways
- Claude Code hooks are deterministic shell commands that fire automatically at specific lifecycle events, unlike CLAUDE.md instructions, they execute every time without exception.
- The five hook types (PreToolUse, PostToolUse, Notification, Stop, SubagentStop) cover the full lifecycle of an AI coding session, from intercepting dangerous commands to auto-running tests.
- Hooks are configured in .claude/settings.json files at three scope levels: global, project-shared, and project-local, making them easy to standardize across teams.
- Exit code 2 in PreToolUse hooks blocks tool execution and sends feedback to Claude, enabling safety guards that prevent destructive commands like rm -rf or DROP TABLE.
- Hooks and MCP servers serve complementary roles: MCP servers add new capabilities to Claude, while hooks enforce behavior guarantees on actions Claude already takes.
- Combining multiple hook recipes (auto-format, lint, test, notify) creates a fully automated CI-like pipeline that runs inside every Claude Code session.
Learn this hands-on
Become a 10x PM by learning how to use Claude Code in your daily work as a Product Manager, through 3 highly efficient live sessions of 1h30. Join the Claude Code for PMs live cohort.
What Are Claude Code Hooks?
Think of hooks as the invisible assistant that runs in the background while Claude Code works. Every time Claude uses a tool (editing a file, running a shell command, fetching data), hooks can intercept that action, react to it, or block it entirely.
Here is the key difference between hooks and a CLAUDE.md instruction file:
- A CLAUDE.md instruction like "always run Prettier after editing files" is a suggestion. Claude interprets it, sometimes follows it, and occasionally forgets it when the context window fills up.
- A PostToolUse hook that runs
prettier --writeafter every Edit is a guarantee. It does not care what Claude was thinking. It runs.
This distinction matters enormously in production workflows. Hooks are not prompts, they are pipeline steps.
As Vercel CEO Guillermo Rauch puts it, "The best developer tools don't just make individual tasks faster, they make entire workflows disappear. The future belongs to teams that automate the mundane and focus human creativity where it matters most." Claude Code hooks embody this philosophy, they automate the repetitive quality checks so developers can focus on building features.
Where Hooks Live
Hooks are configured in JSON settings files. You have three main options:
| File | Scope |
|---|---|
~/.claude/settings.json | All projects on your machine |
.claude/settings.json | This project only (commit to share with team) |
.claude/settings.local.json | This project only (never committed) |
Hooks can also ship inside plugins (via a hooks/hooks.json file) and inside skill or agent frontmatter, where they only stay active while that component is in use. For your first hooks, stick to the three settings files above.
The Anatomy of a Hook
The basic configuration structure looks like this:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
}
]
}
]
}
}
Each hook entry has three parts:
- The event (e.g.,
PostToolUse): when the hook fires - The matcher: a filter that decides which tools or contexts trigger it (
Edit|Writemeans "only Edit and Write tool calls") - The command: the shell command or script to execute
One detail that trips up almost everyone: hooks do not receive their data through special environment variables. Claude Code passes the event data as JSON on stdin. That is why the command above pipes through jq -r '.tool_input.file_path' to extract the file path before handing it to Prettier. If you see a tutorial using variables like $CLAUDE_TOOL_INPUT_FILE_PATH, it is outdated: parse stdin instead. The one environment variable you will use constantly is $CLAUDE_PROJECT_DIR, which always points at your project root.
Every Hook Event, Explained
Claude Code supports a large set of hook events covering the full lifecycle of an AI coding session. The nine below are the ones you will actually use day to day.
| Event | Fires | Can block? |
|---|---|---|
SessionStart | When a session starts, resumes, is cleared, or compacts | No |
UserPromptSubmit | When you submit a prompt, before Claude sees it | Yes |
PreToolUse | Before any tool call executes | Yes |
PostToolUse | After a tool call succeeds | No (tool already ran) |
Notification | When Claude Code sends a notification (e.g., waiting for permission) | No |
Stop | When Claude finishes responding | Yes |
SubagentStop | When a subagent finishes | Yes |
PreCompact | Before context compaction | Yes |
SessionEnd | When the session terminates | No |
The full list is longer (recent versions added events like PermissionRequest, PostToolUseFailure, SubagentStart, ConfigChange, FileChanged, and TeammateIdle for agent teams), but the nine above cover the overwhelming majority of real-world automations. Let's walk through each.
1. PreToolUse
PreToolUse fires before Claude executes any tool. This is your opportunity to intercept, validate, or block an action before it happens.
Common use cases:
- Block dangerous shell commands (
rm -rf,DROP TABLE,git push --force) - Protect sensitive files (
.env, lockfiles, migrations) from edits - Log every command Claude attempts to an audit trail
A crucial security property: PreToolUse hooks fire before any permission check. A hook that denies a tool call blocks it even in bypassPermissions mode. Hooks can tighten restrictions that no permission mode can loosen.
2. PostToolUse
PostToolUse fires after a tool has successfully executed. The hook receives both the tool's input and its output (tool_response), making this the right place for quality enforcement and side effects.
Common use cases:
- Auto-format files after every edit
- Run linting or type-checking on modified files
- Trigger tests when source files change
- Log every file change
PostToolUse cannot un-run the tool, but exiting with code 2 sends your stderr back to Claude as feedback, which is how you build "fix the lint errors you just introduced" loops.
3. UserPromptSubmit
UserPromptSubmit fires when you submit a prompt, before Claude processes it. Anything the hook writes to stdout gets added to Claude's context, and exit code 2 blocks the prompt entirely.
Common use cases:
- Inject the current date, sprint context, or environment info into every request
- Block prompts that accidentally contain API keys or secrets
- Enforce prompt conventions on shared team machines
4. Notification
The Notification hook fires when Claude Code sends a notification, most commonly when it is waiting for permission to run a command (permission_prompt) or idle waiting for input (idle_prompt). Instead of checking your terminal every 30 seconds, you can pipe these notifications to Slack, a desktop alert, or a custom webhook.
Common use cases:
- Desktop or Slack alert when Claude is blocked and waiting for you
- Log permission requests to a central team channel
- Trigger a phone notification during long-running tasks
5. Stop
The Stop hook fires when Claude Code finishes a response and is ready for your next input. This is the "end of turn" event. It fires on every completed response, not just at task completion, and it does not fire when you interrupt Claude.
Common use cases:
- Run the test suite after Claude completes a feature
- Speak a text-to-speech announcement so you can work in another window
- Automatically commit completed work to a staging branch
The Stop hook can return {"decision": "block", "reason": "..."}, which forces Claude to continue working instead of stopping. This is how you implement an "auto-continue until tests pass" pattern (recipe 14 below).
6. SubagentStop
SubagentStop is the same as Stop, but fires when a subagent (spawned via the Task tool) finishes. Its matcher filters by agent type, so you can target specific subagents.
Common use cases:
- Validate each subagent's output before the parent agent proceeds
- Aggregate results from parallel subagents into a single log
- Block a subagent from completing if its output fails a quality check
7. SessionStart
SessionStart fires when a session begins, and its matcher tells you how: startup, resume, clear, or compact. Anything written to stdout is injected into Claude's context, which makes it perfect for giving Claude situational awareness before the first prompt.
Common use cases:
- Inject git status and recent commits at startup
- Re-inject project conventions after compaction (matcher:
compact) - Load environment context that CLAUDE.md cannot compute dynamically
8. PreCompact
PreCompact fires right before Claude Code compacts the context window (matcher: manual or auto). Since compaction summarizes and discards detail, this is your chance to save state first.
Common use cases:
- Back up the transcript before it gets summarized
- Log compaction frequency to spot sessions that run too long
9. SessionEnd
SessionEnd fires when the session terminates, with a matcher telling you why (clear, logout, prompt_input_exit, and so on). It cannot block anything; it is for cleanup and logging.
Common use cases:
- Log session duration for time tracking
- Clean up temp files or stop dev servers the session spawned
How Hooks Communicate: The JSON Contract
Hooks talk to Claude Code through four channels: stdin, stdout, stderr, and exit codes. Get this contract right and everything else is just shell scripting.
Input: JSON on stdin
When an event fires, Claude Code writes a JSON payload to your command's stdin. Every event includes common fields, and each event type adds its own:
{
"session_id": "abc123",
"transcript_path": "/path/to/transcript.jsonl",
"cwd": "/Users/you/myproject",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {
"command": "npm test"
}
}
The event-specific fields you will use most:
| Event | Key fields |
|---|---|
PreToolUse / PostToolUse | tool_name, tool_input (e.g., tool_input.command for Bash, tool_input.file_path for Edit/Write) |
PostToolUse | also tool_response (the tool's output) |
UserPromptSubmit | prompt (the text you typed) |
Stop / SubagentStop | stop_hook_active (true if a Stop hook already forced a continuation) |
SessionStart | source (startup, resume, clear, compact) |
Notification | message and the notification type |
The standard pattern for reading it in bash:
#!/bin/bash
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command')
Install jq if you do not have it (brew install jq on macOS, apt-get install jq on Debian/Ubuntu). Python's json.load(sys.stdin) works just as well.
Output: exit codes
Your script's exit code tells Claude Code what to do next:
- Exit 0: no objection, the action proceeds. For
UserPromptSubmitandSessionStart, anything on stdout is added to Claude's context. - Exit 2: block the action. Write the reason to stderr, and Claude receives it as feedback so it can adjust. This works on blockable events (
PreToolUse,UserPromptSubmit,Stop,SubagentStop,PreCompact). On non-blockable events likePostToolUse, exit 2 cannot undo the tool call but still shows your stderr to Claude. - Any other exit code: non-blocking error. The action proceeds and the error is logged.
Output: structured JSON
Exit codes only let you block or stay silent. For finer control, exit 0 and print a JSON object to stdout:
{
"decision": "block",
"reason": "Tests are failing. Fix them before finishing."
}
The most useful fields:
"decision": "block"with a"reason": blocks the action and feeds the reason to Claude (works onStop,SubagentStop,PostToolUse,UserPromptSubmit)"continue": falsewith a"stopReason": stops Claude entirely after the hook"suppressOutput": true: hides the hook's stdout from the transcript"systemMessage": shows a warning message to the user
PreToolUse hooks get an extra, more precise mechanism via hookSpecificOutput:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Force pushes are not allowed on this repo."
}
}
permissionDecision accepts allow (skip the permission prompt), deny (block the call and tell Claude why), or ask (force a manual confirmation).
Matcher syntax
Matchers filter when a hook group fires. Three rules cover everything:
"*","", or omitting the matcher entirely matches everything.- Plain names match exactly and are case-sensitive:
Bash,Edit|Write(a pipe-separated list).bashwill never match. - Anything with regex characters is evaluated as a regular expression:
^Notebook,mcp__memory__.*.
For tool events, the matcher filters on tool name. For other events it filters on the event's context: SessionStart matches on startup/resume/clear/compact, Notification matches on the notification type like permission_prompt, and SubagentStop matches on the agent type. Stop and UserPromptSubmit do not support matchers at all; they always fire.
MCP tools follow the naming pattern mcp__<server>__<tool>, so mcp__github__.* targets every tool from your GitHub MCP server.
Step-by-Step Setup Guide
Here is how to configure your first hook from scratch. This example sets up auto-formatting on every file edit.
Step 1: Open or create your settings file
Navigate to your project root and open (or create) .claude/settings.json:
mkdir -p .claude && touch .claude/settings.json
Step 2: Add the hook configuration
Paste the following into the file:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
}
]
}
]
}
}
Step 3: Verify the hook is active
Type /hooks inside Claude Code to open the read-only hook browser. You will see your configured hook listed with its event type, matcher, and command. If it appears here, it is active. Settings file edits are normally picked up automatically within a few seconds; if the hook does not appear, restart the session to force a reload.
Step 4: Test it
Ask Claude Code to edit any file in your project. After the edit completes, check the file: it should be formatted by Prettier automatically, with no prompt or manual action from you.
That is the entire setup process. Every recipe in the cookbook below follows the same pattern.
The Hook Recipes Cookbook: 18 Copy-Paste Recipes
These recipes cover the most common automation patterns, organized by what they do: code quality, guard rails, notifications, context injection, and workflow control. Copy them directly into .claude/settings.json, adjusting paths and commands for your project. Recipes that need more than a one-liner use a script in .claude/hooks/; remember to chmod +x those scripts.
Code quality recipes
Recipe 1: Auto-format on every edit (Prettier)
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
}
]
}
]
}
}
Fires after every file write or edit. Keeps all AI-generated code consistently formatted with zero manual intervention.
Recipe 2: Language-aware formatting (multi-language projects)
Save as .claude/hooks/format.sh:
#!/bin/bash
FILE_PATH=$(jq -r '.tool_input.file_path // empty')
[ -z "$FILE_PATH" ] && exit 0
case "$FILE_PATH" in
*.ts|*.tsx|*.js|*.jsx|*.json|*.css) npx prettier --write "$FILE_PATH" ;;
*.py) black "$FILE_PATH" 2>/dev/null ;;
*.go) gofmt -w "$FILE_PATH" ;;
*.rs) rustfmt "$FILE_PATH" 2>/dev/null ;;
esac
exit 0
Register it:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/format.sh"
}
]
}
]
}
}
One hook, every language in your monorepo formatted with the right tool.
Recipe 3: ESLint auto-fix after edits
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs -I {} sh -c 'npx eslint --fix \"{}\" 2>&1 || true'"
}
]
}
]
}
}
Runs ESLint with auto-fix after every edit. The || true prevents the hook from surfacing an error if ESLint finds unfixable issues: it corrects what it can and moves on.
Recipe 4: Lint gate that feeds errors back to Claude
The recipe above fixes silently. This one makes Claude aware of what it broke. Save as .claude/hooks/lint-gate.sh:
#!/bin/bash
FILE_PATH=$(jq -r '.tool_input.file_path // empty')
case "$FILE_PATH" in
*.ts|*.tsx|*.js|*.jsx) ;;
*) exit 0 ;;
esac
ERRORS=$(npx eslint "$FILE_PATH" 2>&1)
if [ $? -ne 0 ]; then
echo "ESLint found problems in $FILE_PATH:" >&2
echo "$ERRORS" | head -20 >&2
exit 2
fi
exit 0
Register it as a PostToolUse hook on Edit|Write (same JSON as recipe 2, pointing at lint-gate.sh). On a PostToolUse event, exit code 2 cannot undo the edit, but the stderr goes straight into Claude's context, so Claude sees the exact errors and fixes them in its next action.
Recipe 5: TypeScript type-check after edits
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "cd \"$CLAUDE_PROJECT_DIR\" && npx tsc --noEmit 2>&1 | head -20 || true"
}
]
}
]
}
}
Runs TypeScript's type checker after every edit and surfaces errors in the transcript. On large codebases, consider scoping this to a Stop hook instead so it runs once per turn rather than once per edit.
Recipe 6: Auto-run tests when source files change
Save as .claude/hooks/test-on-edit.sh:
#!/bin/bash
FILE_PATH=$(jq -r '.tool_input.file_path // empty')
case "$FILE_PATH" in
*/src/*.ts|*/src/*.tsx)
cd "$CLAUDE_PROJECT_DIR" && npx vitest related "$FILE_PATH" --run 2>&1 | tail -15
;;
esac
exit 0
Register as a PostToolUse hook on Edit|Write. Runs only the tests related to the file Claude just touched (Vitest's related mode; Jest has --findRelatedTests). Failures land in the transcript where Claude can see and fix them.
Guard rail recipes
Recipe 7: Block dangerous shell commands
Save as .claude/hooks/block-dangerous.sh:
#!/bin/bash
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
DANGEROUS=("rm -rf /" "rm -rf ~" "DROP TABLE" "DROP DATABASE" "git reset --hard" "mkfs" "> /dev/sda")
for pattern in "${DANGEROUS[@]}"; do
if echo "$COMMAND" | grep -qi "$pattern"; then
echo "Blocked dangerous command matching '$pattern': $COMMAND" >&2
exit 2
fi
done
exit 0
Register it:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-dangerous.sh"
}
]
}
]
}
}
Intercepts every Bash tool call before it runs. Exit code 2 blocks execution and sends the reason back to Claude, so it understands why and picks a safer approach. Because PreToolUse fires before permission checks, this holds even in bypassPermissions mode.
Recipe 8: Protect sensitive files from edits
Save as .claude/hooks/protect-files.sh:
#!/bin/bash
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
PROTECTED_PATTERNS=(".env" "package-lock.json" "pnpm-lock.yaml" ".git/" "migrations/")
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 0
Register as a PreToolUse hook with matcher Edit|Write. Claude receives the block reason as feedback, so instead of retrying blindly it will tell you it cannot touch the file and why.
Recipe 9: Git guard rails (no force pushes, no pushing to main)
Save as .claude/hooks/git-guard.sh:
#!/bin/bash
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
if echo "$COMMAND" | grep -qE 'git push.*(--force|-f)\b'; then
echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"Force pushes are not allowed. Use a regular push or open a PR."}}'
exit 0
fi
if echo "$COMMAND" | grep -qE 'git push.*\b(origin )?(main|master)\b'; then
echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"Pushing to main. Confirm manually."}}'
exit 0
fi
exit 0
Register as a PreToolUse hook with matcher Bash. This one uses structured JSON output instead of exit codes: force pushes are denied outright, while pushes to main downgrade to ask, forcing a manual confirmation even if you previously allowed git commands.
Recipe 10: Auto-commit checkpoint after every edit
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "cd \"$CLAUDE_PROJECT_DIR\" && git add -A && git commit -m \"checkpoint: claude edit $(date +%H:%M:%S)\" --no-verify --quiet || true",
"async": true
}
]
}
]
}
}
Creates an automatic git commit after every file change, giving you a restore point for every step of a long refactor. The "async": true flag runs it in the background without slowing Claude down. Best used on a scratch branch; squash before merging.
Notification recipes
Recipe 11: Desktop notification when Claude needs you
macOS:
{
"hooks": {
"Notification": [
{
"matcher": "permission_prompt|idle_prompt",
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification \"Claude Code needs your attention\" with title \"Claude Code\"'"
}
]
}
]
}
}
On Linux, swap the command for notify-send 'Claude Code' 'Claude Code needs your attention'. The matcher fires on permission prompts and idle waits, the two moments where Claude is blocked on you.
Recipe 12: Slack message when Claude is waiting
{
"hooks": {
"Notification": [
{
"matcher": "permission_prompt",
"hooks": [
{
"type": "command",
"command": "curl -s -X POST \"$SLACK_WEBHOOK_URL\" -H 'Content-type: application/json' -d '{\"text\":\"Claude Code is waiting for permission. Check your terminal.\"}' > /dev/null",
"async": true
}
]
}
]
}
}
Any time Claude Code needs permission to run a command, you get a Slack message. Eliminates the need to babysit the terminal during long sessions. Note that hooks do not automatically inherit your shell exports, so put SLACK_WEBHOOK_URL somewhere the hook can read it, or hardcode the webhook in a script under .claude/hooks/.
Recipe 13: Make Claude speak when it finishes (TTS)
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "say 'Claude is done'",
"async": true
}
]
}
]
}
}
macOS's built-in say command announces out loud that Claude finished its turn, so you can work in another window and never miss a completion. On Linux, use espeak or pipe to a TTS service. For a fancier version, extract last_assistant_message from the stdin JSON and speak a summary of what Claude actually did.
Context injection recipes
Recipe 14: Inject git status at session start
{
"hooks": {
"SessionStart": [
{
"matcher": "startup",
"hooks": [
{
"type": "command",
"command": "cd \"$CLAUDE_PROJECT_DIR\" && echo '=== Git status ===' && git status --short && echo '=== Recent commits ===' && git log --oneline -5"
}
]
}
]
}
}
Every time you open a Claude Code session, the current git status and recent commits are injected into context. Claude starts every session with full awareness of the repository state. SessionStart stdout goes directly into Claude's context, which is what makes this work.
Recipe 15: Re-inject project rules after compaction
{
"hooks": {
"SessionStart": [
{
"matcher": "compact",
"hooks": [
{
"type": "command",
"command": "echo 'Reminder: use pnpm, not npm. Run pnpm test before declaring anything done. Never edit files under migrations/.'"
}
]
}
]
}
}
When the context window fills up, Claude Code compacts the conversation into a summary, and details get lost. This hook re-injects your non-negotiable rules immediately after every compaction, which is exactly the moment Claude is most likely to forget them.
Recipe 16: Add the current date and context to every prompt
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "echo \"Current date: $(date +%Y-%m-%d). Current branch: $(git -C \"$CLAUDE_PROJECT_DIR\" branch --show-current 2>/dev/null)\""
}
]
}
]
}
}
UserPromptSubmit stdout is appended to Claude's context before it processes your prompt. No more "as of my knowledge cutoff" answers when you ask date-sensitive questions, and Claude always knows which branch it is on.
Workflow control recipes
Recipe 17: Block secrets from reaching Claude
Save as .claude/hooks/prompt-guard.sh:
#!/bin/bash
INPUT=$(cat)
PROMPT=$(echo "$INPUT" | jq -r '.prompt // empty')
if echo "$PROMPT" | grep -qE '(sk-[A-Za-z0-9]{20,}|AKIA[A-Z0-9]{16}|ghp_[A-Za-z0-9]{36})'; then
echo "Blocked: your prompt appears to contain an API key. Remove it and use an env var reference instead." >&2
exit 2
fi
exit 0
Register as a UserPromptSubmit hook (no matcher needed; it always fires). Exit code 2 blocks the prompt before Claude ever sees it and erases it from the conversation. The regexes above catch common OpenAI-style keys, AWS access keys, and GitHub tokens; extend the list for your stack.
Recipe 18: Refuse to finish while tests fail
Save as .claude/hooks/stop-gate.sh:
#!/bin/bash
INPUT=$(cat)
# Prevent infinite loops: allow stopping if this hook already forced a continuation
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
exit 0
fi
cd "$CLAUDE_PROJECT_DIR" || exit 0
if ! npm test --silent > /tmp/claude-test-output.txt 2>&1; then
FAILURES=$(tail -20 /tmp/claude-test-output.txt)
jq -n --arg reason "Tests are failing. Fix them before finishing:
$FAILURES" '{"decision": "block", "reason": $reason}'
exit 0
fi
exit 0
Register it:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/stop-gate.sh"
}
]
}
]
}
}
This is the most powerful pattern in the cookbook. When Claude tries to end its turn, the hook runs your test suite. If tests fail, {"decision": "block"} forces Claude to keep working, with the failure output as its instructions. The stop_hook_active check is essential: it lets Claude stop once it has already been sent back to work, preventing an infinite loop (Claude Code also enforces a cap of eight consecutive blocks as a backstop).
Hooks vs. MCP Servers: What Is the Difference?
Both hooks and MCP servers extend Claude Code's capabilities, but they serve fundamentally different purposes.
MCP servers give Claude Code access to external tools and data sources. They add new capabilities, the ability to query a database, read a Figma file, interact with Slack, or search the web. Claude decides when to use an MCP tool based on your prompts and the task at hand.
Hooks automate reactions to things Claude is already doing. They do not add new capabilities, they enforce behavior guarantees. A hook does not give Claude the ability to run Prettier; it ensures Prettier always runs, regardless of whether Claude thought to do it.
The practical rule of thumb:
- Use an MCP server when you want to give Claude access to something new.
- Use a hook when you want to guarantee something always happens.
They also compose well together. Hooks can target MCP tools directly with the matcher pattern mcp__<server>__<tool> (for example, mcp__github__.* matches every GitHub MCP tool), so you can validate inputs before an MCP tool call fires, or log every MCP call to your audit trail.
Combine Hooks Into a Full Automated Pipeline
The real power of Claude Code hooks emerges when you combine multiple recipes. Here is a production-ready configuration for a TypeScript project:
{
"hooks": {
"SessionStart": [
{
"matcher": "startup",
"hooks": [
{
"type": "command",
"command": "cd \"$CLAUDE_PROJECT_DIR\" && git status --short && git log --oneline -5"
}
]
}
],
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-dangerous.sh"
}
]
},
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh"
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/stop-gate.sh"
}
]
}
],
"Notification": [
{
"matcher": "permission_prompt",
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification \"Claude Code needs your attention\" with title \"Claude Code\"'",
"async": true
}
]
}
]
}
}
With this configuration in place, Claude Code starts every session context-aware, blocks dangerous commands and protected files automatically, formats every file it touches, refuses to finish a turn while tests fail, and pings you the moment it needs input. Commit .claude/settings.json and the .claude/hooks/ scripts to the repo, and every teammate gets the same guarantees.
Troubleshooting and FAQ
My hook is not firing at all
Work through this checklist in order:
- Check
/hooksfirst. Type/hooksinside Claude Code. If your hook is not listed there, Claude Code is not parsing it: your JSON is invalid (trailing commas and comments are not allowed), or the settings file is in the wrong location. Settings edits are normally picked up within a few seconds; if not, restart the session to force a reload. - Check the matcher. Matchers are case-sensitive exact matches on tool names:
Bashworks,bashdoes not.Edit|Writeis a list, and anything with regex characters is treated as a regex. - Check the event.
PreToolUsefires before the tool,PostToolUseafter it succeeds. If the tool call was blocked or failed, your PostToolUse hook never fires. - Check
disableAllHooks. Make sure it is not set totrueanywhere in your settings hierarchy.
The hook fires but the command fails
Test the script manually by piping sample JSON into it:
echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | ./my-hook.sh
echo $?
Common causes:
- "command not found": hooks run in a non-interactive shell without your full profile. Use absolute paths for binaries or reference scripts via
"$CLAUDE_PROJECT_DIR". - "jq: command not found": install jq, or parse with Python (
json.load(sys.stdin)). - The script never runs: make it executable with
chmod +x. - Missing env vars: hooks do not see everything your terminal exports. Source them explicitly at the top of your script:
source "$CLAUDE_PROJECT_DIR/.env".
How do I actually debug a hook?
Two tools. First, the transcript view (Ctrl+O) shows a one-line summary for each hook that fired: success is silent, blocking errors show stderr, non-blocking errors show a hook error notice. Second, for full details (which hooks matched, exit codes, stdout, stderr), start Claude Code with claude --debug, or use claude --debug-file /tmp/claude.log and tail -f /tmp/claude.log in another terminal. Already mid-session? Run /debug to enable logging and get the log path.
My hook outputs valid JSON but Claude Code shows a parse error
Your shell profile is polluting stdout. Shell-form hooks spawn sh -c (or Git Bash on Windows), and some configurations still source your profile. An unconditional echo "Shell ready" in .bashrc gets prepended to your hook's JSON and breaks parsing. Wrap profile echoes in an interactivity check: if [[ $- == *i* ]]; then echo "Shell ready"; fi.
My Stop hook loops forever (or hits a block cap)
Claude Code overrides a Stop hook after it blocks eight times in a row without progress. Always check the stop_hook_active field from stdin and exit 0 when it is true (see recipe 18). If a workflow legitimately needs more than eight iterations, raise the cap with the CLAUDE_CODE_STOP_HOOK_BLOCK_CAP environment variable.
My formatter hook triggers itself
A PostToolUse hook that rewrites files only fires on Claude's tool calls, not on changes your hook makes directly on disk, so a plain prettier --write will not loop. But if your hook does something that causes Claude to edit the file again (like a lint gate returning errors that make Claude re-edit, which triggers the gate again), make sure the loop converges: only report errors, never style nits that formatting already fixed.
Do hooks work with permission modes and --dangerously-skip-permissions?
Yes, and this is a feature. PreToolUse hooks run before any permission check, so a hook that returns permissionDecision: "deny" blocks the tool even in bypassPermissions mode. The reverse does not hold: a hook returning allow cannot loosen deny rules from settings. Hooks can only tighten, never weaken, your security posture.
Are hooks a security risk?
Hooks run arbitrary shell commands with your user account's full permissions, automatically, on every matching event. Treat them like CI configuration:
- Review every hook in
.claude/settings.jsonbefore trusting a cloned repo, exactly as you would review a Makefile or an npm postinstall script. - Always quote variables in scripts (
"$FILE_PATH", not$FILE_PATH) to avoid injection via crafted file names. - Keep secrets out of hook commands in committed settings files; load them from the environment or an uncommitted file instead.
- Enterprise admins can enforce
allowManagedHooksOnlyto block user and project hooks entirely on managed machines.
Can a hook modify what Claude is about to do, instead of just blocking it?
Yes. A PreToolUse hook can return hookSpecificOutput.updatedInput to rewrite the tool's arguments before execution (for example, rewriting npm to pnpm in every Bash command). If multiple hooks rewrite the same tool call, the last one to finish wins, so keep a single rewriting hook per tool.
Do I have to write shell scripts, or can hooks call other things?
Command hooks are the workhorse, but current versions of Claude Code also support http hooks (POST the event JSON to an endpoint and read the same JSON response format back), mcp_tool hooks (invoke a tool on a connected MCP server), and prompt or agent hooks that use a Claude model to make judgment calls that a regex cannot. Start with command hooks; reach for the others when you need them.
Start with One Hook
The most common mistake people make with Claude Code hooks is trying to configure everything at once. Start with a single PostToolUse hook that runs Prettier after file edits. Get comfortable with the feedback loop. Then add a guard rail. Then the Stop gate. Then notifications.
Each hook you add compounds the value of the one before it. Within a week of consistent use, your Claude Code workflow will be unrecognizable from what it was before, and the quality of AI-generated code in your projects will show it.
If you want to go deeper into automating your entire development workflow with Claude Code, including subagents, MCP servers, and project-level configuration patterns, the full Claude Code course walks through each piece with real projects.
