Skip to content

Chapter 5: Hooks — Automate Everything Claude Does ​

Learning Objectives ​

After completing this chapter, you will be able to:

  1. Explain the hook lifecycle and how PreToolUse, PostToolUse, SessionStart, and Stop events map to Claude's tool-call pipeline
  2. Write hook scripts that enforce commit quality, block dangerous commands, inject session context, and observe tool usage
  3. Use exit codes correctly: 0 (allow), 1 (error — operation still proceeds), 2 (intentional block)
  4. Configure hooks in settings.json with matchers, handler types, and event binding
  5. Debug common hook failures: missing shebang, wrong exit code, non-executable scripts

What You'll Build ​

Four production hooks that solve real problems:

  1. A commit quality gate that enforces message format, runs linters, and catches leaked secrets before they hit your repo
  2. A dangerous command blocker that stops --no-verify, DROP TABLE, and force pushes to main
  3. A session context injector that feeds Claude your git branch, recent PRs, and CI status on every startup
  4. 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
Done

The critical part: PreToolUse hooks can block tool execution by returning exit code 2. Everything else is observational.

Hook Events ​

EventWhenCan Block?Real Use Case
PreToolUseBefore tool runsYes (exit 2)Block --no-verify, enforce commit format
PostToolUseAfter tool runsNoRun linter, log usage, format code
SessionStartSession beginsNoInject git context, CI status, PR info
StopClaude finishesNoNotification, usage stats, session summary
NotificationClaude notifiesNoForward to Slack, phone push

Handler Types ​

TypeWhat It DoesWhen to Use
commandRuns a shell script90% of cases. Your bread and butter.
httpPOST to a webhookSlack notifications, telemetry endpoints
promptClaude evaluates a condition"Does this change break the public API?"
agentLaunches a subagentDeep code review before committing

Exit Code Protocol ​

Exit CodeMeaningEffect on Tool Execution
0Allow -- hook passedOperation proceeds normally
1Error in hook scriptOperation still proceeds (hook failure is non-fatal)
2Intentional blockTool 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).

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 ​

11
Commit Quality Gate
Intermediate~10 min

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 ​

bash
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:

bash
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.sh

3. Configure ​

bash
cat > .claude/settings.json << 'EOF'
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "bash .claude/hooks/commit-quality-gate.sh"
          }
        ]
      }
    ]
  }
}
EOF

4. Test It ​

bash
claude

Test 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:

terminal
$ 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"
terminal
> 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 message

Expected: The hook allows it through.

What Just Happened? ​

1
Bash
git commit -m 'update stuff'
↓
2
Bash
git commit -m 'chore: update project configuration'
↓
3
Bash
git commit --no-verify -m 'fix: bypass hooks'

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 --oneline truncates 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-version and semantic-release parse 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 a BREAKING CHANGE footer 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 ​

12
Dangerous Command Blocker
Intermediate~10 min

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 ​

bash
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.sh

2. Update Config ​

bash
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"
          }
        ]
      }
    ]
  }
}
EOF

3. Test ​

bash
claude

Try these -- all should be blocked:

Run rm -rf /tmp/important-data
terminal
> 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
terminal
> 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;
terminal
> 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? ​

1
Bash
rm -rf /tmp/important-data
↓
2
Bash
git push origin main --force
↓
3
Bash
psql -c 'DROP TABLE users;'
↓
4
Bash
ls -la src/

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 ​

13
Session Context Injector
Intermediate~10 min

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 ​

bash
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.sh

2. Add to Config ​

bash
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"
          }
        ]
      }
    ]
  }
}
EOF

3. Test ​

bash
# Make some changes to create interesting context
echo "// TODO: add input validation" >> src/app.js
git add src/app.js

# Start a new session
claude

Here is what you see when the session starts with context injection:

terminal
$ 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? ​

1
SessionStart
inject-context.sh
↓
2
Bash
git branch --show-current
↓
3
Bash
git status --short
↓
4
Bash
grep -rn TODO

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 ​

14
Continuous Learning Observer
Intermediate~10 min

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 ​

bash
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.sh

2. Create the Analysis Script ​

This is what you run at the end of the day to see patterns:

bash
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.sh

3. Add to Config ​

Add PostToolUse to your settings:

bash
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"
          }
        ]
      }
    ]
  }
}
EOF

4. Use It ​

Work with Claude normally for a session. Then check your data:

bash
bash .claude/hooks/analyze-usage.sh

Sample output:

terminal
$ 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-10

What Just Happened? ​

1
PostToolUse
observe.sh
↓
2
Bash
jq -r .tool_name
↓
3
Bash
echo >> $log_file
↓
4
Bash
analyze-usage.sh

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:

bash
# 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:

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:

bash
# 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
fi

What 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:

bash
# 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
fi

Quick 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:

terminal
> 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:

bash
# 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
fi

General 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:

terminal
$ 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:

bash
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.sh

Pro tip: Add a check in your CLAUDE.md:

markdown
# Hook Maintenance
After creating or modifying any hook script in .claude/hooks/,
always run: chmod +x .claude/hooks/*.sh

Reference: Complete Hook Event Data ​

Eventstdin FieldsCan BlockBest Use
PreToolUsetool_name, tool_inputYes (exit 2)Security gates, format enforcement
PostToolUsetool_name, tool_input, tool_outputNoLogging, formatting, linting
SessionStartsession_id, modelNoContext injection, environment checks
Stop(none)NoNotifications, cleanup, usage reports
NotificationmessageNoForwarding to Slack/phone

Environment Variables Available in Hooks ​

VariableValue
CLAUDE_PROJECT_DIRProject root path
CLAUDE_SESSION_IDCurrent session identifier
CLAUDE_MODELModel 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:

  1. Extend the commit gate: Add a check that blocks commits with console.log in production code (but allows it in test files)
  2. Extend the dangerous blocker: Add patterns specific to your stack (e.g., DROP INDEX for database projects, kubectl delete namespace for k8s projects)
  3. Extend the context injector: Add your project's deployment status (e.g., check if staging and production are on the same commit)
  4. 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.sh produces a report

Knowledge Check ​

Test your understanding of hooks with these questions.

Your PreToolUse hook script has a typo that causes a bash syntax error. What happens to the tool call Claude was trying to make?
The tool call is blocked -- errors are treated as rejections
The tool call proceeds -- exit code 1 (error) is non-blocking
Claude retries the hook automatically
The entire Claude session crashes
You want to block Claude from using BOTH the Write and Edit tools on files in the /config directory. Which matcher pattern should you use in settings.json?
Write, Edit
Write|Edit
Write&&Edit
You need two separate matcher entries
Which hook event fires BEFORE Claude executes a tool and can prevent that execution?
PostToolUse
SessionStart
PreToolUse
Stop
You configure a SessionStart hook that outputs your git branch and recent commits. Where does this output go?
It is printed to the terminal for the user to read
It is injected into Claude context so Claude can reference it
It is saved to a log file
It replaces the CLAUDE.md content for that session
You have three PreToolUse hooks configured on the Bash matcher. Hook A exits 0, Hook B exits 2, Hook C exits 0. What happens?
All three hooks run; the tool is blocked because Hook B returned exit 2
Only Hook A runs; hooks stop after the first one
The tool proceeds because two out of three hooks allowed it (majority wins)
Hook A and B run; Hook C is skipped because B already blocked

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_input your 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/bash and run chmod +x on every hook script

Next: Chapter 6: Custom Skills and Slash Commands -- Build reusable workflows like /deep-research, /review, and /investigate.

Released under MIT License