TL;DR

Claude Code hooks let you attach shell commands, HTTP endpoints, or even AI prompts to lifecycle events: file edits, tool calls, session starts. They run deterministically, every time, unlike instructions Claude might interpret loosely. This tutorial walks through five hooks I actually use in production: an auto-formatter, a destructive-command blocker, an AI-powered lint gate, an automatic test runner, and a session-start context injector. Every hook includes the full configuration JSON and the backing script.

Why Hooks Changed How I Use Claude Code

I spent my first month with Claude Code adding the same reminders to every prompt. “Run prettier after editing.” “Don’t delete the build directory.” “Check the tests before you say you’re done.” Claude followed these instructions maybe 80% of the time. Good enough for casual use, maddening for a codebase where one missed format check means a failed CI pipeline.

Hooks fixed that problem entirely. Instead of hoping Claude remembers to format a file, a PostToolUse hook fires prettier --write on every edit. Instead of trusting that Claude won’t run rm -rf /, a PreToolUse hook intercepts the command and blocks it before execution. The LLM doesn’t get a vote. The hook runs every time, the same way, with zero prompt engineering required.

The shift from “Claude, please remember to do X” to “X happens automatically, always” was the biggest reliability improvement I made to my Claude Code workflow all year. These are the five hooks I rely on every day.

How Claude Code Hooks Work

A hook is a handler (a shell command, HTTP call, or AI prompt) that fires at a specific point in Claude Code’s lifecycle. If you’ve been comparing terminal coding agents, hooks are one of the features that set Claude Code apart from the competition. You configure hooks in your settings file (project-scoped, user-scoped, or local), and Claude Code runs them deterministically whenever the matching event occurs.

Lifecycle Events

Claude Code exposes 30 lifecycle events. The ones you’ll use most:

EventWhen It FiresCan Block?
PreToolUseBefore Claude calls a tool (Bash, Edit, Write, etc.)Yes
PostToolUseAfter a tool call completesYes
UserPromptSubmitWhen you press Enter on a promptYes
StopWhen Claude finishes a responseYes
SessionStartWhen a session begins or resumesNo

The “Can Block?” column is what makes hooks useful. A PreToolUse hook that exits with code 2 blocks the tool call entirely and feeds the error message back to Claude so it can adjust.

Handler Types

Five handler types exist, each suited to different complexity levels:

Command hooks run a shell command. They receive the event’s JSON payload on stdin and communicate results through exit codes and stdout. You’ll use these 90% of the time.

HTTP hooks POST the event JSON to a URL, which works well for integrating with external services or shared team tooling.

MCP tool hooks call a tool on a connected MCP server, bridging hooks into your existing MCP integrations without custom scripts.

For validation that needs judgment rather than pattern matching, prompt hooks send a text prompt to a fast Claude model (Haiku by default) for single-turn evaluation. The model returns an ok: true or ok: false decision as JSON.

Agent hooks are the heaviest option: they spawn a sub-agent with access to tools like Read and Grep for multi-turn codebase verification. If you’ve used Claude Code subagents before, agent hooks follow the same principle but triggered automatically instead of on demand.

Configuration Structure

Hooks live in your settings file under the hooks key. The basic shape:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": ".claude/hooks/my-script.sh"
          }
        ]
      }
    ]
  }
}

The matcher field filters which tool calls trigger the hook. "Bash" matches only Bash tool calls. "Edit|Write" matches both. Omitting it (or using "*") matches everything. Matchers also support regex when the pattern contains characters beyond letters, digits, underscores, and pipes.

Put this in .claude/settings.json for project-scoped hooks (shared via git), ~/.claude/settings.json for user-scoped, or .claude/settings.local.json for local-only hooks that stay out of version control.

Hook 1: Auto-Format Every File Edit

Every time Claude edits or creates a file, this hook runs your formatter on it automatically. No more reviewing diffs full of inconsistent indentation, and no more manually running prettier after every session.

Configuration

Add this to .claude/settings.json:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write|NotebookEdit",
        "hooks": [
          {
            "type": "command",
            "command": ".claude/hooks/auto-format.sh",
            "timeout": 15
          }
        ]
      }
    ]
  }
}

