Claude Code hooks are user-defined shell commands that run at fixed points in Claude’s tool-and-conversation lifecycle. Unlike instructions in CLAUDE.md — which the model reads and usually follows — hooks are pipeline machinery that Claude cannot skip. This distinction matters: when you need something to happen every single time without exception, that’s what hooks are for.
This guide covers the full hook lifecycle, configuration structure, and three patterns that most guides don’t cover: argument-level filtering with the if field, multi-hook composition, and prompt-based judgment hooks.
What Are Claude Code Hooks?
A hook is a command (or HTTP endpoint, or LLM evaluation) that Claude Code runs deterministically at a lifecycle event. Five event types you’ll use constantly:
| Event | When it fires | Can block? |
|---|---|---|
UserPromptSubmit | When you submit a prompt, before Claude processes it | Yes (exit 2) |
PreToolUse | Before a tool call executes | Yes (exit 2 or JSON deny) |
PostToolUse | After a tool call succeeds | Yes (exit 2 blocks the turn) |
Stop | When Claude finishes responding | Yes (forces Claude to keep working) |
Notification | When Claude sends a notification | No |
But there are 27+ events total. Beyond the common five, hooks exist for SessionStart, SessionEnd, ConfigChange, CwdChanged, FileChanged, SubagentStart, SubagentStop, PermissionRequest, PostToolBatch, WorktreeCreate, WorktreeRemove, and more. For the full list and matchers, see the hooks reference.
Hook Configuration
Hooks live inside a hooks object in any Claude Code settings file. Three scope levels:
// ~/.claude/settings.json — applies to all your projects
// .claude/settings.json — single project, committable to git
// .claude/settings.local.json — single project, gitignored
The structure is always the same: event name → array of matcher/hooks groups:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
}
]
}
]
}
}
The matcher field filters which tool calls trigger the hook. For PreToolUse and PostToolUse, it matches the tool name. For SessionStart, it matches the start reason (startup, resume, compact). For Notification, it matches notification type (idle_prompt, permission_prompt). Empty string means “match everything.”
Run /hooks inside Claude Code to browse configured hooks by event and verify they registered correctly.
UserPromptSubmit: Intercept Before Claude Reads It
UserPromptSubmit fires when you hit Enter, before Claude processes the prompt. Two practical uses: blocking inputs and injecting context.
Block prompts matching a pattern:
#!/bin/bash
INPUT=$(cat)
PROMPT=$(echo "$INPUT" | jq -r '.prompt')
if echo "$PROMPT" | grep -qi "drop table\|truncate\|delete from"; then
echo "SQL destructive operation detected. Use the migration workflow instead." >&2
exit 2
fi
exit 0
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/validate-prompt.sh"
}
]
}
]
}
}
Inject context on every prompt:
When a UserPromptSubmit hook exits 0 and writes to stdout, that output is added to Claude’s context as a system reminder. This is the mechanism for automatic context injection:
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "echo \"Current branch: $(git branch --show-current). Sprint goal: $(cat .claude/sprint-goal.txt 2>/dev/null || echo 'not set')\""
}
]
}
]
}
}
Claude reads this on every prompt. Useful for keeping current branch, open ticket, or sprint context without repeating yourself.
Note: UserPromptSubmit has no matcher support — it fires on every prompt submission. There’s no way to filter by prompt content at the settings level; filtering must happen inside your script.
PreToolUse: Security Gates Before Execution
PreToolUse is the most widely used hook. It fires before a tool executes and a non-zero exit code blocks it entirely.
Block dangerous bash commands:
#!/bin/bash
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command')
DANGEROUS=(
"rm -rf /"
"rm -rf ~"
"git push --force origin main"
"git push -f origin main"
"git reset --hard HEAD~"
"chmod -R 777"
)
for pattern in "${DANGEROUS[@]}"; do
if echo "$COMMAND" | grep -qF "$pattern"; then
echo "Blocked: '$pattern' is on the disallowed list." >&2
exit 2
fi
done
exit 0
Protect sensitive files from edits:
#!/bin/bash
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
PROTECTED=(".env" ".env.production" "package-lock.json" ".git/" "*.pem" "*.key")
for pattern in "${PROTECTED[@]}"; do
case "$FILE_PATH" in
*"$pattern"*)
echo "Blocked: $FILE_PATH is protected. Edit through the secrets workflow." >&2
exit 2
;;
esac
done
exit 0
Register both against the relevant tools:
{
"hooks": {
"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"
}
]
}
]
}
}
The if Field: Argument-Level Filtering
Most hooks guides only show matcher, which filters at the tool-name level. Claude Code v2.1.85+ added the if field for filtering by tool arguments, so the hook process only spawns when the call matches a specific pattern.
This is the difference between “run on every Bash call” and “run only on git commands”:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(git push*)",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/check-push-policy.sh"
},
{
"type": "command",
"if": "Edit(*.sql)",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/validate-sql.sh"
}
]
}
]
}
}
The if pattern uses the same permission rule syntax as Claude Code’s permission system: ToolName(argument-glob). For Bash, it matches against each subcommand in the command string — including commands inside $() and backticks. For Edit and Write, it matches against the file path.
One important edge: if the pattern specifies more than just the command name (e.g., Bash(git push *)), it runs the hook regardless when it encounters shell substitutions it can’t parse. Design hooks to be idempotent in case they fire on unexpected inputs.
The if field is only valid on tool events: PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest, and PermissionDenied. Adding it to other events prevents the hook from running.
PostToolUse: Auto-Logging and Quality Gates
PostToolUse fires after a tool call succeeds. Since the tool already ran, you can’t undo its action — but you can log it, trigger side effects, or block the turn if the result doesn’t meet your standards.
Auto-format after every file edit:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write 2>/dev/null || true"
}
]
}
]
}
}
Log every bash command Claude runs:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "jq -c '{ts: now | todate, cmd: .tool_input.command, exit: .tool_response.exit_code}' >> ~/.claude/bash-audit.jsonl"
}
]
}
]
}
}
Run type-check after TypeScript edits:
#!/bin/bash
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
if [[ "$FILE_PATH" == *.ts || "$FILE_PATH" == *.tsx ]]; then
if ! npx tsc --noEmit --skipLibCheck 2>&1; then
echo "TypeScript errors detected. Fix them before proceeding." >&2
exit 2
fi
fi
exit 0
Exit code 2 from a PostToolUse hook blocks the turn and sends the stderr to Claude. Claude reads it as an error and tries again — this is the mechanism for a genuine quality gate.
Stop and Notification Hooks
Stop: Keep Claude Working Until Done
Stop fires when Claude finishes responding. Exit code 2 feeds the stderr back to Claude as its next instruction, keeping it working.
Block stop until tests pass:
#!/bin/bash
INPUT=$(cat)
# Prevent infinite loops — check if a Stop hook is already active
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
exit 0
fi
if ! npm test --silent 2>&1; then
echo "Tests are failing. Fix the failures before finishing." >&2
exit 2
fi
exit 0
The stop_hook_active field is critical. Without it, if tests keep failing, your Stop hook will trigger indefinitely. Claude Code automatically overrides a Stop hook after 8 consecutive blocks — but checking stop_hook_active is cleaner and avoids burning context.
SessionStart: Inject Context After Compaction
When Claude’s context window fills and compaction happens, important details can be lost. A SessionStart hook with compact matcher re-injects them:
{
"hooks": {
"SessionStart": [
{
"matcher": "compact",
"hooks": [
{
"type": "command",
"command": "echo 'Reminder: use pnpm not npm. Run pnpm test before committing. Current sprint: $(cat .claude/sprint.txt 2>/dev/null)'"
}
]
}
]
}
}
Notification: Get Alerted When Claude Needs You
{
"hooks": {
"Notification": [
{
"matcher": "idle_prompt",
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification \"Claude is done and waiting\" with title \"Claude Code\"'"
}
]
}
]
}
}
Matcher values for Notification: permission_prompt, idle_prompt, auth_success, elicitation_dialog, agent_needs_input, agent_completed. Empty string matches all.
For Linux: notify-send 'Claude Code' 'Waiting for input'. For Windows PowerShell: powershell.exe -Command "[System.Windows.Forms.MessageBox]::Show('Claude Code waiting', 'Claude Code')".
Multi-Hook Composition: Running Multiple Hooks in Parallel
When multiple hooks are configured for the same event, they all run in parallel. This is the part most guides skip.
After all hooks finish, Claude Code merges the results. For PreToolUse permission decisions, the priority order is: deny > defer > ask > allow. One deny wins, even if three other hooks return allow.
This example runs two PreToolUse hooks on Bash: one that logs every command and one that blocks dangerous patterns. They run concurrently:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.command' >> ~/.claude/command-log.txt"
},
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-dangerous.sh"
}
]
}
]
}
}
When Claude tries rm -rf /tmp/build, both hooks run. The logger writes the command and exits 0. The guard exits 2 and denies. The deny takes precedence — Claude Code blocks the command and shows Claude the guard’s stderr. The log entry is still written because the logging hook already ran.
One gotcha: when multiple PreToolUse hooks return updatedInput to rewrite tool arguments, the last one to finish wins. Since hooks run in parallel, the order is non-deterministic. Avoid having more than one hook modify the same tool’s input.
Prompt-Based and Agent-Based Hooks
Beyond shell commands, Claude Code supports two more hook types that most guides ignore.
Prompt Hooks: Claude Haiku as Your Gate
A type: "prompt" hook sends your prompt and the hook’s input to a Claude model (Haiku by default) to make a judgment call. The model returns {"ok": true} or {"ok": false, "reason": "..."}.
This is useful when the decision requires understanding, not just pattern matching:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "prompt",
"prompt": "Review the session transcript. If all the tasks the user originally requested are complete and working, respond {\"ok\": true}. If any task is incomplete or broken, respond {\"ok\": false, \"reason\": \"describe what still needs to be done\"}."
}
]
}
]
}
}
When the model returns "ok": false, Claude receives the reason as its next instruction and keeps working. The timeout for prompt hooks is 30 seconds.
Prompt hooks cost tokens (Haiku is cheap but not free). Use them for high-value gates — task completion verification, security review — not for every file edit.
Agent Hooks: Subagent Verification with Tool Access
A type: "agent" hook spawns a full subagent that can read files, run commands, and use tools before returning a decision. Up to 50 tool-use turns, 60-second default timeout.
Use agent hooks when verification requires inspecting actual state:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "agent",
"prompt": "Run the test suite with `npm test`. If all tests pass, return {\"ok\": true}. If any fail, return {\"ok\": false, \"reason\": \"list the failing test names and errors\"}.",
"timeout": 120
}
]
}
]
}
}
Agent hooks are experimental as of 2026 and may change. For production workflows, prefer command hooks.
Real-World Automation Patterns
Python Script for Complex Validation
When bash one-liners aren’t enough, use Python for structured JSON handling:
#!/usr/bin/env python3
"""PreToolUse hook: validate bash commands against a policy file."""
import json
import sys
import re
def main():
data = json.load(sys.stdin)
command = data.get("tool_input", {}).get("command", "")
blocked_patterns = [
r"rm\s+-rf\s+[/~]",
r"git\s+push\s+(-f|--force)\s+origin\s+main",
r"DROP\s+TABLE",
r"chmod\s+-R\s+777",
]
for pattern in blocked_patterns:
if re.search(pattern, command, re.IGNORECASE):
print(f"Blocked: command matches disallowed pattern '{pattern}'", file=sys.stderr)
sys.exit(2)
sys.exit(0)
if __name__ == "__main__":
main()
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/validate_command.py"
}
]
}
]
}
}
direnv Integration with CwdChanged
When Claude runs cd commands, Claude Code’s bash tool doesn’t automatically pick up direnv environment variables. Fix this with CwdChanged and CLAUDE_ENV_FILE:
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "direnv export bash > \"$CLAUDE_ENV_FILE\""
}
]
}
],
"CwdChanged": [
{
"hooks": [
{
"type": "command",
"command": "direnv export bash > \"$CLAUDE_ENV_FILE\""
}
]
}
]
}
}
CLAUDE_ENV_FILE is a path Claude Code sets automatically. Whatever you write there is sourced as a preamble before each Bash command.
Auto-Approve Safe Permissions
Skip the approval dialog for operations you always allow by using PermissionRequest hooks with JSON output:
{
"hooks": {
"PermissionRequest": [
{
"matcher": "ExitPlanMode",
"hooks": [
{
"type": "command",
"command": "echo '{\"hookSpecificOutput\": {\"hookEventName\": \"PermissionRequest\", \"decision\": {\"behavior\": \"allow\"}}}'"
}
]
}
]
}
}
Keep matchers narrow. Matching .* or leaving matcher empty would auto-approve every permission prompt, including file writes and shell commands.
ConfigChange Audit Log
Track settings file changes during a session:
{
"hooks": {
"ConfigChange": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "jq -c '{ts: now | todate, source: .source, file: .file_path}' >> ~/claude-config-audit.log"
}
]
}
]
}
}
Matcher values for ConfigChange: user_settings, project_settings, local_settings, policy_settings, skills. To block a change from taking effect, exit 2.
Debugging Hooks
When a hook doesn’t behave as expected, three places to look:
Test manually before registering:
echo '{"tool_name":"Bash","tool_input":{"command":"git push origin main"}}' | ./.claude/hooks/block-dangerous.sh
echo $? # Check exit code
Check transcript view:
Press Ctrl+O in Claude Code to toggle transcript view. Each hook that fires shows a one-line summary. Exit 0 is silent. Exit 2 shows stderr. Any other exit code shows a hook error notice.
Enable debug logging:
claude --debug-file /tmp/claude.log
# In another terminal:
tail -f /tmp/claude.log
The debug log shows which hooks matched, their exit codes, stdout, and stderr. Or run /debug mid-session to enable logging without restarting.
Common issues:
- Hook not firing: check
/hooksmenu, verify matcher case (it’s case-sensitive), confirm the event type (PreToolUsevsPostToolUse) jq: command not found: install jq or switch to Python for JSON parsingcommand not foundfor your script: use absolute paths or$CLAUDE_PROJECT_DIRreferences; make scripts executable withchmod +x- JSON validation failed: your shell profile may have unconditional
echostatements that prepend text to hook output. Wrap them inif [[ $- == *i* ]]; then ... fi
Stop hook hitting block cap:
If a Stop hook blocks 8 times in a row, Claude Code overrides it. Fix by checking stop_hook_active:
INPUT=$(cat)
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
exit 0
fi
# rest of hook logic
Hook Location Decision Guide
| Where to put it | When |
|---|---|
~/.claude/settings.json | Personal workflow (notifications, preferred formatter) |
.claude/settings.json | Team standards, share via git |
.claude/settings.local.json | Personal overrides for a project, gitignored |
Plugin hooks/hooks.json | Reusable hook bundles across projects |
To disable all hooks temporarily: "disableAllHooks": true in any settings file.
For production hook design, review Security Considerations in the official docs before deploying. And check our rules gallery for community-contributed CLAUDE.md templates that work alongside these hooks.