Claude Code Hooks 2026: Automate Your Dev Workflow
Learn Claude Code Hooks with 10+ practical examples. Master PreToolUse, PostToolUse, and automation patterns. Includes security guards, debugging tips, and best practices.
Hooks are a powerful extension mechanism in Claude Code that lets you inject custom logic before and after tool execution. This guide compiles practical experience to help you avoid common pitfalls and quickly master Hooks development.
Hook Types Overview
| Hook Type | Trigger Timing | Use Case |
|---|---|---|
| PreToolUse | Before tool execution | Block dangerous operations, add context |
| PostToolUse | After tool execution | Log operations, post-processing |
| SessionStart | When session starts | Load environment, display information |
| Stop | When automation loop ends | Decide whether to continue looping |
Input/Output Specification
Input Format (stdin JSON)
All Hooks receive JSON-formatted input:
{
"tool_name": "Bash",
"tool_input": {
"command": "ls -la",
"file_path": "/path/to/file",
"content": "file content..."
},
"tool_response": "command output..."
}
Common fields:
tool_name- Tool name (Bash, Write, Edit, Read, etc.)tool_input.command- Bash commandtool_input.file_path- File pathtool_input.content- File content for Writetool_input.new_string- New content for Edittool_response- Tool execution result (PostToolUse)
Output Format
1. Allow Operation (no output)
exit 0
2. Block Operation (exit 2 + stderr)
echo "🛡️ BLOCKED: Reason explanation" >&2
exit 2
3. Add Context (PreToolUse)
jq -n --arg ctx "Context message" '{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"additionalContext": $ctx
}
}'
exit 0
4. Stop Hook Prevent Stop
jq -n --arg reason "Continue reason" '{
"decision": "block",
"reason": $reason
}'
exit 0
⚠️ Important: Old Format No Longer Supported
# ❌ Wrong (causes hook error)
echo '{"decision": "allow"}'
# ✅ Correct
exit 0
Script Templates
PreToolUse - Blocking Type (Security Guard)
#!/bin/bash
# Security Guard - Block dangerous commands
# PreToolUse hook for Bash tool
# Read input
HOOK_INPUT=$(cat)
COMMAND=$(echo "$HOOK_INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null || echo "")
# Skip if no command
[[ -z "$COMMAND" ]] && exit 0
# Check dangerous patterns
# 1. Delete root directory
if echo "$COMMAND" | grep -qE 'rm\s+-[rf]+\s+/($|[^a-zA-Z])' 2>/dev/null; then
echo "🛡️ BLOCKED: Delete root filesystem (rm -rf /)" >&2
exit 2
fi
# 2. Pipe remote script to shell
if echo "$COMMAND" | grep -qE '(curl|wget).*\|\s*(sh|bash)' 2>/dev/null; then
echo "🛡️ BLOCKED: Pipe remote script to shell" >&2
exit 2
fi
# 3. Force push to main/master
if echo "$COMMAND" | grep -qE 'git\s+push.*--force.*\s+(main|master)' 2>/dev/null; then
echo "🛡️ BLOCKED: Force push to main/master" >&2
exit 2
fi
# Allow execution
exit 0
PreToolUse - Context Type (Git Context)
#!/bin/bash
# Git Context - Add Git status context
# PreToolUse hook for Bash tool
# Consume stdin (important! consume even if not needed)
cat > /dev/null
# Check if in git repo
if ! git rev-parse --git-dir &>/dev/null; then
exit 0
fi
# Collect Git info
BRANCH=$(git branch --show-current 2>/dev/null || echo "unknown")
UNCOMMITTED=$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ')
# Output context
jq -n --arg ctx "[Git] Branch: $BRANCH, Uncommitted: $UNCOMMITTED files" '{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"additionalContext": $ctx
}
}'
exit 0
PreToolUse - Warning Type (File Guard)
#!/bin/bash
# File Guard - Warn about sensitive files
# PreToolUse hook for Write/Edit tools
HOOK_INPUT=$(cat)
FILE_PATH=$(echo "$HOOK_INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null || echo "")
[[ -z "$FILE_PATH" ]] && exit 0
FILENAME=$(basename "$FILE_PATH")
WARNINGS=()
# Check sensitive patterns
if echo "$FILENAME" | grep -qiE '^\.env$|^\.env\.' 2>/dev/null; then
WARNINGS+=("Environment file may contain secrets")
fi
if echo "$FILENAME" | grep -qiE '\.pem$|\.key$|^id_rsa' 2>/dev/null; then
WARNINGS+=("File may be a private key")
fi
# Output warnings
if [[ ${#WARNINGS[@]} -gt 0 ]]; then
WARNING_TEXT=$(printf '%s; ' "${WARNINGS[@]}")
jq -n --arg warn "⚠️ Sensitive file ($FILENAME): $WARNING_TEXT" '{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"additionalContext": $warn
}
}'
fi
exit 0
PostToolUse - Logging Type
#!/bin/bash
# Log File Change - Record file changes
# PostToolUse hook for Write/Edit tools
HOOK_INPUT=$(cat)
TOOL_NAME=$(echo "$HOOK_INPUT" | jq -r '.tool_name // empty' 2>/dev/null || echo "")
FILE_PATH=$(echo "$HOOK_INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null || echo "")
[[ -z "$TOOL_NAME" ]] && exit 0
[[ -z "$FILE_PATH" ]] && exit 0
# Ensure log directory exists
mkdir -p .claude/logs 2>/dev/null
# Record changes
case "$TOOL_NAME" in
Write)
echo "[$(date -Iseconds)] WRITE: $FILE_PATH" >> .claude/logs/file-changes.log
;;
Edit)
echo "[$(date -Iseconds)] EDIT: $FILE_PATH" >> .claude/logs/file-changes.log
;;
esac
exit 0
Stop Hook - Loop Control
#!/bin/bash
# Post Auto Check - Control automation loop
# Stop hook
AUTO_DIR=".auto"
AC_FILE="$AUTO_DIR/acceptance-criteria.json"
STOP_FILE="$AUTO_DIR/stop"
# No state directory, allow stop
if [ ! -d "$AUTO_DIR" ]; then
exit 0
fi
# Check manual stop signal
if [ -f "$STOP_FILE" ]; then
rm -rf "$AUTO_DIR"
exit 0
fi
# Check AC completion rate
if [ -f "$AC_FILE" ] && command -v jq &> /dev/null; then
PASSED=$(jq '[.criteria[] | select(.status=="passed")] | length' "$AC_FILE" 2>/dev/null || echo "0")
TOTAL=$(jq '.criteria | length' "$AC_FILE" 2>/dev/null || echo "0")
if [ "$TOTAL" -gt 0 ] && [ "$PASSED" -lt "$TOTAL" ]; then
# Block stop, continue loop
jq -n --arg reason "AC completion: $PASSED/$TOTAL, continuing" '{
"decision": "block",
"reason": $reason
}'
exit 0
fi
fi
# Allow stop
exit 0
Design Patterns
Global Shared + Namespace
Install hooks at user level, shared across all projects:
~/.claude/hooks/bootstrap-kit/
├── pre-tool-use/
│ ├── security-guard.sh
│ ├── git-context.sh
│ ├── file-guard.sh
│ └── architecture-guard.sh
├── log-bash-event.sh
├── log-file-change.sh
├── session-start/
│ └── session-start.sh
└── auto/
└── post_auto_check.sh
settings.json Configuration
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "/Users/username/.claude/hooks/bootstrap-kit/pre-tool-use/security-guard.sh"
}
]
},
{
"matcher": "Write",
"hooks": [
{
"type": "command",
"command": "/Users/username/.claude/hooks/bootstrap-kit/pre-tool-use/file-guard.sh"
}
]
}
],
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "/Users/username/.claude/hooks/bootstrap-kit/log-bash-event.sh"
}
]
}
],
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "/Users/username/.claude/hooks/bootstrap-kit/session-start/session-start.sh"
}
]
}
],
"Stop": [
{
"matcher": ".*",
"hooks": [
{
"type": "command",
"command": "/Users/username/.claude/hooks/bootstrap-kit/auto/post_auto_check.sh"
}
]
}
]
}
}
Common Errors and Solutions
1. Hook Error Keeps Appearing
Symptom:
PreToolUse:Bash hook error: [/path/to/hook.sh]: ...
Cause: Using old format {"decision": "allow"}
Solution: Use exit 0 (no output)
2. Path Cannot Load
Symptom: Hook doesn’t execute
Cause: Using $HOME or ${HOME} in settings.json
Solution: Use absolute paths
// ❌ Wrong
"command": "$HOME/.claude/hooks/my-hook.sh"
// ✅ Correct
"command": "/Users/username/.claude/hooks/my-hook.sh"
3. Hooks Overriding Each Other
Symptom: Configured hooks don’t take effect
Cause: Conflicting configurations in multiple settings files
Solution: Check and clear hooks in:
~/.claude/settings.local.json.claude/settings.local.json
4. Unconsumed stdin Causes Errors
Symptom: Broken pipe or hook anomaly
Cause: Script doesn’t read stdin
Solution: Consume stdin even if not needed
cat > /dev/null
# or
HOOK_INPUT=$(cat)
5. JSON Output Format Error
Symptom: Context not displayed or hook error
Cause: Incorrect JSON structure
Solution: Use jq to generate JSON
# ❌ Manual concatenation prone to errors
echo '{"hookSpecificOutput":{"additionalContext":"msg"}}'
# ✅ Use jq
jq -n --arg ctx "msg" '{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"additionalContext": $ctx
}
}'
Testing Methods
1. Direct Execution Test
# Test script syntax
bash -n /path/to/hook.sh
# Simulate input test
echo '{"tool_input":{"command":"ls"}}' | /path/to/hook.sh
2. Test in Claude Code
# Test PreToolUse (Bash)
echo "test command"
# Test PreToolUse (Write) - observe if there's a warning
# Write to a .env file
# Test Security Guard - should be blocked
rm -rf /
3. Check Logs
# Check file change log
cat .claude/logs/file-changes.log
# Check event log
cat .claude/logs/events.log
Best Practices
1. Always Consume stdin
HOOK_INPUT=$(cat)
# or
cat > /dev/null
2. Use jq for JSON Processing
COMMAND=$(echo "$HOOK_INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null || echo "")
3. Defensive Programming
# Check empty values
[[ -z "$COMMAND" ]] && exit 0
# Error handling
command -v jq &> /dev/null || exit 0
4. Use stderr for Block Messages
echo "🛡️ BLOCKED: reason" >&2
exit 2
5. Use Absolute Paths
"command": "/Users/username/.claude/hooks/..."
6. Script Permissions
chmod +x /path/to/hook.sh
7. Log Output to stderr
# Debug messages
echo "[DEBUG] something" >&2
References
This guide is based on Claude Code v2.1.9+ testing. Behavior may change with version updates.