Script

Create .claude/hooks/auto-format.sh:

#!/usr/bin/env bash
set -euo pipefail

INPUT=$(cat /dev/stdin)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')

if [[ -z "$FILE_PATH" || ! -f "$FILE_PATH" ]]; then
  exit 0
fi

EXT="${FILE_PATH##*.}"

case "$EXT" in
  js|jsx|ts|tsx|json|css|md|html|yaml|yml)
    npx prettier --write "$FILE_PATH" 2>/dev/null
    ;;
  py)
    python3 -m black --quiet "$FILE_PATH" 2>/dev/null
    python3 -m isort --quiet "$FILE_PATH" 2>/dev/null
    ;;
  go)
    gofmt -w "$FILE_PATH" 2>/dev/null
    ;;
  rs)
    rustfmt "$FILE_PATH" 2>/dev/null
    ;;
esac

exit 0
chmod +x .claude/hooks/auto-format.sh

How It Works

The PostToolUse event fires after any Edit, Write, or NotebookEdit call. The hook reads the event JSON from stdin, extracts the file_path from tool_input, checks the extension, and runs the appropriate formatter. Exit code 0 means success, so Claude continues normally. If the formatter isn’t installed for a given language, the 2>/dev/null silently swallows the error.

The 15-second timeout prevents a pathological formatter from blocking the entire session. In practice, formatting a single file takes under a second.

Lesson learned: I originally set the matcher to "*". Bad idea. The hook fired on every tool call, including Bash, Read, and Grep. Reading a file doesn’t produce a file_path in the same schema position, so the hook would parse nothing and exit cleanly, but the overhead of spawning a shell process on every single tool call added visible latency. Narrowing the matcher to "Edit|Write|NotebookEdit" cut the noise completely.

Hook 2: Block Destructive Commands

Claude occasionally generates destructive commands — rm -rf, git push --force, DROP TABLE — especially when it’s debugging a stubborn test failure and gets creative. This PreToolUse hook intercepts those commands before they execute.

Add this to .claude/settings.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": ".claude/hooks/block-destructive.sh"
          }
        ]
      }
    ]
  }
}

Then create .claude/hooks/block-destructive.sh:

#!/usr/bin/env bash
set -euo pipefail

COMMAND=$(jq -r '.tool_input.command // ""' < /dev/stdin)

BLOCKED_PATTERNS=(
  'rm -rf /'
  'rm -rf ~'
  'rm -rf \.'
  'git push.*--force'
  'git reset --hard'
  'git clean -fd'
  'DROP TABLE'
  'DROP DATABASE'
  'truncate '
  'chmod -R 777'
  'mkfs\.'
  'dd if='
  '> /dev/sd'
)

for pattern in "${BLOCKED_PATTERNS[@]}"; do
  if echo "$COMMAND" | grep -qiE "$pattern"; then
    jq -n --arg reason "Blocked: command matches destructive pattern '$pattern'" '{
      hookSpecificOutput: {
        hookEventName: "PreToolUse",
        permissionDecision: "deny",
        permissionDecisionReason: $reason
      }
    }'
    exit 0
  fi
done

exit 0

Under the Hood

Before Claude runs any Bash command, the PreToolUse event fires. The script extracts the command string, loops through a blocklist of regex patterns, and if any match, returns a JSON response with permissionDecision: "deny". Claude receives the denial reason as feedback and adjusts its approach, usually by asking you what to do instead.

The patterns are intentionally conservative. rm -rf . (with the escaped dot) catches attempts to wipe the current directory, but rm -rf ./build (a common and safe cleanup) doesn’t match because the dot is followed by a slash, not a line end. Tune the patterns to your project’s risk profile.

Why This Beats Permission Mode

Claude Code’s built-in permission system already prompts before destructive commands. But if you’re running in auto or acceptEdits mode for speed, those prompts are suppressed. This hook fires regardless of permission mode. It’s a hard gate that can’t be bypassed by configuration, only by editing the hook itself.

