Chapter 5: Hooks — Automate Everything Claude Does
Learning Objectives
After completing this chapter, you will be able to:
- Explain the hook lifecycle and how PreToolUse, PostToolUse, SessionStart, and Stop events map to Claude's tool-call pipeline
- Write hook scripts that enforce commit quality, block dangerous commands, inject session context, and observe tool usage
- Use exit codes correctly: 0 (allow), 1 (error — operation still proceeds), 2 (intentional block)
- Configure hooks in
settings.jsonwith matchers, handler types, and event binding - Debug common hook failures: missing shebang, wrong exit code, non-executable scripts
What You'll Build
Four production hooks that solve real problems:
- A commit quality gate that enforces message format, runs linters, and catches leaked secrets before they hit your repo
- A dangerous command blocker that stops
--no-verify,DROP TABLE, and force pushes to main - A session context injector that feeds Claude your git branch, recent PRs, and CI status on every startup
- A continuous learning observer that captures tool usage patterns so you can optimize your workflow
These aren't toy demos. They're adapted from Everything Claude Code (148k stars) and gstack (68k stars) -- battle-tested in real teams.
How Hooks Work
Every time Claude uses a tool, it passes through a lifecycle. Hooks let you intercept that lifecycle at specific points and run your own code.
You: "commit these changes"
|
v
Claude prepares: Bash(command="git commit -m '...'")
|
v
PreToolUse hook fires --> your script checks the commit message format
| your script scans for secrets in staged files
| your script blocks --no-verify flag
|
v (all checks pass, exit 0)
Claude executes the command
|
v
PostToolUse hook fires --> your script logs the tool usage
|
v
DoneThe critical part: PreToolUse hooks can block tool execution by returning exit code 2. Everything else is observational.
Hook Events
| Event | When | Can Block? | Real Use Case |
|---|---|---|---|
| PreToolUse | Before tool runs | Yes (exit 2) | Block --no-verify, enforce commit format |
| PostToolUse | After tool runs | No | Run linter, log usage, format code |
| SessionStart | Session begins | No | Inject git context, CI status, PR info |
| Stop | Claude finishes | No | Notification, usage stats, session summary |
| Notification | Claude notifies | No | Forward to Slack, phone push |
Handler Types
| Type | What It Does | When to Use |
|---|---|---|
| command | Runs a shell script | 90% of cases. Your bread and butter. |
| http | POST to a webhook | Slack notifications, telemetry endpoints |
| prompt | Claude evaluates a condition | "Does this change break the public API?" |
| agent | Launches a subagent | Deep code review before committing |
Exit Code Protocol
| Exit Code | Meaning | Effect on Tool Execution |
|---|---|---|
| 0 | Allow -- hook passed | Operation proceeds normally |
| 1 | Error in hook script | Operation still proceeds (hook failure is non-fatal) |
| 2 | Intentional block | Tool execution is prevented |
Why Exit Code 2 for Blocking (Not 1)?
This is a deliberate design choice, not arbitrary. In Unix conventions, exit code 1 means "general error" -- something went wrong in the script itself (a syntax error, a missing dependency, a failed jq parse). If hook errors blocked operations, a typo in your hook script would prevent Claude from working at all.
Exit code 2 means "intentional rejection." Your script ran correctly, evaluated the situation, and made a conscious decision to block. This separation ensures that broken hooks degrade gracefully (Claude keeps working) while intentional policies are enforced reliably (blocked operations stay blocked).
This mirrors how HTTP status codes work: 500 (server error) vs 403 (forbidden). The server might be broken, or it might be deliberately rejecting you -- those are very different situations.
Configuration
Hooks live in your settings.json. You can configure them at project level (.claude/settings.json) or user level (~/.claude/settings.json).
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash .claude/hooks/my-hook.sh"
}
]
}
]
}
}Matchers use regex. "Bash" matches the Bash tool. "Write|Edit" matches both. "mcp__github__.*" matches all GitHub MCP tools.
Demo 11: Commit Quality Gate
Inspired by ECC's
pre-bash-commit-quality.js-- a hook that catches bad commits before they happen.
The Problem
Your team uses Conventional Commits. But Claude sometimes generates commit messages like "update stuff" or commits with --no-verify to skip pre-commit hooks. Worse, staged files occasionally contain hardcoded API keys.
Build It
1. Set Up
mkdir -p ~/claude-demos/demo-11 && cd ~/claude-demos/demo-11
git init && git branch -M main
mkdir -p .claude/hooks src
cat > CLAUDE.md << 'EOF'
# Commit Quality Demo
- Use Conventional Commits format: type(scope): description
- Never skip pre-commit hooks
- Never commit secrets or API keys
EOF
cat > src/app.js << 'EOF'
export function processPayment(amount, currency) {
if (amount <= 0) throw new Error('Invalid amount');
return { status: 'processed', amount, currency };
}
EOF
git add -A && git commit -m "feat: initial project setup"2. Create the Hook Script
This is the real hook -- it checks three things in every git commit command Claude runs:
cat > .claude/hooks/commit-quality-gate.sh << 'HOOKEOF'
#!/bin/bash
# PreToolUse Hook: Commit quality gate
# Blocks commits that fail format, secret, or hygiene checks
input=$(cat)
tool_name=$(echo "$input" | jq -r '.tool_name // empty')
command=$(echo "$input" | jq -r '.tool_input.command // empty')
# Only check Bash commands that look like git commits
if [ "$tool_name" != "Bash" ]; then
exit 0
fi
if ! echo "$command" | grep -q "git commit"; then
exit 0
fi
# === CHECK 1: Block --no-verify ===
# This is from ECC's hook set — never let Claude skip pre-commit hooks
if echo "$command" | grep -q "\-\-no-verify"; then
echo "BLOCKED: --no-verify flag detected"
echo ""
echo "Commits must pass pre-commit hooks. Remove --no-verify and fix"
echo "whatever issue the hooks are catching."
exit 2
fi
# === CHECK 2: Enforce Conventional Commits format ===
# Extract the commit message from -m "..." or -m '...'
msg=$(echo "$command" | sed -n 's/.*-m ["\x27]\([^"\x27]*\)["\x27].*/\1/p')
if [ -n "$msg" ]; then
# Check format: type(scope): description OR type: description
if ! echo "$msg" | grep -qE '^(feat|fix|docs|style|refactor|test|chore|perf|ci|build|revert)(\([a-zA-Z0-9_-]+\))?: .+'; then
echo "BLOCKED: Commit message doesn't follow Conventional Commits"
echo ""
echo "Expected format: type(scope): description"
echo " Types: feat, fix, docs, style, refactor, test, chore, perf, ci, build, revert"
echo " Example: feat(auth): add password reset flow"
echo ""
echo "Got: $msg"
exit 2
fi
# Subject line max 72 chars
subject=$(echo "$msg" | head -1)
if [ ${#subject} -gt 72 ]; then
echo "BLOCKED: Commit subject line is ${#subject} chars (max 72)"
echo "Shorten it: $subject"
exit 2
fi
fi
# === CHECK 3: Scan staged files for secrets ===
# Check if there are staged files with potential secrets
staged_files=$(git diff --cached --name-only 2>/dev/null)
if [ -n "$staged_files" ]; then
for file in $staged_files; do
if [ -f "$file" ]; then
# Check for common secret patterns
if grep -qiE '(api_key|api_secret|aws_secret|private_key|password)\s*[=:]\s*["\x27][^\s]+["\x27]' "$file" 2>/dev/null; then
echo "BLOCKED: Potential secret detected in staged file: $file"
echo ""
echo "Found what looks like a hardcoded credential. Use environment"
echo "variables instead, or add the file to .gitignore."
exit 2
fi
# Check for .env files
if echo "$file" | grep -qE '\.env$|\.env\.local$|\.env\.production$'; then
echo "BLOCKED: Environment file staged for commit: $file"
echo "Run: git reset HEAD $file"
exit 2
fi
fi
done
fi
# All checks passed
exit 0
HOOKEOF
chmod +x .claude/hooks/commit-quality-gate.sh3. Configure
cat > .claude/settings.json << 'EOF'
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash .claude/hooks/commit-quality-gate.sh"
}
]
}
]
}
}
EOF4. Test It
claudeTest the format check:
Commit the current changes with message "update stuff"Here is what you will see in the terminal when the hook blocks a bad commit message:
$ claude
> Commit the current changes with message "update stuff"
Claude wants to run: Bash
git commit -m "update stuff"
Hook: commit-quality-gate.sh
BLOCKED: Commit message doesn't follow Conventional Commits
Expected format: type(scope): description
Types: feat, fix, docs, style, refactor, test, chore, perf, ci, build, revert
Example: feat(auth): add password reset flow
Got: update stuff
I'll retry with a proper format.
Claude wants to run: Bash
git commit -m "chore: update project configuration"
Allow? [y/n/a]: y
[main abc1234] chore: update project configuration
1 file changed, 3 insertions(+)Test the --no-verify block:
Run git commit --no-verify -m "fix: bypass hooks"> Run git commit --no-verify -m "fix: bypass hooks"
Claude wants to run: Bash
git commit --no-verify -m "fix: bypass hooks"
Hook: commit-quality-gate.sh
BLOCKED: --no-verify flag detected
Commits must pass pre-commit hooks. Remove --no-verify and fix
whatever issue the hooks are catching.
I understand. I'll commit without --no-verify and address any
pre-commit hook failures directly.Test that good commits pass:
Make a small change to src/app.js, then commit with a proper conventional commit messageExpected: The hook allows it through.
What Just Happened?
The key insight: the hook runs before the command executes. Claude never touches git commit until the hook says it is safe. When blocked, Claude reads the hook's stderr output and self-corrects -- usually on the very next attempt.
Why 72-Character Subject Lines?
Git Convention Since 2005
The 72-character limit is not arbitrary. It comes from the Linux kernel project's commit message standard, adopted by virtually every major open-source project since.
Practical reasons:
git log --onelinetruncates at the terminal width. Most terminals default to 80 columns, and with the 7-char commit hash prefix + space, that leaves 72 for the message.git shortlog(used for release notes) wraps poorly with long subjects.- GitHub's commit list UI truncates after ~72 characters with an ellipsis.
- Patch email formatting (the original Git workflow) assumed 72-char line widths.
If you are working in a team that uses a different limit (e.g., 50 chars for subject), modify the 72 in the hook script to match.
Why Conventional Commits Format?
Structured Messages Enable Automation
Conventional Commits uses a type(scope): description format for a reason:
- Automated changelogs: Tools like
standard-versionandsemantic-releaseparse the type prefix to generate CHANGELOG.md automatically. - Semantic versioning:
feat:triggers a minor version bump,fix:triggers a patch bump,feat!:or any type with aBREAKING CHANGEfooter triggers a major bump. - Searchable history:
git log --grep="^feat"instantly shows all feature additions. Try that with free-form messages. - PR categorization: GitHub Actions can auto-label PRs based on the commit type.
The format is type(optional-scope): description. The types (feat, fix, docs, style, refactor, test, chore, perf, ci, build, revert) cover every category of change.
Demo 12: Dangerous Command Blocker
Adapted from ECC's security hooks -- block destructive commands before they execute.
The Problem
Claude is generally cautious, but it can be prompted into running destructive commands. Force pushing to main, dropping databases, rm -rf on important paths -- these are all one bad prompt away.
Build It
1. Create the Hook
cd ~/claude-demos/demo-11
cat > .claude/hooks/block-dangerous.sh << 'HOOKEOF'
#!/bin/bash
# PreToolUse Hook: Block destructive Bash commands
# Covers the patterns that actually cause incidents in production teams
input=$(cat)
tool_name=$(echo "$input" | jq -r '.tool_name // empty')
if [ "$tool_name" != "Bash" ]; then
exit 0
fi
command=$(echo "$input" | jq -r '.tool_input.command // empty')
# --- Destructive file operations ---
if echo "$command" | grep -qE 'rm\s+(-[a-zA-Z]*f[a-zA-Z]*\s+|--force\s+).*(/|~|\*|\.)'; then
echo "BLOCKED: Destructive rm command"
echo "Command: $command"
echo ""
echo "If you need to delete files, be specific about the path."
exit 2
fi
# --- Git force push to protected branches ---
if echo "$command" | grep -qiE 'git\s+push\s+.*--force.*\s+(main|master|production|release)'; then
echo "BLOCKED: Force push to protected branch"
echo "Command: $command"
echo ""
echo "Force pushing to main/master/production is never safe."
echo "Use a feature branch and create a PR instead."
exit 2
fi
# The reverse pattern too: branch name before --force
if echo "$command" | grep -qiE 'git\s+push\s+\S+\s+(main|master|production|release)\s+--force'; then
echo "BLOCKED: Force push to protected branch"
exit 2
fi
# --- Database destruction ---
if echo "$command" | grep -qiE '(DROP\s+(TABLE|DATABASE|SCHEMA)|TRUNCATE\s+TABLE|DELETE\s+FROM\s+\S+\s*;?\s*$)'; then
echo "BLOCKED: Destructive database command"
echo "Command: $command"
echo ""
echo "Use migrations for schema changes. DELETE requires a WHERE clause."
exit 2
fi
# --- Disk operations ---
if echo "$command" | grep -qE '(mkfs\.|dd\s+if=|wipefs)'; then
echo "BLOCKED: Disk-level destructive command"
exit 2
fi
# --- chmod 777 on root ---
if echo "$command" | grep -qE 'chmod\s+(-R\s+)?777\s+/'; then
echo "BLOCKED: chmod 777 on root filesystem"
exit 2
fi
exit 0
HOOKEOF
chmod +x .claude/hooks/block-dangerous.sh2. Update Config
cat > .claude/settings.json << 'EOF'
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash .claude/hooks/commit-quality-gate.sh"
},
{
"type": "command",
"command": "bash .claude/hooks/block-dangerous.sh"
}
]
}
]
}
}
EOF3. Test
claudeTry these -- all should be blocked:
Run rm -rf /tmp/important-data> Run rm -rf /tmp/important-data
Claude wants to run: Bash
rm -rf /tmp/important-data
Hook: block-dangerous.sh
BLOCKED: Destructive rm command
Command: rm -rf /tmp/important-data
If you need to delete files, be specific about the path.
I can't run that command because it matches a destructive pattern.
If you need to remove specific files, I can list them first and
delete them individually.Force push to main branch> Force push to main branch
Claude wants to run: Bash
git push origin main --force
Hook: block-dangerous.sh
BLOCKED: Force push to protected branch
Command: git push origin main --force
Force pushing to main/master/production is never safe.
Use a feature branch and create a PR instead.
I can't force push to main. Instead, I'll create a feature branch
and push there.Run this SQL: DROP TABLE users;> Run this SQL: DROP TABLE users;
Claude wants to run: Bash
psql -c "DROP TABLE users;"
Hook: block-dangerous.sh
BLOCKED: Destructive database command
Command: psql -c "DROP TABLE users;"
Use migrations for schema changes. DELETE requires a WHERE clause.And this should work fine:
Run ls -la src/What Just Happened?
Notice that the hook runs multiple regex checks in sequence. The command only needs to match one pattern to be blocked. Safe commands pass through all checks and exit with 0.
The Takeaway
gstack's philosophy applies here: be specific about what you block. Don't try to catch every possible destructive command -- focus on the patterns that actually cause incidents. The list above covers 95% of real-world production accidents.
Demo 13: Session Context Injector
Based on ECC's session initialization pattern and gstack's "re-ground" principle.
The Problem
Every time you start Claude in a project, it has no idea what you were just working on. It doesn't know your current branch, recent PRs, CI status, or what changed since your last session. You end up spending the first few messages catching Claude up.
Build It
1. Create the Injector
cd ~/claude-demos/demo-11
cat > .claude/hooks/inject-context.sh << 'HOOKEOF'
#!/bin/bash
# SessionStart Hook: Give Claude full project context on every startup
# Inspired by gstack's "re-ground" principle — always start with reality
echo "=== SESSION CONTEXT ==="
# --- Git state ---
if git rev-parse --git-dir > /dev/null 2>&1; then
branch=$(git branch --show-current 2>/dev/null)
echo "Branch: $branch"
# Recent commits on this branch
echo ""
echo "Recent commits:"
git log --oneline -5 2>/dev/null | sed 's/^/ /'
# Uncommitted changes
changes=$(git status --short 2>/dev/null | wc -l | tr -d ' ')
if [ "$changes" -gt 0 ]; then
echo ""
echo "Uncommitted changes ($changes files):"
git status --short 2>/dev/null | head -10 | sed 's/^/ /'
if [ "$changes" -gt 10 ]; then
echo " ... and $((changes - 10)) more"
fi
fi
# Check if branch is behind remote
behind=$(git rev-list --count HEAD..@{u} 2>/dev/null)
ahead=$(git rev-list --count @{u}..HEAD 2>/dev/null)
if [ -n "$behind" ] && [ "$behind" -gt 0 ]; then
echo ""
echo "WARNING: Branch is $behind commits behind remote"
fi
if [ -n "$ahead" ] && [ "$ahead" -gt 0 ]; then
echo "Branch is $ahead commits ahead of remote (unpushed)"
fi
fi
# --- CI status (if gh CLI is available) ---
if command -v gh > /dev/null 2>&1; then
# Get the most recent PR for this branch
pr_info=$(gh pr view --json number,title,state,statusCheckRollup 2>/dev/null)
if [ -n "$pr_info" ]; then
pr_num=$(echo "$pr_info" | jq -r '.number')
pr_title=$(echo "$pr_info" | jq -r '.title')
pr_state=$(echo "$pr_info" | jq -r '.state')
echo ""
echo "Active PR: #$pr_num - $pr_title ($pr_state)"
# CI check status
failing=$(echo "$pr_info" | jq -r '.statusCheckRollup[]? | select(.conclusion == "FAILURE") | .name' 2>/dev/null)
if [ -n "$failing" ]; then
echo "FAILING CI checks:"
echo "$failing" | sed 's/^/ - /'
fi
fi
fi
# --- Active TODO/FIXME count ---
todo_count=$(grep -rn "TODO\|FIXME\|HACK\|XXX" --include="*.js" --include="*.ts" --include="*.py" . 2>/dev/null | grep -v node_modules | grep -v ".git" | wc -l | tr -d ' ')
if [ "$todo_count" -gt 0 ]; then
echo ""
echo "Open TODOs/FIXMEs: $todo_count"
fi
echo ""
echo "=== END CONTEXT ==="
HOOKEOF
chmod +x .claude/hooks/inject-context.sh2. Add to Config
cat > .claude/settings.json << 'EOF'
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash .claude/hooks/commit-quality-gate.sh"
},
{
"type": "command",
"command": "bash .claude/hooks/block-dangerous.sh"
}
]
}
],
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "bash .claude/hooks/inject-context.sh"
}
]
}
]
}
}
EOF3. Test
# Make some changes to create interesting context
echo "// TODO: add input validation" >> src/app.js
git add src/app.js
# Start a new session
claudeHere is what you see when the session starts with context injection:
$ claude
Hook: inject-context.sh
=== SESSION CONTEXT ===
Branch: main
Recent commits:
abc1234 feat: initial project setup
Uncommitted changes (1 files):
M src/app.js
Open TODOs/FIXMEs: 1
=== END CONTEXT ===
Claude Code v1.x.x
I can see you're on the main branch with 1 uncommitted change to
src/app.js and 1 open TODO. What would you like to work on?
>Claude now starts every session knowing your branch, recent commits, uncommitted changes, CI status, and open TODOs. No more wasting the first 3 messages on "what branch am I on?"
What Just Happened?
The SessionStart hook runs before Claude says anything. The output becomes part of Claude's initial context, so it starts the conversation already informed. This is what gstack calls "re-grounding" -- you always start from reality, not assumptions.
Demo 14: Continuous Learning Observer
Inspired by ECC's
observe.sh-- a PostToolUse hook that watches what Claude does and learns from it.
The Problem
You've been using Claude for a month. Which tools does it use most? How often does it read files vs write them? Which commands does it run repeatedly? Without data, you can't optimize your workflow or write better CLAUDE.md instructions.
Build It
1. Create the Observer
cd ~/claude-demos/demo-11
cat > .claude/hooks/observe.sh << 'HOOKEOF'
#!/bin/bash
# PostToolUse Hook: Log every tool call for analysis
# Inspired by ECC's continuous learning pattern
input=$(cat)
tool_name=$(echo "$input" | jq -r '.tool_name // "unknown"')
session_id="${CLAUDE_SESSION_ID:-unknown}"
# Create log directory
log_dir="$HOME/.claude/tool-logs"
mkdir -p "$log_dir"
# Log file per day
log_file="$log_dir/$(date +%Y-%m-%d).jsonl"
# Extract useful context based on tool type
case "$tool_name" in
"Bash")
detail=$(echo "$input" | jq -r '.tool_input.command // ""' | head -1 | cut -c1-100)
;;
"Read")
detail=$(echo "$input" | jq -r '.tool_input.file_path // ""')
;;
"Write"|"Edit")
detail=$(echo "$input" | jq -r '.tool_input.file_path // ""')
;;
"Glob"|"Grep")
detail=$(echo "$input" | jq -r '.tool_input.pattern // ""')
;;
*)
detail=""
;;
esac
# Write structured log entry (JSONL format)
echo "{\"ts\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"session\":\"$session_id\",\"tool\":\"$tool_name\",\"detail\":\"$detail\"}" >> "$log_file"
exit 0
HOOKEOF
chmod +x .claude/hooks/observe.sh2. Create the Analysis Script
This is what you run at the end of the day to see patterns:
cat > .claude/hooks/analyze-usage.sh << 'HOOKEOF'
#!/bin/bash
# Analyze tool usage patterns from observer logs
# Run manually: bash .claude/hooks/analyze-usage.sh
log_dir="$HOME/.claude/tool-logs"
if [ ! -d "$log_dir" ]; then
echo "No tool logs found. Use Claude for a while first."
exit 0
fi
echo "=== Claude Code Usage Report ==="
echo "Period: $(ls "$log_dir"/*.jsonl 2>/dev/null | head -1 | xargs basename .jsonl 2>/dev/null) to $(ls "$log_dir"/*.jsonl 2>/dev/null | tail -1 | xargs basename .jsonl 2>/dev/null)"
echo ""
echo "--- Tool Usage Frequency ---"
cat "$log_dir"/*.jsonl 2>/dev/null | jq -r '.tool' | sort | uniq -c | sort -rn
echo ""
echo "--- Most Read Files ---"
cat "$log_dir"/*.jsonl 2>/dev/null | jq -r 'select(.tool=="Read") | .detail' | sort | uniq -c | sort -rn | head -10
echo ""
echo "--- Most Common Bash Commands ---"
cat "$log_dir"/*.jsonl 2>/dev/null | jq -r 'select(.tool=="Bash") | .detail' | sort | uniq -c | sort -rn | head -10
echo ""
echo "--- Sessions per Day ---"
cat "$log_dir"/*.jsonl 2>/dev/null | jq -r '.ts[:10] + " " + .session' | sort -u | cut -d' ' -f1 | uniq -c
HOOKEOF
chmod +x .claude/hooks/analyze-usage.sh3. Add to Config
Add PostToolUse to your settings:
cat > .claude/settings.json << 'EOF'
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash .claude/hooks/commit-quality-gate.sh"
},
{
"type": "command",
"command": "bash .claude/hooks/block-dangerous.sh"
}
]
}
],
"PostToolUse": [
{
"hooks": [
{
"type": "command",
"command": "bash .claude/hooks/observe.sh"
}
]
}
],
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "bash .claude/hooks/inject-context.sh"
}
]
}
]
}
}
EOF4. Use It
Work with Claude normally for a session. Then check your data:
bash .claude/hooks/analyze-usage.shSample output:
$ bash .claude/hooks/analyze-usage.sh
=== Claude Code Usage Report ===
Period: 2026-04-10 to 2026-04-10
--- Tool Usage Frequency ---
47 Read
23 Bash
15 Edit
8 Write
6 Grep
3 Glob
--- Most Read Files ---
8 src/app.js
5 package.json
4 CLAUDE.md
3 src/utils.js
--- Most Common Bash Commands ---
5 npm test
4 git status
3 git diff --cached
2 npx eslint src/
--- Sessions per Day ---
3 2026-04-10What Just Happened?
This is the gstack telemetry pattern adapted for individual use. After a week, you will know exactly which files Claude reads most (put them in CLAUDE.md), which commands it runs repeatedly (make them skills), and where it spends its time.
When Things Go Wrong
Hooks are just shell scripts, and shell scripts break in predictable ways. Here are the four most common failures and how to fix them.
Problem 1: Missing Shebang Line
You write a hook script but forget the #!/bin/bash at the top:
# BAD: No shebang line
input=$(cat)
tool_name=$(echo "$input" | jq -r '.tool_name // empty')
# ...What happens: The system tries to execute the script with the default shell, which might be sh (not bash). Bash-specific features like [[ ]] tests, $(( )) arithmetic, or {1..10} brace expansion silently fail or produce wrong results. The script exits with code 1 (error), and the operation proceeds anyway because exit 1 means "hook error," not "block."
The fix: Always start every hook script with #!/bin/bash:
#!/bin/bash
# GOOD: Explicit bash interpreter
input=$(cat)Problem 2: Exit Code Confusion (1 vs 2)
This is the single most common hook bug. You want to block an operation, but you use exit 1 instead of exit 2:
# BAD: Using exit 1 to "block"
if echo "$command" | grep -q "dangerous"; then
echo "This is dangerous!"
exit 1 # WRONG: This means "error in hook" -- operation STILL PROCEEDS
fiWhat happens: Claude sees exit code 1, interprets it as "the hook script had an error," and proceeds with the operation anyway. Your "safety gate" is silently doing nothing.
The fix: Always use exit 2 for intentional blocks:
# GOOD: Using exit 2 to block
if echo "$command" | grep -q "dangerous"; then
echo "BLOCKED: This is dangerous!"
exit 2 # CORRECT: This means "intentional block" -- operation is PREVENTED
fiQuick Reference for Exit Codes
exit 0-- "Everything is fine, proceed"exit 1-- "My script broke, but let Claude continue" (fail-open)exit 2-- "I am deliberately blocking this operation" (fail-closed)
If you remember only one thing from this chapter: exit 2 blocks, exit 1 does not.
Problem 3: Hook Blocking Legitimate Operations
Your hook is too aggressive. You wrote a pattern to block rm -rf but it also catches rm -rf node_modules/ (a perfectly normal cleanup operation), or your commit format check rejects merge commits that Git auto-generates:
> Clean up the project by removing node_modules
Claude wants to run: Bash
rm -rf node_modules/
Hook: block-dangerous.sh
BLOCKED: Destructive rm command
But this is a legitimate operation...The fix: Make your patterns more specific. Add allowlists for known-safe operations:
# BETTER: Allow specific safe rm targets
if echo "$command" | grep -qE 'rm\s+(-[a-zA-Z]*f[a-zA-Z]*\s+|--force\s+)'; then
# Allowlist: these are safe to delete
if echo "$command" | grep -qE 'rm\s+-rf\s+(node_modules|\.cache|dist|build|__pycache__|\.pytest_cache)\s*$'; then
exit 0 # Known-safe cleanup target
fi
# Block everything else
echo "BLOCKED: Destructive rm command"
exit 2
fiGeneral principle: Start restrictive, then add specific exceptions as you encounter false positives. Never go the other direction (start permissive, then try to block everything bad).
Problem 4: Hook Script Not Executable
You created the script but forgot chmod +x:
$ claude
> Commit these changes
Hook error: Permission denied: .claude/hooks/commit-quality-gate.sh
(Hook returned exit code 1, proceeding with operation)
Claude wants to run: Bash
git commit -m "update stuff"
# The bad commit goes through because the hook ERRORED (exit 1),
# and errors are non-blocking!What happens: The OS cannot execute the script. The hook system catches the permission error and returns exit code 1 (error). Since errors are non-blocking by design, the operation proceeds -- your safety gate is completely bypassed.
The fix: Always run chmod +x after creating hook scripts:
chmod +x .claude/hooks/commit-quality-gate.sh
chmod +x .claude/hooks/block-dangerous.sh
chmod +x .claude/hooks/inject-context.sh
chmod +x .claude/hooks/observe.shPro tip: Add a check in your CLAUDE.md:
# Hook Maintenance
After creating or modifying any hook script in .claude/hooks/,
always run: chmod +x .claude/hooks/*.shReference: Complete Hook Event Data
| Event | stdin Fields | Can Block | Best Use |
|---|---|---|---|
| PreToolUse | tool_name, tool_input | Yes (exit 2) | Security gates, format enforcement |
| PostToolUse | tool_name, tool_input, tool_output | No | Logging, formatting, linting |
| SessionStart | session_id, model | No | Context injection, environment checks |
| Stop | (none) | No | Notifications, cleanup, usage reports |
| Notification | message | No | Forwarding to Slack/phone |
Environment Variables Available in Hooks
| Variable | Value |
|---|---|
CLAUDE_PROJECT_DIR | Project root path |
CLAUDE_SESSION_ID | Current session identifier |
CLAUDE_MODEL | Model being used |
Exercise: Build Your Hook Suite
Take the four hooks from this chapter and deploy them on a real project you're working on. Then extend them:
- Extend the commit gate: Add a check that blocks commits with
console.login production code (but allows it in test files) - Extend the dangerous blocker: Add patterns specific to your stack (e.g.,
DROP INDEXfor database projects,kubectl delete namespacefor k8s projects) - Extend the context injector: Add your project's deployment status (e.g., check if staging and production are on the same commit)
- Extend the observer: Add timing data -- how long does each tool call take? Which tools are slowest?
Success Criteria
- [ ] All four hooks are configured in
.claude/settings.json - [ ] Commit quality gate blocks bad messages AND
--no-verify - [ ] Dangerous command blocker catches force pushes and destructive SQL
- [ ] Session starts with full project context (branch, changes, CI, TODOs)
- [ ] Observer logs are generating and
analyze-usage.shproduces a report
Knowledge Check
Test your understanding of hooks with these questions.
Deep Dive: How Hooks Integrate with Tool Calling
Hooks sit at a specific point in Claude Code's tool-call pipeline. For the full picture of how Claude selects tools, constructs parameters, and processes results, see Appendix A05: Tool Calling Internals.
Key concepts from A05 that relate to hooks:
- Tool selection: Claude chooses a tool based on the task. The PreToolUse hook fires after tool selection but before execution -- you see what Claude chose but can override the decision.
- JSON Schema matching: The
tool_inputyour hook receives is the exact JSON object Claude constructed for the tool call. Understanding the schema helps you write precise checks. - Permission gating: Hooks run alongside (not instead of) Claude Code's built-in permission system. A tool call must pass both the permission check AND all PreToolUse hooks.
Summary
Hooks are the automation layer that makes Claude Code safe and smart by default. The four patterns in this chapter -- quality gates, safety blockers, context injection, and usage observation -- are what separates a casual user from someone who has Claude working as a proper team member.
Key points:
- PreToolUse + exit code 2 = the only way to programmatically block Claude from doing something
- Exit code 1 means "hook error" (non-blocking); exit code 2 means "intentional block" (blocking) -- do not confuse them
- SessionStart context injection eliminates the "cold start" problem every session
- PostToolUse observers turn your usage patterns into optimization data
- Stack multiple hooks on the same event -- they run in order, and any one can block
- Always include
#!/bin/bashand runchmod +xon every hook script
Next: Chapter 6: Custom Skills and Slash Commands -- Build reusable workflows like /deep-research, /review, and /investigate.