Module 5: Skills and Hooks: automating your workflow

Hooks: Claude Code's Lifecycle Events

Hooks: Claude Code's Lifecycle Events

Overview

Skills give you slash commands for on-demand tasks. Hooks do something different: they run scripts automatically at specific points in Claude Code's lifecycle. You don't invoke them — they fire on their own when the configured event happens.

Think of hooks as sensors on a production line. When Claude writes a file, a hook can run the linter automatically. When Claude runs a command, a hook can check it isn't destructive. When Claude finishes a task, a hook can run the tests. All without you doing a thing.

This capsule covers the 5 lifecycle events, how to configure hooks in settings.json, how to write hook scripts, how to use matchers to filter tools, and how to control the flow with return codes.


What Hooks are

A hook is an association between a Claude Code lifecycle event and a script that runs when that event fires.

┌──────────────────────────────────────────────────────────────┐
│                    LIFECYCLE                                 │
│                                                              │
│  ┌──────────────┐     ┌──────────────┐                       │
│  │ PreToolUse   │──▶  │ Check        │──▶ Proceed?           │
│  └──────────────┘     │ before       │    Yes → continue     │
│         │             │ acting       │    No → block         │
│         ▼             └──────────────┘                       │
│  ┌──────────────┐                                            │
│  │ [Claude uses │                                            │
│  │  the tool]   │                                            │
│  └──────────────┘                                            │
│         │                                                    │
│         ▼                                                    │
│  ┌──────────────┐     ┌──────────────┐                       │
│  │ PostToolUse  │──▶  │ Validate     │──▶ Log/format/test    │
│  └──────────────┘     │ after        │                       │
│         │             │ acting       │                       │
│         ▼             └──────────────┘                       │
│  ┌──────────────┐                                            │
│  │Notification  │──▶ When Claude sends a notification        │
│  └──────────────┘                                            │
│         │                                                    │
│         ▼                                                    │
│  ┌──────────────┐                                            │
│  │ Stop         │──▶ When the main agent stops               │
│  └──────────────┘                                            │
│         │                                                    │
│         ▼                                                    │
│  ┌──────────────┐                                            │
│  │SubagentStop  │──▶ When a subagent stops                   │
│  └──────────────┘                                            │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Every available hook event

Claude Code exposes 22+ events organized by category. Here's the full list as of April 2026:

Session Lifecycle:

EventWhen it fires
SessionStartWhen a session starts or resumes
SessionEndWhen a session ends
InstructionsLoadedWhen CLAUDE.md or .claude/rules/*.md is loaded

Per-Turn Events:

EventWhen it fires
UserPromptSubmitWhen you submit a prompt, before Claude processes it
StopWhen Claude finishes responding
StopFailureWhen the turn ends due to an API error

Tool Execution (Agentic Loop):

EventWhen it fires
PreToolUseBefore a tool runs (can block it)
PostToolUseAfter a tool completes successfully
PostToolUseFailureAfter a tool fails
PermissionRequestWhen the permission dialog appears
PermissionDeniedWhen the auto mode classifier denies a tool

Agent Team / Subagents:

EventWhen it fires
SubagentStartWhen a subagent is launched
SubagentStopWhen a subagent finishes
TeammateIdleWhen an agent team teammate is about to go idle
TaskCreatedWhen a task is created via TaskCreate
TaskCompletedWhen a task is marked complete

File & Configuration Events:

EventWhen it fires
ConfigChangeWhen a configuration file changes mid-session
CwdChangedWhen the working directory changes
FileChangedWhen a watched file changes on disk
WorktreeCreateWhen a worktree is created
WorktreeRemoveWhen a worktree is removed

Context Management:

EventWhen it fires
PreCompactBefore context compaction
PostCompactAfter compaction completes

MCP Integration:

EventWhen it fires
ElicitationWhen an MCP server asks for user input
ElicitationResultAfter the user responds

Others:

EventWhen it fires
NotificationWhen Claude Code sends a notification

The 5 events you'll use most

Out of all the events above, these are the ones that dominate daily use. We'll cover each in depth:

EventWhen it firesTypical use
PreToolUseBefore Claude uses a toolGating: block actions that aren't allowed
PostToolUseAfter Claude uses a toolValidation: lint, format, tests
UserPromptSubmitBefore Claude processes your promptInject context, validate prompts
SessionStartWhen a session starts/resumesLoad initial context, secrets, variables
StopWhen Claude finishes respondingFinal tests, notifications, summaries

PreToolUse and PostToolUse are the most used, and they support matcher to filter by tool. UserPromptSubmit is key for AI pipelines — you can inject dynamic context. SessionStart is for setup (loading env vars, secrets). Stop lets you run final checks.

Important newer events (post-2025):

  • UserPromptSubmit → validate/enrich prompts before they're processed
  • PreCompact / PostCompact → hook into context window compaction
  • SessionStart / SessionEnd → session setup/teardown
  • InstructionsLoaded → tracking when CLAUDE.md loads
  • Elicitation → MCP integration when a server asks for input

Where they're configured

Hooks are configured in Claude Code's settings.json files:

  • Global: ~/.claude/settings.json (applies to all your projects)
  • Project: .claude/settings.json (shared with the team via git)
  • Personal: .claude/settings.local.json (just you, in .gitignore)

The configuration uses a hooks key with event names as sub-keys. Each hook has:

  • type: The handler type (command, http, prompt, or agent)
  • command / url / etc.: Depending on the type
  • matcher (optional, for PreToolUse and PostToolUse): Filters by a specific tool
  • if (optional): An extra condition for running the hook
  • timeout (optional): Timeout in seconds
  • statusMessage (optional): A message shown while it runs

The 4 Hook Handler types

Claude Code supports 4 handler types for hooks:

TypeDescriptionWhen to use it
commandRuns a shell commandLocal scripts, CLIs, the default option
httpPOST request to an HTTP endpointIntegrating with external APIs (Slack, Datadog, your own services)
promptA single-turn LLM evaluationDecisions that need judgment (is this prompt safe?)
agentA check run by a subagentComplex validations that need context

Example with the command handler (the most common):

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "/path/to/script.sh",
            "if": "Bash(rm *)",
            "timeout": 30
          }
        ]
      }
    ]
  }
}

Example with the http handler:

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "http",
            "url": "https://my-service.com/claude-done",
            "timeout": 10
          }
        ]
      }
    ]
  }
}

Example with the prompt handler (an LLM evaluates):

{
  "hooks": {
    "UserPromptSubmit": [
      {
        "hooks": [
          {
            "type": "prompt",
            "prompt": "Does this prompt contain credentials or sensitive data? Answer only 'yes' or 'no'."
          }
        ]
      }
    ]
  }
}

Important hook behavior

  • The hook's stdout is shown to Claude as user feedback. That means you can communicate information to Claude from your scripts.
  • On PreToolUse, a hook that returns a non-zero exit code blocks the tool from running. This is how you build safety gates.
  • On other events, a non-zero exit code is reported as an error but doesn't block the operation.

Visibility of hook output

  • stdout → Claude sees it as user feedback. Use it to communicate results.
  • stderr → Recorded in logs, but Claude doesn't see it directly.
  • Exit code 0 → Hook succeeded, the operation continues.
  • Exit code ≠ 0 → For PreToolUse, BLOCKS the tool. For PostToolUse, it's just reported.

Configuration format

Each event receives an array of hooks. A complete example:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write",
        "command": "./scripts/pre-write-check.sh"
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Write",
        "command": "./scripts/post-write-validate.sh"
      }
    ],
    "Notification": [
      {
        "command": "echo 'Claude sent a notification'"
      }
    ],
    "Stop": [
      {
        "command": "npm test"
      }
    ],
    "SubagentStop": [
      {
        "command": "echo 'Subagent finished'"
      }
    ]
  }
}

Tool Matchers

Matchers filter which tool should trigger a PreToolUse or PostToolUse hook. Without a matcher, the hook runs for every tool.

Tools available for matching

MatcherWhat it intercepts
WriteClaude writes or edits a file
ExecuteClaude runs a terminal command
ReadClaude reads a file
GrepClaude searches inside files
GlobClaude searches for files by pattern
WebFetchClaude makes an HTTP request

Example: a hook only for file writes

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write",
        "command": "npx eslint --fix $CLAUDE_FILE_PATH"
      }
    ]
  }
}

This hook only runs when Claude writes a file. It doesn't run when it reads files, executes commands, or searches the codebase.

Multiple hooks for the same event

You can configure multiple hooks for a single event:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write",
        "command": "npx prettier --write $CLAUDE_FILE_PATH"
      },
      {
        "matcher": "Write",
        "command": "npx eslint $CLAUDE_FILE_PATH"
      },
      {
        "matcher": "Execute",
        "command": "echo 'Command executed: $CLAUDE_TOOL_INPUT'"
      }
    ]
  }
}

Hooks run in sequential order.


Environment variables in Hooks

When a hook runs, Claude Code passes information about the action through environment variables:

VariableDescriptionAvailable in
CLAUDE_FILE_PATHPath of the file being written/readWrite, Read
CLAUDE_TOOL_INPUTThe tool's full input (JSON)All
CLAUDE_TOOL_NAMEThe tool's name (Write, Execute, etc.)All
CLAUDE_SESSION_IDThe current session's IDAll
CLAUDE_PROJECT_DIRThe project's root directoryAll

Example: using environment variables

#!/bin/bash
# scripts/post-write-validate.sh

echo "File modified: $CLAUDE_FILE_PATH"
echo "Tool: $CLAUDE_TOOL_NAME"

# Only run lint on TypeScript files
if [[ "$CLAUDE_FILE_PATH" == *.ts ]] || [[ "$CLAUDE_FILE_PATH" == *.tsx ]]; then
    npx eslint "$CLAUDE_FILE_PATH"
fi

Return codes: controlling the flow

The hook script's return code determines what Claude Code does next:

Return codeEffect on PreToolUseEffect on PostToolUse
0 (success)Claude proceeds with the actionClaude continues normally
Non-0 (error)Claude blocks the actionClaude sees the error and can act on it

This is fundamental for PreToolUse: you can use hooks to block actions that don't meet your conditions.

Example: blocking commits without tests

#!/bin/bash
# scripts/pre-commit-check.sh

if [[ "$CLAUDE_TOOL_INPUT" == *"git commit"* ]]; then
    # Run tests before allowing the commit
    npm test --silent
    if [ $? -ne 0 ]; then
        echo "ERROR: Tests failing. Fix tests before committing."
        exit 1  # Blocks the commit
    fi
fi

exit 0  # Allows the action
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Execute",
        "command": "./scripts/pre-commit-check.sh"
      }
    ]
  }
}

If the tests fail, the hook returns 1 and Claude does not run the commit. Claude sees the error message and can decide to fix the tests first.


PreToolUse: gating before acting

PreToolUse runs before Claude uses a tool. It's your chance to validate, check, or block an action.

Basic example: logging actions

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write",
        "command": "echo 'Claude is about to write: $CLAUDE_FILE_PATH'"
      }
    ]
  }
}

Intermediate example: validating a naming convention

#!/bin/bash
# scripts/check-naming.sh

FILE="$CLAUDE_FILE_PATH"

# Check that component files use PascalCase
if [[ "$FILE" == src/components/*.tsx ]]; then
    BASENAME=$(basename "$FILE" .tsx)
    if [[ ! "$BASENAME" =~ ^[A-Z] ]]; then
        echo "ERROR: Components must use PascalCase. '$BASENAME' does not."
        exit 1
    fi
fi

# Check that test files carry the test_ prefix
if [[ "$FILE" == tests/*.py ]]; then
    BASENAME=$(basename "$FILE")
    if [[ ! "$BASENAME" == test_* ]]; then
        echo "ERROR: Tests must start with 'test_'. '$BASENAME' does not."
        exit 1
    fi
fi

exit 0

If Claude tries to create src/components/userProfile.tsx (lowercase), the hook blocks the write and Claude sees the error. Claude can then fix the name to UserProfile.tsx.

Advanced example: preventing dangerous operations

#!/bin/bash
# scripts/safety-gate.sh

INPUT="$CLAUDE_TOOL_INPUT"

# Block rm -rf in important directories
if [[ "$INPUT" == *"rm -rf"* ]]; then
    for dir in "src" "lib" "app" "node_modules" ".git"; do
        if [[ "$INPUT" == *"$dir"* ]]; then
            echo "BLOCKED: rm -rf is not allowed in critical directories ($dir)"
            exit 1
        fi
    done
fi

# Block git push --force to main
if [[ "$INPUT" == *"git push"*"--force"* ]] && [[ "$INPUT" == *"main"* ]]; then
    echo "BLOCKED: force push to main is not allowed"
    exit 1
fi

exit 0

PostToolUse: validating after acting

PostToolUse runs after Claude uses a tool. It's ideal for validations, formatting, and automatic checks.

Basic example: auto-format with Prettier

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write",
        "command": "npx prettier --write $CLAUDE_FILE_PATH 2>/dev/null || true"
      }
    ]
  }
}

Every time Claude writes a file, Prettier formats it automatically.

Intermediate example: lint after a write

#!/bin/bash
# scripts/post-write-lint.sh

FILE="$CLAUDE_FILE_PATH"

# Only lint JS/TS files
case "$FILE" in
    *.js|*.jsx|*.ts|*.tsx)
        echo "Running ESLint on $FILE..."
        npx eslint "$FILE" 2>&1
        ;;
    *.py)
        echo "Running ruff on $FILE..."
        ruff check "$FILE" 2>&1
        ;;
    *.css|*.scss)
        echo "Running Stylelint on $FILE..."
        npx stylelint "$FILE" 2>&1
        ;;
esac

If the linter finds errors, Claude sees them in the output and can fix them automatically on its next action.

Advanced example: automatic tests after changes

#!/bin/bash
# scripts/post-write-test.sh

FILE="$CLAUDE_FILE_PATH"

# If a source file was modified, find and run its test
if [[ "$FILE" == src/*.ts ]] || [[ "$FILE" == src/*.tsx ]]; then
    # Build the test's path
    TEST_FILE="${FILE/src\//tests/test_}"
    TEST_FILE="${TEST_FILE/.tsx/.test.tsx}"
    TEST_FILE="${TEST_FILE/.ts/.test.ts}"
    
    if [ -f "$TEST_FILE" ]; then
        echo "Running related tests: $TEST_FILE"
        npx vitest run "$TEST_FILE" --reporter=verbose 2>&1
    fi
fi

# If a test was modified, run that test directly
if [[ "$FILE" == *test* ]] || [[ "$FILE" == *spec* ]]; then
    echo "Running modified test: $FILE"
    npx vitest run "$FILE" --reporter=verbose 2>&1
fi

Stop: final checks

Stop runs when Claude Code's main agent stops. It's ideal for global checks and notifications at the end of a work session.

Basic example

{
  "hooks": {
    "Stop": [
      {
        "command": "echo 'Agent stopped. Checking state...'"
      }
    ]
  }
}

Intermediate example: post-task summary

#!/bin/bash
# scripts/stop-summary.sh

echo "=== Post-task summary ==="
echo "Modified files:"
git diff --name-only 2>/dev/null | head -10
echo ""
echo "Tests:"
npm test --silent 2>/dev/null && echo "All passing" || echo "Some tests failing"
echo "========================="

Advanced example: a notification with the full state

#!/bin/bash
# scripts/on-stop.sh

MODIFIED=$(git diff --name-only 2>/dev/null | wc -l | tr -d ' ')
UNTRACKED=$(git ls-files --others --exclude-standard 2>/dev/null | wc -l | tr -d ' ')

echo "=== Agent stopped ==="
echo "Modified files: $MODIFIED"
echo "New files: $UNTRACKED"

# Type checking
if [ -f "tsconfig.json" ]; then
    npx tsc --noEmit 2>&1 | tail -1
fi

# Lint
npx eslint src/ --quiet 2>&1 | tail -3

echo "========================="

Notification: custom alerts

Notification runs when Claude sends a notification. It's useful for logging or custom alerts.

Basic example

{
  "hooks": {
    "Notification": [
      {
        "command": "echo 'Claude sent a notification' >> ~/claude-notifications.log"
      }
    ]
  }
}

SubagentStop: validating subagent results

SubagentStop runs when a subagent (invoked by the main agent) finishes its work. It's useful for validating a subagent's partial results.

Basic example

{
  "hooks": {
    "SubagentStop": [
      {
        "command": "echo 'Subagent completed its task'"
      }
    ]
  }
}

Comparisons and decisions

Hooks vs doing everything manually

AspectManualWith Hooks
ConsistencyDepends on you rememberingAlways runs
SpeedYou have to type/paste commandsAutomatic
Human errorFrequent (forgetting lint, tests)Eliminated
Initial setupNone10-15 minutes
Overhead per actionVariable (depends on what you remember)Fixed (the script always runs)

Claude Code hooks vs git hooks

AspectClaude Code HooksGit Hooks
EventsPreToolUse, PostToolUse, Notification, Stop, SubagentStoppre-commit, post-commit, pre-push, etc.
When they runDuring a Claude Code sessionOn git operations
ContextThey know which tool Claude used and which file it modifiedThey only know there's a commit/push
Who runs themClaude Code (the agent)Git (the tool)
Complementary✅ Yes — they validate during development✅ Yes — they validate on the way into git

They don't compete — they complement each other. Claude Code hooks catch problems during development. Git hooks catch them at commit time.

When to use each event

NeedEvent
Block an action before it happensPreToolUse
Validate the result of an actionPostToolUse
Auto-format code after a writePostToolUse + Write matcher
Prevent destructive commandsPreToolUse + Execute matcher
Run related testsPostToolUse + Write matcher
Global check when work endsStop
Customize alerts or loggingNotification
Validate subagent resultsSubagentStop

Common patterns

Pattern 1: Post-write validation pipeline

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write",
        "command": "npx prettier --write $CLAUDE_FILE_PATH 2>/dev/null"
      },
      {
        "matcher": "Write",
        "command": "npx eslint $CLAUDE_FILE_PATH 2>/dev/null"
      }
    ]
  }
}

Every file written goes through format → lint automatically.

Pattern 2: Safety gate for commands

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Execute",
        "command": "./scripts/safety-gate.sh"
      }
    ]
  }
}

Every command passes through a safety filter before it runs.

Pattern 3: Continuous testing

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write",
        "command": "./scripts/run-related-tests.sh"
      }
    ]
  }
}

Every time Claude writes code, the related tests run.

Pattern 4: A summary when work ends

{
  "hooks": {
    "Stop": [
      {
        "command": "./scripts/stop-summary.sh"
      }
    ]
  }
}

Every time Claude finishes its work, a summary script runs and shows the project's final state.


Pitfalls and edge cases

Pitfall 1: Slow hooks

The mistake: A PostToolUse hook that runs the entire test suite after every write.

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write",
        "command": "npm test"
      }
    ]
  }
}

The problem: If npm test takes 30 seconds and Claude writes 10 files, that's 5 minutes of waiting on hooks alone.

The fix: Run only the related tests, not the whole suite:

#!/bin/bash
# Only tests for the modified file
npx vitest run --reporter=dot "$CLAUDE_FILE_PATH" 2>/dev/null || true

Or move the full suite to the Stop hook (it runs once, when Claude finishes).

Pitfall 2: Infinite loops

The mistake: A PostToolUse hook that modifies the file Claude just wrote, which triggers another PostToolUse, which modifies the file, which triggers another PostToolUse...

The problem: An infinite loop.

The fix: Claude Code has loop protections built in, but design your hooks to be idempotent. A format hook (Prettier) is safe because formatting an already-formatted file produces no changes.

Preventing infinite loops

What happens if a PostToolUse hook modifies a file? Does that trigger another PostToolUse?

Answer: Claude Code has built-in loop protection. Hooks don't recursively trigger other hooks. If your PostToolUse hook uses the Write tool internally, that write does NOT trigger another PostToolUse cycle.

Good practice: Design your hooks as idempotent operations — they produce the same result no matter how many times they run.

Pitfall 3: Hooks that block legitimate operations

The mistake: A PreToolUse that blocks rm without considering context.

if [[ "$INPUT" == *"rm"* ]]; then
    exit 1  # Blocks EVERYTHING containing "rm"
fi

The problem: It blocks legitimate operations like npm run format (which may contain "rm" in its internals).

The fix: Be specific about the patterns you block:

if [[ "$INPUT" =~ ^rm\ -rf\ / ]]; then
    exit 1  # Only blocks rm -rf at the system root
fi

Pitfall 4: Scripts without execute permissions

The mistake: Creating a hook script and forgetting to make it executable.

The fix:

chmod +x scripts/post-write-validate.sh

Pitfall 5: Hooks that don't handle errors

The mistake: A hook that fails with an unhandled error and blocks the whole session.

The fix: Use || true for hooks that shouldn't block:

{
  "command": "npx eslint $CLAUDE_FILE_PATH 2>/dev/null || true"
}

The || true ensures the hook always returns 0 (success), even if the linter fails. Use this for informational hooks (where you want to see the error but not block).


Complete worked example

Scenario: you're setting up a TypeScript + React project with a full set of quality hooks.

Script structure

scripts/
├── hooks/
│   ├── pre-write-check.sh
│   ├── post-write-validate.sh
│   └── on-stop.sh

settings.json

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write",
        "command": "./scripts/hooks/pre-write-check.sh"
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Write",
        "command": "./scripts/hooks/post-write-validate.sh"
      }
    ],
    "Stop": [
      {
        "command": "./scripts/hooks/on-stop.sh"
      }
    ]
  }
}

Scripts

pre-write-check.sh:

#!/bin/bash
FILE="$CLAUDE_FILE_PATH"

# Make sure nothing gets written into node_modules
if [[ "$FILE" == *node_modules* ]]; then
    echo "BLOCKED: don't modify files in node_modules"
    exit 1
fi

# Check component naming
if [[ "$FILE" == src/components/*.tsx ]]; then
    BASENAME=$(basename "$FILE" .tsx)
    if [[ ! "$BASENAME" =~ ^[A-Z] ]]; then
        echo "BLOCKED: components must use PascalCase ($BASENAME)"
        exit 1
    fi
fi

exit 0

post-write-validate.sh:

#!/bin/bash
FILE="$CLAUDE_FILE_PATH"

case "$FILE" in
    *.ts|*.tsx|*.js|*.jsx)
        npx prettier --write "$FILE" 2>/dev/null
        npx eslint "$FILE" --quiet 2>/dev/null || true
        ;;
    *.css|*.scss)
        npx prettier --write "$FILE" 2>/dev/null
        ;;
esac

on-stop.sh:

#!/bin/bash
echo "=== Final check ==="
npx tsc --noEmit 2>&1 | tail -3
npm test --silent 2>&1 | tail -3
echo "================================="

Practice exercises

Exercise 1: Basic — Your first PostToolUse hook

Configure a PostToolUse hook that logs a line every time Claude writes a file.

Requirements:

  • Only fires when Claude uses the Write tool
  • Prints the path of the file written
  • Must not block the operation
Solution

Add to .claude/settings.json:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write",
        "command": "echo \"File written: $CLAUDE_FILE_PATH\""
      }
    ]
  }
}

Exercise 2: Basic — A PostToolUse hook for formatting

Configure a hook that runs Prettier automatically after every write.

Requirements:

  • Only on JS/TS/CSS files
  • Must not block if Prettier fails
  • Must be quiet (no Prettier output when there are no changes)
Solution

Create scripts/hooks/auto-format.sh:

#!/bin/bash
FILE="$CLAUDE_FILE_PATH"

case "$FILE" in
    *.js|*.jsx|*.ts|*.tsx|*.css|*.scss|*.json)
        npx prettier --write "$FILE" --log-level=warn 2>/dev/null || true
        ;;
esac
chmod +x scripts/hooks/auto-format.sh

In .claude/settings.json:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write",
        "command": "./scripts/hooks/auto-format.sh"
      }
    ]
  }
}

Exercise 3: Intermediate — A PreToolUse safety gate

Create a hook that blocks dangerous commands before Claude runs them.

Requirements:

  • Block rm -rf in directories like src/, .git/, node_modules/
  • Block git push --force to main or master
  • Allow everything else
  • Show a clear message when something is blocked
Solution

Create scripts/hooks/safety-gate.sh:

#!/bin/bash
INPUT="$CLAUDE_TOOL_INPUT"

# Block rm -rf in critical directories
if [[ "$INPUT" == *"rm -rf"* ]]; then
    for dir in "src" ".git" "node_modules" "lib" "app"; do
        if [[ "$INPUT" == *"$dir"* ]]; then
            echo "⛔ BLOCKED: rm -rf in a critical directory ($dir)"
            exit 1
        fi
    done
fi

# Block force push to main/master
if [[ "$INPUT" == *"git push"*"--force"* ]]; then
    if [[ "$INPUT" == *"main"* ]] || [[ "$INPUT" == *"master"* ]]; then
        echo "⛔ BLOCKED: force push to main/master is not allowed"
        exit 1
    fi
fi

exit 0
chmod +x scripts/hooks/safety-gate.sh

In .claude/settings.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Execute",
        "command": "./scripts/hooks/safety-gate.sh"
      }
    ]
  }
}

Exercise 4: Intermediate — A complete post-write pipeline

Configure a pipeline that runs format → lint → type-check after every TypeScript file write.

Requirements:

  • Prettier for formatting
  • ESLint for linting
  • The TypeScript compiler (tsc) for type checking
  • Only on .ts and .tsx files
  • If lint or type-check fail, Claude should see the errors (but not be blocked)
Solution

Create scripts/hooks/post-write-pipeline.sh:

#!/bin/bash
FILE="$CLAUDE_FILE_PATH"

# Only process TypeScript files
case "$FILE" in
    *.ts|*.tsx)
        echo "--- Validation pipeline ---"
        
        # Step 1: Format
        npx prettier --write "$FILE" --log-level=warn 2>/dev/null
        
        # Step 2: Lint
        LINT_OUTPUT=$(npx eslint "$FILE" --quiet 2>&1)
        if [ -n "$LINT_OUTPUT" ]; then
            echo "⚠️  ESLint:"
            echo "$LINT_OUTPUT"
        fi
        
        # Step 3: Type check
        TSC_OUTPUT=$(npx tsc --noEmit 2>&1 | grep "$FILE")
        if [ -n "$TSC_OUTPUT" ]; then
            echo "⚠️  TypeScript:"
            echo "$TSC_OUTPUT"
        fi
        
        echo "--- Pipeline complete ---"
        ;;
esac

exit 0
chmod +x scripts/hooks/post-write-pipeline.sh

Exercise 5: Advanced — A complete hook system

Configure all 5 hook events for a real project, with a script for each one.

Requirements:

  • PreToolUse (Write): validate naming conventions
  • PreToolUse (Execute): safety gate for dangerous commands
  • PostToolUse (Write): format + lint
  • Stop: run tests + summary
  • Notification: log notifications
Solution

Use the configuration from this capsule's complete worked example as your base. Adapt the scripts to your project's conventions (language, framework, lint/test tooling).

Verify that:

  1. Every script is executable (chmod +x)
  2. The hooks run correctly (test each one)
  3. The PreToolUse hooks don't block legitimate operations
  4. The PostToolUse hooks aren't too slow
  5. The Stop hook gives you useful information when work ends

Summary

What you learned in this capsule:

  • Hooks are scripts that run automatically on Claude Code lifecycle events
  • The 5 most common events: PreToolUse, PostToolUse, Notification, Stop, SubagentStop (Claude Code exposes 20+ events in total — see the official docs)
  • Configuration lives in settings.json (global ~/.claude/settings.json, project .claude/settings.json, or personal .claude/settings.local.json)
  • Matchers filter which tool triggers the hook (Write, Execute, Read, etc.) -- only for PreToolUse and PostToolUse
  • Return codes: 0 = proceed, non-0 = block (on PreToolUse)
  • The hook's stdout is shown to Claude as user feedback
  • Environment variables: CLAUDE_FILE_PATH, CLAUDE_TOOL_INPUT, CLAUDE_TOOL_NAME give the hook context
  • Critical pitfalls: slow hooks, infinite loops, over-blocking, scripts without permissions

Next capsule: 04 - Automating validations -- how to build a complete quality pipeline using hooks.


Additional resources

Official documentation

  • Hooks — Complete documentation for hooks and lifecycle events
  • Settings — Where to configure hooks (global, project, local)
  • Plugins Reference — Hooks as part of the plugin system

Complementary