I run Claude in auto mode for most of my work because the constant approval prompts kill my flow (I covered the broader problem of AI agent guardrails in an earlier post). The command blocker is what makes auto mode viable. I’ve watched Claude attempt git push --force three times in one session while trying to fix a rebase gone wrong. Each time, the hook caught it, Claude saw the denial reason, and it switched to a safer approach without me lifting a finger. Auto mode is fast but reckless without the hook catching the worst commands.

Hook 3: AI-Powered Code Quality Gate

Some validations can’t be captured in a regex. “Does this code follow our error handling conventions?” or “Is this SQL query safe from injection?” require judgment. Prompt hooks delegate that judgment to a fast Claude model.

Add this to your settings:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "prompt",
            "prompt": "Review the code change in the JSON input below. Check for: 1) SQL injection via string concatenation, 2) hardcoded secrets or API keys, 3) unvalidated user input passed to shell commands, 4) debug/console.log statements left in production code. If you find any issue, respond with {\"ok\": false, \"reason\": \"<specific issue>\"}. If the code is clean, respond with {\"ok\": true}.",
            "timeout": 15
          }
        ]
      }
    ]
  }
}

The Execution Flow

After every Edit or Write, Claude Code sends the tool call details to a separate Haiku model instance. That model reads the code change, checks for the four categories listed in the prompt, and returns a structured JSON decision. If it finds a hardcoded API key or a SQL injection pattern, the ok: false response stops Claude and surfaces the reason.

Fifteen seconds sounds generous, but prompt hooks hit an LLM endpoint, so they’re inherently slower than a shell script. Haiku typically responds in 1-3 seconds, but network hiccups happen. Fifteen seconds is generous enough to survive a slow response without blocking the session indefinitely.

The tradeoff: prompt hooks add latency to every matching tool call. On a session where Claude edits 30 files, that’s 30 additional LLM calls at 1-3 seconds each, up to 90 seconds of cumulative overhead. I run this hook only on projects with strict security requirements (anything touching user data or payment flows). For a personal blog or a side project, the auto-formatter and command blocker are enough.

You can reduce the blast radius by scoping the matcher. The if field accepts a single permission rule, so to cover multiple tools you define separate hook handlers:

{
  "hooks": [
    {
      "type": "prompt",
      "prompt": "...",
      "if": "Write(src/api/**)"
    },
    {
      "type": "prompt",
      "prompt": "...",
      "if": "Edit(src/api/**)"
    }
  ]
}

This restricts the AI quality gate to files under src/api/, leaving everything else untouched.

Hook 4: Auto-Run Tests After Changes

The “verify your work” hook. After Claude edits a source file, this hook runs the related tests automatically and feeds the results back as context. Configuration:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": ".claude/hooks/auto-test.sh",
            "timeout": 120
          }
        ]
      }
    ]
  }
}

Create .claude/hooks/auto-test.sh:

#!/usr/bin/env bash
set -uo pipefail

INPUT=$(cat /dev/stdin)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')

if [[ -z "$FILE_PATH" || ! -f "$FILE_PATH" ]]; then
  exit 0
fi

# Skip non-source files
case "$FILE_PATH" in
  *.md|*.json|*.yaml|*.yml|*.toml|*.txt|*.css|*.html)
    exit 0
    ;;
esac

# Detect test framework and run
if [[ -f "pyproject.toml" || -f "pytest.ini" || -f "setup.cfg" ]]; then
  # Python: run pytest on the changed file's test
  DIR=$(dirname "$FILE_PATH")
  BASE=$(basename "$FILE_PATH" .py)
  TEST_FILE="$DIR/test_${BASE}.py"
  
  if [[ -f "$TEST_FILE" ]]; then
    OUTPUT=$(python3 -m pytest "$TEST_FILE" --tb=short -q 2>&1) || true
  elif [[ -d "tests" ]]; then
    OUTPUT=$(python3 -m pytest tests/ --tb=short -q 2>&1) || true
  else
    exit 0
  fi
elif [[ -f "package.json" ]]; then
  # JS/TS: run related tests
  OUTPUT=$(npx jest --findRelatedTests "$FILE_PATH" --no-coverage 2>&1) || true
