Claude Code Hooks (2026)
Claude Code hooks let you run custom scripts automatically when specific events happen during a Claude Code session. They execute shell commands at defined trigger points: before a tool runs, after a tool runs, when a notification fires, or when Claude stops.
Hooks solve the problem of manual intervention. Instead of watching Claude Code and manually running linters, formatters, security scanners, or notification scripts, you define them once and they run automatically every time. If you are new to Claude Code, start with the beginner guide first, then return here to add hooks to your workflow.
This guide covers every hook type, the JSON configuration format, practical examples for real workflows, and troubleshooting for common issues.
How Hooks Work
Hooks are defined in your Claude Code settings files. When Claude Code encounters a trigger event, it:
- Checks if any hooks match the event type
- Filters hooks by the optional
matcher(tool name, file pattern) - Executes the hook command as a shell subprocess
- Reads the hook’s stdout and exit code
- Decides how to proceed based on the result
Hooks run synchronously. Claude Code waits for the hook to complete before continuing. A hook that takes 30 seconds blocks Claude Code for 30 seconds.
Configuration Location
Hooks are configured in .claude/settings.json (project-level) or ~/.claude/settings.json (global). Project-level hooks override global hooks.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write",
"command": "echo 'About to write a file'"
}
]
}
}
You can also configure hooks in .claude/settings.local.json for personal hooks that should not be committed to version control.
Hook Types
PreToolUse
Fires before Claude Code executes a tool. Use this to validate, modify, or block tool invocations.
Trigger: Right before any tool call (Bash, Write, Edit, Read, etc.)
Environment variables available:
CLAUDE_TOOL_NAME: The tool being called (e.g., “Write”, “Bash”, “Edit”)CLAUDE_TOOL_INPUT: JSON string of the tool’s input parametersCLAUDE_SESSION_ID: Current session identifier
Exit code behavior:
0: Allow the tool call to proceed1: Block the tool call (Claude sees the rejection)2: Block silently (Claude does not see feedback)
stdout behavior: Output from the hook is shown to Claude as context.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"command": "/Users/you/scripts/validate-bash-command.sh"
}
]
}
}
Example: Block dangerous commands
#!/bin/bash
# validate-bash-command.sh
# Block rm -rf, force pushes, and other dangerous operations
TOOL_INPUT="$CLAUDE_TOOL_INPUT"
COMMAND=$(echo "$TOOL_INPUT" | jq -r '.command')
if echo "$COMMAND" | grep -qE 'rm -rf /|git push.*--force|DROP TABLE|format c:'; then
echo "BLOCKED: Dangerous command detected: $COMMAND"
exit 1
fi
exit 0
PostToolUse
Fires after a tool executes successfully. Use this for linting, formatting, logging, or triggering side effects.
Trigger: After any tool call completes successfully
Environment variables available:
CLAUDE_TOOL_NAME: The tool that was calledCLAUDE_TOOL_INPUT: JSON string of the tool’s input parametersCLAUDE_TOOL_OUTPUT: JSON string of the tool’s output/resultCLAUDE_SESSION_ID: Current session identifier
Exit code behavior:
0: Continue normally- Non-zero: Claude sees the error output (but the tool call already happened)
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write",
"command": "/Users/you/scripts/auto-lint.sh"
}
]
}
}
Example: Auto-format after file writes
#!/bin/bash
# auto-lint.sh
# Run Prettier on any file Claude writes
FILE_PATH=$(echo "$CLAUDE_TOOL_INPUT" | jq -r '.file_path // .path // empty')
if [ -z "$FILE_PATH" ]; then
exit 0
fi
# Only format supported file types
case "$FILE_PATH" in
*.ts|*.tsx|*.js|*.jsx|*.json|*.css|*.md)
npx prettier --write "$FILE_PATH" 2>/dev/null
echo "Formatted: $FILE_PATH"
;;
esac
exit 0
Notification
Fires when Claude Code generates a notification (e.g., when a long-running task completes or when Claude needs user input).
Trigger: Terminal notification events
Environment variables available:
CLAUDE_NOTIFICATION_MESSAGE: The notification textCLAUDE_SESSION_ID: Current session identifier
Exit code behavior:
- Exit code does not affect Claude Code behavior
{
"hooks": {
"Notification": [
{
"command": "/Users/you/scripts/notify-slack.sh"
}
]
}
}
Example: Send Slack notification
#!/bin/bash
# notify-slack.sh
# Post Claude Code notifications to Slack
WEBHOOK_URL="https://hooks.slack.com/services/YOUR/WEBHOOK/URL"
MESSAGE="$CLAUDE_NOTIFICATION_MESSAGE"
curl -s -X POST "$WEBHOOK_URL" \
-H 'Content-type: application/json' \
-d "{\"text\": \"Claude Code: $MESSAGE\"}"
Stop
Fires when Claude Code stops (either completes a task or is interrupted).
Trigger: Session ending or task completion
Environment variables available:
CLAUDE_STOP_REASON: Why Claude stopped (“end_turn”, “max_tokens”, “user_interrupt”)CLAUDE_SESSION_ID: Current session identifier
Exit code behavior:
0: If stdout is non-empty, Claude sees it and may continue working- Non-zero: Claude sees the error output and may continue working
This hook is powerful: returning output from a Stop hook can make Claude continue working. Use this for automated verification loops.
{
"hooks": {
"Stop": [
{
"command": "/Users/you/scripts/verify-on-stop.sh"
}
]
}
}
Example: Run tests before allowing Claude to stop
#!/bin/bash
# verify-on-stop.sh
# Run test suite when Claude finishes. If tests fail, Claude sees the output and continues fixing.
if [ "$CLAUDE_STOP_REASON" = "end_turn" ]; then
TEST_OUTPUT=$(npm test 2>&1)
if [ $? -ne 0 ]; then
echo "Tests failed. Please fix these failures:"
echo "$TEST_OUTPUT"
exit 0 # Exit 0 with output = Claude continues
fi
fi
Matcher Patterns
The matcher field filters which tool invocations trigger a hook. It matches against the tool name.
Exact Match
{
"matcher": "Write",
"command": "echo 'File written'"
}
Only triggers for the Write tool.
Regex Match
{
"matcher": "Write|Edit",
"command": "echo 'File modified'"
}
Triggers for both Write and Edit tools.
No Matcher (Match All)
{
"command": "echo 'Any tool was used'"
}
Omitting matcher triggers the hook for every tool call. Use this sparingly as it runs on every single tool invocation.
Available Tool Names
Tools you can match against:
| Tool Name | Description |
|---|---|
Bash |
Shell command execution |
Write |
Creating or overwriting files |
Edit |
Editing existing files |
Read |
Reading file contents |
Glob |
File pattern matching |
Grep |
Content search |
WebFetch |
Fetching web content |
WebSearch |
Web search |
NotebookEdit |
Jupyter notebook editing |
Complete Configuration Example
Here is a full .claude/settings.json with hooks for a production TypeScript project:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"command": "/Users/you/project/.claude/hooks/block-dangerous-commands.sh"
},
{
"matcher": "Write",
"command": "/Users/you/project/.claude/hooks/check-file-path.sh"
}
],
"PostToolUse": [
{
"matcher": "Write|Edit",
"command": "/Users/you/project/.claude/hooks/auto-format.sh"
},
{
"matcher": "Write",
"command": "/Users/you/project/.claude/hooks/log-file-change.sh"
},
{
"matcher": "Bash",
"command": "/Users/you/project/.claude/hooks/log-commands.sh"
}
],
"Notification": [
{
"command": "/Users/you/project/.claude/hooks/notify.sh"
}
],
"Stop": [
{
"command": "/Users/you/project/.claude/hooks/run-checks.sh"
}
]
}
}
Practical Use Cases
Use Case 1: Security Gate
Prevent Claude from writing to sensitive directories or files:
#!/bin/bash
# check-file-path.sh (PreToolUse for Write)
FILE_PATH=$(echo "$CLAUDE_TOOL_INPUT" | jq -r '.file_path // .path // empty')
# Block writes to sensitive locations
BLOCKED_PATTERNS=(
".env"
"credentials"
"secrets"
".ssh"
"node_modules"
"*.pem"
"*.key"
)
for pattern in "${BLOCKED_PATTERNS[@]}"; do
if echo "$FILE_PATH" | grep -qi "$pattern"; then
echo "BLOCKED: Cannot write to sensitive path: $FILE_PATH"
exit 1
fi
done
exit 0
Use Case 2: Auto-Lint on Save
Run ESLint and fix issues automatically after Claude writes TypeScript files:
#!/bin/bash
# auto-format.sh (PostToolUse for Write|Edit)
FILE_PATH=$(echo "$CLAUDE_TOOL_INPUT" | jq -r '.file_path // .path // empty')
if [ -z "$FILE_PATH" ] || [ ! -f "$FILE_PATH" ]; then
exit 0
fi
case "$FILE_PATH" in
*.ts|*.tsx)
LINT_OUTPUT=$(npx eslint --fix "$FILE_PATH" 2>&1)
if [ $? -ne 0 ]; then
echo "ESLint issues in $FILE_PATH:"
echo "$LINT_OUTPUT"
fi
npx prettier --write "$FILE_PATH" 2>/dev/null
;;
*.css|*.scss)
npx prettier --write "$FILE_PATH" 2>/dev/null
;;
esac
exit 0
Use Case 3: Audit Log
Log every Claude Code action for compliance:
#!/bin/bash
# log-commands.sh (PostToolUse for Bash)
LOG_FILE="$HOME/.claude/audit.log"
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
COMMAND=$(echo "$CLAUDE_TOOL_INPUT" | jq -r '.command // empty')
echo "[$TIMESTAMP] SESSION=$CLAUDE_SESSION_ID TOOL=$CLAUDE_TOOL_NAME CMD=$COMMAND" >> "$LOG_FILE"
exit 0
Use Case 4: Test Verification Loop
Force Claude to fix test failures before completing:
#!/bin/bash
# run-checks.sh (Stop hook)
if [ "$CLAUDE_STOP_REASON" != "end_turn" ]; then
exit 0
fi
# Run type checking
TSC_OUTPUT=$(npx tsc --noEmit 2>&1)
if [ $? -ne 0 ]; then
echo "TypeScript errors found. Please fix:"
echo "$TSC_OUTPUT"
exit 0 # Output + exit 0 = Claude continues
fi
# Run tests
TEST_OUTPUT=$(npm test 2>&1)
if [ $? -ne 0 ]; then
echo "Test failures. Please fix:"
echo "$TEST_OUTPUT"
exit 0
fi
# All checks passed, let Claude stop
exit 0
Use Case 5: Branch Protection
Prevent Claude from running git commands on protected branches:
#!/bin/bash
# protect-branches.sh (PreToolUse for Bash)
COMMAND=$(echo "$CLAUDE_TOOL_INPUT" | jq -r '.command // empty')
# Only check git commands
if ! echo "$COMMAND" | grep -q "^git "; then
exit 0
fi
CURRENT_BRANCH=$(git branch --show-current 2>/dev/null)
PROTECTED_BRANCHES=("main" "master" "production" "staging")
for branch in "${PROTECTED_BRANCHES[@]}"; do
if [ "$CURRENT_BRANCH" = "$branch" ]; then
if echo "$COMMAND" | grep -qE 'git (push|reset|rebase|merge|commit)'; then
echo "BLOCKED: Cannot run '$COMMAND' on protected branch '$branch'"
exit 1
fi
fi
done
exit 0
Use Case 6: Cost Tracking
Track token-related costs in real-time:
#!/bin/bash
# track-cost.sh (PostToolUse, no matcher = all tools)
COST_FILE="$HOME/.claude/session-costs.jsonl"
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
echo "{\"timestamp\":\"$TIMESTAMP\",\"session\":\"$CLAUDE_SESSION_ID\",\"tool\":\"$CLAUDE_TOOL_NAME\"}" >> "$COST_FILE"
exit 0
Hook Script Best Practices
1. Keep Hooks Fast
Hooks run synchronously. A slow hook blocks Claude Code on every tool call. Target under 2 seconds per hook.
# Bad: Running full test suite on every file write
npm test # Could take 60+ seconds
# Good: Running only affected tests
npx jest --findRelatedTests "$FILE_PATH" --passWithNoTests
2. Handle Missing Dependencies
Your hook may run in environments where tools are not installed:
#!/bin/bash
if ! command -v eslint &> /dev/null; then
# ESLint not installed, skip
exit 0
fi
npx eslint --fix "$FILE_PATH"
3. Use Exit Codes Correctly
| Exit Code | PreToolUse | PostToolUse | Stop |
|---|---|---|---|
| 0 | Allow tool call | Continue | Stop (unless stdout) |
| 1 | Block + show message | Show error | Continue |
| 2 | Block silently | Show error | Continue |
4. Parse JSON Safely
Tool input/output is JSON. Use jq for reliable parsing:
# Bad: grep/sed parsing
FILE=$(echo "$CLAUDE_TOOL_INPUT" | grep -o '"file_path":"[^"]*"' | cut -d'"' -f4)
# Good: jq parsing
FILE=$(echo "$CLAUDE_TOOL_INPUT" | jq -r '.file_path // empty')
5. Log Errors Separately
Do not let hook errors pollute Claude’s context:
#!/bin/bash
# Redirect errors to log file, only show relevant output to Claude
exec 2>>/tmp/claude-hook-errors.log
# Hook logic here...
6. Make Scripts Executable
chmod +x .claude/hooks/*.sh
Claude Code will not execute scripts that lack the executable permission.
Need the complete toolkit? The Claude Code Playbook includes 200 production-ready templates.
Combining Hooks with Other Features
Hooks + CLAUDE.md
Use CLAUDE.md to tell Claude about your hooks:
## Hooks
This project uses Claude Code hooks:
- PreToolUse: Blocks writes to .env and credentials files
- PostToolUse: Auto-formats with Prettier after Write/Edit
- Stop: Runs `npm test` before allowing task completion
Do not try to disable or work around these hooks.
Hooks + MCP Servers
Hooks and MCP servers are independent systems. Hooks trigger on built-in tool events; MCP servers provide additional tools. However, you can write hooks that interact with MCP-related files:
# PostToolUse hook that validates MCP configuration after edits
FILE_PATH=$(echo "$CLAUDE_TOOL_INPUT" | jq -r '.file_path // empty')
if echo "$FILE_PATH" | grep -q "mcp.json"; then
# Validate MCP config
jq empty "$FILE_PATH" 2>/dev/null || echo "Invalid JSON in MCP config"
fi
Hooks + Git Hooks
Claude Code hooks and Git hooks serve different purposes but can work together:
- Git hooks: Run on git operations (pre-commit, pre-push)
- Claude Code hooks: Run on Claude Code tool operations
You might use a Claude Code PostToolUse hook to stage files, then rely on a Git pre-commit hook for final validation.
Debugging Hooks
Hook Not Firing
- Check the file path in settings.json is absolute and correct
- Verify the script is executable (
chmod +x) - Check the matcher matches the exact tool name (case-sensitive)
- Look for JSON syntax errors in settings.json
Hook Blocks Everything
- Check exit codes.
exit 1blocks PreToolUse hooks. - Add logging to see what the hook receives:
echo "DEBUG: TOOL=$CLAUDE_TOOL_NAME INPUT=$CLAUDE_TOOL_INPUT" >> /tmp/hook-debug.log
Hook Output Not Visible
- PreToolUse hooks show stdout to Claude only on exit code 0 or 1
- PostToolUse hooks always show stdout to Claude
- Stop hooks with empty stdout do not trigger Claude to continue
Performance Issues
If hooks slow down Claude Code:
- Profile your hook scripts with
time - Move heavy operations to background processes where possible
- Add early-exit conditions for irrelevant tool calls
- Use file caching for expensive lookups
Migration from Earlier Versions
If you set up hooks before the stable API, update your configuration:
- Move hooks from
~/.claude.jsonto~/.claude/settings.json - Update environment variable names (older versions used
TOOL_NAMEwithout theCLAUDE_prefix) - Verify matcher patterns still work with current tool names
Frequently Asked Questions
Can hooks modify the tool input before it executes?
No. PreToolUse hooks can only allow or block tool calls. They cannot modify the tool’s parameters. If you need to change what Claude does, return feedback text that Claude will see and respond to.
Do hooks work with Claude Code headless mode?
Yes. Hooks fire in both interactive and headless (--print) mode. This makes them useful for CI/CD integrations.
Can I have multiple hooks for the same event?
Yes. Define multiple entries in the array. They execute in order, and all must pass for the tool call to proceed (for PreToolUse).
Are hooks sandboxed?
No. Hooks run with the same permissions as your shell user. A hook can do anything your terminal can do. Review hook scripts carefully before installing them.
Do hooks persist across sessions?
Hook definitions in settings.json persist. Each new Claude Code session loads and applies them. Session-specific state (like counters) must be stored in files.
Can I share hooks with my team?
Yes. Commit .claude/settings.json and .claude/hooks/ to your repository. Team members get the hooks when they clone the project. Use .claude/settings.local.json for personal hooks that should not be shared.
What happens if a hook script crashes?
A crashing hook (signal-terminated, segfault) is treated as a non-zero exit code. For PreToolUse, this blocks the tool call. For PostToolUse, Claude sees the error.
Can hooks access the internet?
Yes. Hooks are regular shell scripts. They can make HTTP requests, call APIs, send notifications, or do anything else a shell script can do.
Where can I find pre-built hook scripts?
Check the best Claude Code hooks collection for community-maintained hooks. The SuperClaude framework also includes hook templates. For cost-related hooks, see our token usage audit guide and cost saving tools.
See Also
- Claude Code Spec Workflow Guide
- CLAUDE.md Best Practices Definitive Guide
- Super Claude Code Framework Guide
- Advanced Usage Guide. Power user techniques and advanced patterns