elif [[ -f "go.mod" ]]; then
  # Go: run package tests
  PKG_DIR=$(dirname "$FILE_PATH")
  OUTPUT=$(go test "./$PKG_DIR/..." -count=1 -short 2>&1) || true
else
  exit 0
fi

# Feed results back to Claude as context
if [[ -n "$OUTPUT" ]]; then
  TRIMMED=$(echo "$OUTPUT" | tail -20)
  jq -n --arg ctx "Test results for $FILE_PATH:\n$TRIMMED" \
    '{ additionalContext: $ctx }'
fi

exit 0

How It Works

After every file edit, the hook checks whether the changed file is a source file (skipping markdown, config, etc.), detects which test framework the project uses, and runs the relevant tests. The test output gets fed back to Claude via the additionalContext field in the JSON response. Claude sees the results in its context and can react to failures immediately, without you having to ask “did the tests pass?”

The || true after each test command prevents the hook from failing when tests fail. We want test failures passed to Claude as context rather than crashing the hook. Exit code 0 with additionalContext is the right pattern: “the hook succeeded, and here’s what it found.”

I set the timeout to 120 seconds because test suites vary wildly. A Go package’s unit tests finish in under a second; a Python integration suite might take 30 seconds. If your tests are slower than two minutes per run, you probably want to narrow the scope (run only the single test file, not the entire suite).

Hook 5: Session-Start Context Injector

Every time you start or resume a Claude Code session, this hook loads project context (the current git branch, recent commits, open issues, running services) so Claude starts with situational awareness instead of a blank slate. Configuration:

{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "startup|resume",
        "hooks": [
          {
            "type": "command",
            "command": ".claude/hooks/session-context.sh",
            "timeout": 10
          }
        ]
      }
    ]
  }
}

Create .claude/hooks/session-context.sh:

#!/usr/bin/env bash
set -uo pipefail

BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "not a git repo")
LAST_COMMITS=$(git log --oneline -5 2>/dev/null || echo "no commits")
DIRTY_FILES=$(git diff --name-only 2>/dev/null | head -10)
STAGED=$(git diff --cached --name-only 2>/dev/null | head -10)

# Check for running dev servers
RUNNING_PORTS=""
if command -v lsof &>/dev/null; then
  RUNNING_PORTS=$(lsof -iTCP -sTCP:LISTEN -P 2>/dev/null | grep -E ':(3000|5173|8000|8080|4321)' | awk '{print $9}' | sort -u || true)
fi

# Build context
CONTEXT="Project state at session start:
Branch: $BRANCH

Last 5 commits:
$LAST_COMMITS"

if [[ -n "$DIRTY_FILES" ]]; then
  CONTEXT="$CONTEXT

Uncommitted changes:
$DIRTY_FILES"
fi

if [[ -n "$STAGED" ]]; then
  CONTEXT="$CONTEXT

Staged for commit:
$STAGED"
fi

if [[ -n "$RUNNING_PORTS" ]]; then
  CONTEXT="$CONTEXT

Dev servers listening:
$RUNNING_PORTS"
fi

jq -n --arg ctx "$CONTEXT" '{ additionalContext: $ctx }'

What Gets Injected

Both fresh starts (startup) and resumed sessions (resume) trigger the SessionStart event. The script gathers git state, uncommitted files, and running dev servers, then returns everything as additionalContext. Claude receives this context at the very beginning of the conversation, before your first prompt.

The startup|resume matcher ensures the hook fires whether you’re starting fresh or picking up where you left off. The clear and compact subtypes are intentionally excluded. When you clear a session, you probably don’t want the old context polluting the new one.

The Payoff

Without this hook, my first message in every Claude Code session used to be “run git status and git log –oneline -5.” That’s two tool calls, two permission prompts (in default mode), and 10 seconds of overhead before I could ask my actual question. The hook handles all of that before I type a single character.

Debugging Hooks

When a hook misbehaves, start with the /hooks command. It opens an interactive browser showing every configured hook, its source file, matcher, and handler details. If your hook isn’t listed, the problem is in the configuration file path, not the script.

Next, check exit codes. Exit 0 means success (parse stdout for JSON). Exit 2 means a blocking error (stderr gets shown to the user). Exit 1 or 3-255 means a non-blocking error (first line of stderr appears in the transcript, but execution continues).

The fastest debugging cycle is to test hooks manually. Pipe a fake event JSON into your script:

echo '{"tool_input":{"command":"rm -rf /"}}' | .claude/hooks/block-destructive.sh
echo $?

If the exit code and stdout match what you expect, the hook logic is correct and any remaining issue is in the configuration (wrong matcher, wrong event, wrong file path).

Hooks vs Skills vs CLAUDE.md

Claude Code has three extension mechanisms, and picking the wrong one wastes effort:

MechanismWhen to UseRuns How
CLAUDE.mdProject conventions, coding style, preferred librariesRead by Claude at session start; advisory
SkillsReusable multi-step workflows (deploy, review, publish)Invoked on demand via the Skill tool
HooksEnforcement, automation, validation that must always happenFires automatically on lifecycle events

CLAUDE.md is guidance that Claude might follow or might not. Skills are procedures Claude invokes when you ask for them (I used a spec-driven approach with Claude Code in the spec-driven development tutorial). Hooks are the only mechanism that’s guaranteed to fire every time, regardless of what Claude decides to do.

If you’d be upset when Claude skips something, make it a hook. Everything else goes in CLAUDE.md or a skill.

FAQ

What are Claude Code hooks?

Hooks are user-defined handlers (shell commands, HTTP endpoints, or AI prompts) that fire automatically at specific points in Claude Code’s lifecycle. Unlike instructions in CLAUDE.md that Claude interprets flexibly, hooks execute deterministically every time the matching event occurs.

What lifecycle events do Claude Code hooks support?

30 events across nine categories: per-session (SessionStart, SessionEnd, Setup), per-turn (UserPromptSubmit, Stop), tool loop (PreToolUse, PostToolUse, PermissionRequest, PostToolBatch), agent/team (SubagentStart, SubagentStop, TeammateIdle), file/config (FileChanged, CwdChanged, ConfigChange, InstructionsLoaded), compaction (PreCompact, PostCompact), worktree (WorktreeCreate, WorktreeRemove), MCP, and display events. PreToolUse and PostToolUse are the most commonly used.

How are hooks different from skills in Claude Code?

Skills are reusable AI workflows that Claude invokes when asked (via the Skill tool). Hooks fire automatically on lifecycle events without Claude choosing to run them. Use skills for multi-step procedures; use hooks for enforcement and automation that must always happen.

Can Claude Code hooks block destructive commands?

Yes. A PreToolUse hook that exits with code 2 or returns permissionDecision: "deny" blocks the tool call entirely. Claude receives the denial reason as feedback and adjusts its approach. This works regardless of the session’s permission mode.

What is a prompt hook in Claude Code?

A prompt hook sends a text prompt to a fast Claude model (Haiku by default) for single-turn evaluation. The model returns a structured JSON decision. Prompt hooks are slower than command hooks (1-3 seconds vs milliseconds) but can make judgment calls that regex can’t, like detecting SQL injection patterns or evaluating code quality.

How do I debug Claude Code hooks?

Three approaches: use the /hooks command to verify your hook is loaded, test scripts manually by piping fake JSON to stdin (echo '{"tool_input":{...}}' | ./script.sh), and check exit codes (0 = success, 2 = blocking error, 1/3-255 = non-blocking error with stderr shown in transcript).

Sources

Bottom Line

Five hooks cover 90% of what I need from Claude Code automation. The auto-formatter alone saves me from reviewing formatting diffs on every PR. The destructive-command blocker has caught three rm -rf attempts in the past month (all during aggressive debugging loops). The session-context injector shaved a “git status → git log” ritual off every session start.

Start with Hook 1 (auto-format) and Hook 2 (command blocker). They take five minutes to set up and pay for themselves immediately. Add the others as your workflow demands. The full configuration files and scripts from this tutorial are ready to copy into your .claude/ directory.