
Claude Code follows your CLAUDE.md instructions most of the time. But "most of the time" isn't good enough when the instruction is "never delete .env" or "always run Prettier after editing a file."

**Claude Code hooks are event-driven shell commands, LLM prompts, or agentic verifiers that execute deterministically at specific points in Claude Code's tool-call lifecycle. They block destructive actions, enforce code standards, and automate post-edit tasks without relying on the LLM to remember instructions.** Unlike [CLAUDE.md](https://docs.anthropic.com/en/docs/claude-code/claude-md) guidelines that Claude may occasionally skip, hooks fire every time, with no exceptions.

This tutorial is for developers and engineering leads who use [Claude Code](https://docs.anthropic.com/en/docs/claude-code/overview) and want deterministic guardrails around agent behavior. It assumes you have Claude Code installed and have worked with it on at least one project. We focus exclusively on **command hooks** (not prompt or agent hooks) for the two events you'll use most: PreToolUse and PostToolUse. For prompt and agent hook types, see the [Beyond Commands](#beyond-commands-prompt-and-agent-hooks) section.

As a software engineer who builds agentic development workflows daily, I've been implementing PreToolUse and PostToolUse hooks across various repositories. This tutorial distills what I've learned into the patterns that matter.

By the end, you'll have a working hook configuration that blocks dangerous commands, auto-formats code, and protects sensitive files.

> **Key Takeaways**
>
> - **Hooks are deterministic enforcement.** Unlike CLAUDE.md instructions, hooks fire every time, with no exceptions.
> - **PreToolUse** intercepts tool calls before execution. Use it to block destructive commands, protect files, or modify parameters.
> - **PostToolUse** reacts after a tool succeeds. Use it to auto-format, lint, or run tests on changed files.
> - **Exit code 2** is the magic number. It blocks the action and feeds the error back to Claude.
> - Start with three hooks: destructive command blocking, file protection, and auto-formatting. Add more as patterns emerge.

## What Are Claude Code Hooks?

Hooks are user-defined event handlers that execute shell commands, LLM prompts, or agentic verifiers at specific points in Claude Code's workflow. They were [introduced in June 2025](https://docs.anthropic.com/en/docs/claude-code/hooks) as part of Anthropic's push toward [agentic coding tools](https://www.anthropic.com/research/swe-bench-sonnet), and have grown from 6 event types to 17 as of early 2026. According to the [Claude Code changelog](https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md), major hook capabilities (including `updatedInput` for parameter rewriting and `prompt`/`agent` handler types) shipped across four releases between June and October 2025.

The core idea: instead of telling Claude "please format code with Prettier" in your [how to write a CLAUDE.md file](/posts/how-to-write-claude-md) guide and hoping it listens, you configure a PostToolUse hook that runs [Prettier](https://prettier.io/) automatically every time Claude writes a file. The formatter runs whether Claude remembers to or not.

Three handler types exist:

| Type | What It Does | When to Use |
|------|-------------|-------------|
| **command** | Runs a shell command | Deterministic checks: formatting, linting, file protection |
| **prompt** | Sends a single prompt to an LLM | Judgment calls, e.g. "is this response complete?" |
| **agent** | Spawns a multi-turn agent with tool access | Complex verification: run tests and analyze failures |

In practice, **command hooks dominate real-world configurations** because most guardrails are deterministic: block a command, format a file, protect a path. Prompt and agent hooks fill the gap when you need LLM judgment. This tutorial focuses on command hooks because they're the ones you should set up first.

## The Hook Lifecycle

Here's what happens when Claude Code processes a request:

```
User prompt
  -> Claude generates tool call (e.g., Edit a file)
    -> PreToolUse fires (can block, modify, or allow)
      -> Tool executes
        -> PostToolUse fires (can format, lint, test, log)
          -> Claude continues with next action
```

![Claude Code hook lifecycle showing PreToolUse firing before tool execution and PostToolUse firing after](/claude-code-hook-lifecycle.webp)

**PreToolUse** fires after Claude decides what tool to call and with what parameters, but *before* the tool actually runs. You can block the call, modify the parameters, or let it through.

**PostToolUse** fires after the tool succeeds. The action already happened, so you can't undo it. But you can react: format the written file, run a linter, trigger a test, log the action.

Claude Code supports 17 event types total. Here are the ones worth knowing about:

| Event | When It Fires | Can Block? |
|-------|--------------|------------|
| **PreToolUse** | Before a tool call executes | Yes |
| **PostToolUse** | After a tool call succeeds | No |
| **Stop** | When Claude finishes responding | Yes |
| **SessionStart** | When a session begins or resumes | No |
| **UserPromptSubmit** | When you submit a prompt | Yes |
| **Notification** | When Claude sends a notification | No |
| **SubagentStop** | When a sub-agent finishes | Yes |

The full list includes `PostToolUseFailure`, `PermissionRequest`, `SubagentStart`, `TeammateIdle`, `TaskCompleted`, `ConfigChange`, `WorktreeCreate`, `WorktreeRemove`, `PreCompact`, and `SessionEnd`. You don't need all of them. PreToolUse and PostToolUse cover the vast majority of practical use cases in my experience.

## Your First Hook: Configuration Walkthrough

Hooks live in [JSON settings files](https://docs.anthropic.com/en/docs/claude-code/settings). You have three options:

| File | Scope | Shareable |
|------|-------|-----------|
| `.claude/settings.json` | This project | Yes (commit to git) |
| `.claude/settings.local.json` | This project | No (gitignored) |
| `~/.claude/settings.json` | All your projects | No (local to your machine) |

For team-shared hooks (formatting, protected files), use `.claude/settings.json`. For personal preferences (notification style, debug logging), use `~/.claude/settings.json`.

Here's the structure:

```json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": ".claude/hooks/validate-bash.sh",
            "timeout": 10
          }
        ]
      }
    ]
  }
}
```

The key pieces:

- **Event name** (`PreToolUse`): when this hook fires
- **matcher**: a regex that filters which tool triggers this hook. `"Bash"` matches the Bash tool. `"Edit|Write"` matches either. `"mcp__.*"` matches all MCP tools. Omit it or use `""` to match everything.
- **hooks array**: one or more handlers to run. All matching hooks run in parallel.
- **type**: `"command"`, `"prompt"`, or `"agent"`
- **command**: the shell command to execute
- **timeout**: seconds before the hook is killed (default: 600)

Your hook script receives JSON on stdin with context about the event: the tool name, its parameters, the session ID, and the current working directory.

To debug hooks, run Claude Code with `claude --debug` or toggle verbose mode with `Ctrl+O` during a session. Without this, hooks fail silently.

## PreToolUse: Intercept Before Execution

**PreToolUse matters more than any other hook event for production safety.** It fires after Claude decides on a tool call but before that call executes, giving you a synchronous checkpoint to enforce constraints before any side effects occur. You can:

- **Block** the call (exit code 2, or return `permissionDecision: "deny"`)
- **Allow** it, bypassing the permission dialog (`permissionDecision: "allow"`)
- **Modify** the tool parameters before execution (`updatedInput`, available since v2.0.10)
- **Add context** that Claude sees before the tool runs (`additionalContext`)

### Example 1: Block Destructive Shell Commands

This hook prevents Claude from running `rm -rf`, `git push --force`, or `DROP TABLE`:

```bash
#!/bin/bash
# .claude/hooks/block-destructive.sh
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')

if [ -z "$COMMAND" ]; then
  exit 0
fi

# Block dangerous patterns
DANGEROUS_PATTERNS=(
  'rm -rf'
  'rm -r /'
  'git push --force'
  'git push -f'
  'DROP TABLE'
  'DROP DATABASE'
  'git reset --hard'
)

for pattern in "${DANGEROUS_PATTERNS[@]}"; do
  if echo "$COMMAND" | grep -qi "$pattern"; then
    echo "Blocked: command matches dangerous pattern '$pattern'" >&2
    exit 2
  fi
done

exit 0
```

Hook configuration:

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

Exit code 2 is critical here. It tells Claude Code this is a **blocking error**: the tool call is prevented and the stderr message is fed back to Claude as an error. Any other non-zero exit code is treated as a non-blocking warning (logged but ignored). This follows the convention documented in the [Anthropic Claude Code hooks reference](https://docs.anthropic.com/en/docs/claude-code/hooks#hook-exit-codes): exit 0 = success, exit 2 = blocking error, any other non-zero = non-blocking warning.

Here's what this looks like in practice. Without the hook, Claude silently executes the command:

```
$ claude "clean up the temp directory"

> Bash: rm -rf /tmp/project-cache

Done. Removed /tmp/project-cache and all contents.
```

With the hook active, the destructive command is caught and Claude adjusts:

```
$ claude "clean up the temp directory"

> Bash: rm -rf /tmp/project-cache
Hook blocked: command matches dangerous pattern 'rm -rf'

> Bash: find /tmp/project-cache -type f -delete

Done. Removed files in /tmp/project-cache while preserving the directory.
```

Claude receives the hook's error, understands the constraint, and finds a safer alternative. No manual intervention needed.

### Example 2: Protect Files From Edits

Some files should never be modified by Claude: `.env`, lock files, CI config.

```bash
#!/bin/bash
# .claude/hooks/protect-files.sh
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')

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

PROTECTED_PATTERNS=(
  ".env"
  "package-lock.json"
  "pnpm-lock.yaml"
  "yarn.lock"
  ".git/"
  ".github/workflows/"
)

for pattern in "${PROTECTED_PATTERNS[@]}"; do
  if [[ "$FILE_PATH" == *"$pattern"* ]]; then
    echo "Blocked: '$FILE_PATH' is a protected file" >&2
    exit 2
  fi
done

exit 0
```

```json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write|Bash",
        "hooks": [
          {
            "type": "command",
            "command": ".claude/hooks/protect-files.sh"
          }
        ]
      }
    ]
  }
}
```

Note the matcher: `"Edit|Write|Bash"` catches all three tools that could modify files. The Bash tool is included because Claude might use `sed` or `echo >` to write files.

### Example 3: Modify Tool Input

Since [v2.0.10](https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md), PreToolUse hooks can rewrite tool parameters before execution. This is a significant upgrade over the block-and-retry pattern.

Convert a destructive `rm -rf` into an interactive `rm -i`:

```bash
#!/bin/bash
# .claude/hooks/safer-rm.sh
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')

if echo "$COMMAND" | grep -q '^rm -rf'; then
  SAFER=$(echo "$COMMAND" | sed 's/^rm -rf/rm -ri/')
  jq -n --arg cmd "$SAFER" '{
    hookSpecificOutput: {
      hookEventName: "PreToolUse",
      permissionDecision: "allow",
      updatedInput: {
        command: $cmd
      }
    }
  }'
  exit 0
fi

exit 0
```

The `updatedInput` field replaces the tool's parameters before execution. Combined with `permissionDecision: "allow"`, the modified command runs without a permission prompt.

## PostToolUse: React After Execution

**PostToolUse is the quality enforcement layer.** It runs after every successful tool call, making it the ideal trigger for formatting, linting, and post-edit testing. The action is done. You can't undo a file write or a shell command. But you can:

- **Format** the file that was just written
- **Lint** the code that was just changed
- **Run tests** related to the changed file
- **Log** what happened for audit purposes

### Example 1: Auto-Format With Prettier

Every file Claude writes gets formatted automatically:

```json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write 2>/dev/null || true",
            "statusMessage": "Formatting with Prettier..."
          }
        ]
      }
    ]
  }
}
```

This is inline, no separate script file needed. It pipes the file path from stdin JSON through `jq`, then passes it to Prettier. The `2>/dev/null || true` ensures non-Prettier-supported files don't cause errors.

The `statusMessage` field shows a custom spinner while the hook runs, replacing the default "Running hook..." text.

### Example 2: Run Linter After Code Changes

```bash
#!/bin/bash
# .claude/hooks/lint-on-change.sh
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')

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

# Only lint TypeScript/JavaScript files
case "$FILE_PATH" in
  *.ts|*.tsx|*.js|*.jsx)
    RESULT=$(npx eslint --fix "$FILE_PATH" 2>&1)
    if [ $? -ne 0 ]; then
      echo "$RESULT" >&2
      exit 2
    fi
    ;;
esac

exit 0
```

When exit code 2 is returned from a PostToolUse hook, the error message is fed back to Claude. Claude sees the [ESLint](https://eslint.org/) errors and can fix them in its next turn, creating an automatic fix loop.

### Example 3: Trigger Related Tests

```bash
#!/bin/bash
# .claude/hooks/auto-test.sh
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')

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

# Find and run the matching test file
case "$FILE_PATH" in
  *.test.ts|*.test.tsx|*.spec.ts|*.spec.tsx)
    npx vitest run "$FILE_PATH" --reporter=verbose 2>&1
    ;;
  *.ts|*.tsx)
    TEST_FILE="${FILE_PATH%.ts}.test.ts"
    if [ -f "$TEST_FILE" ]; then
      npx vitest run "$TEST_FILE" --reporter=verbose 2>&1
    fi
    ;;
esac

exit 0
```

This runs the corresponding [Vitest](https://vitest.dev/) test file whenever Claude edits a source file. No other hook pays back its setup time faster. Claude gets immediate feedback on whether its changes broke something.

## Beyond Commands: Prompt and Agent Hooks

Command hooks handle deterministic checks. But sometimes you need judgment.

**Prompt hooks** send a single question to an LLM:

```json
{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "prompt",
            "prompt": "Review the conversation context: $ARGUMENTS\n\nAre all requested tasks complete? Respond with JSON: {\"ok\": true} to stop, or {\"ok\": false, \"reason\": \"what's missing\"} to continue.",
            "timeout": 30
          }
        ]
      }
    ]
  }
}
```

This Stop hook uses a fast model (Haiku by default) to evaluate whether Claude's work is actually done before stopping. If the evaluator finds incomplete work, Claude continues.

**Agent hooks** spawn a multi-turn agent with Read, Grep, and Glob access:

```json
{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "agent",
            "prompt": "Run the test suite and verify all tests pass. If any fail, explain what's wrong. $ARGUMENTS",
            "timeout": 120
          }
        ]
      }
    ]
  }
}
```

Use command hooks for formatting, linting, and file protection. Use prompt hooks for quick judgment calls. Use agent hooks for complex verification that requires reading files and running multiple commands.

## Hooks vs CLAUDE.md vs Skills

Three mechanisms configure Claude Code's behavior. Each serves a different purpose:

| | CLAUDE.md | Hooks | Skills |
|---|---|---|---|
| **Nature** | Suggestion | Enforcement | Contextual expertise |
| **Deterministic?** | No | Yes | No |
| **When it applies** | Every session | On matching events | When context matches |
| **Best for** | Guidelines, preferences | Rules, automation | Reusable workflows |
| **Example** | "Prefer Bun over npm" | "Block rm -rf" | "Deploy to staging" |

**The simplest rule: if violating the instruction would cause damage, use a hook. If violating it is merely suboptimal, use CLAUDE.md.** The decision is straightforward:

- **Preference?** -> CLAUDE.md. "Use named exports." Claude usually follows it.
- **Must never be violated?** -> Hook. "Never edit .env." The hook blocks it every time.
- **Reusable expertise?** -> Skill. "Deploy with these steps." Activates when relevant.

For a deeper dive on CLAUDE.md configuration, see [How to Write the Perfect CLAUDE.md File](/posts/how-to-write-claude-md).

## Five Common Mistakes

**1. Wrong exit code.** Exit code 2 blocks. Exit code 1 is a non-blocking warning. If your hook isn't preventing anything, check your exit code. I spent two hours wondering why my file-protection hook was "broken" because Claude kept editing `.env` no matter what. I added `echo` statements, rewrote the pattern matching twice, and finally ran `claude --debug` only to see `hook exited with code 1 (non-blocking)` in the output. Changed `exit 1` to `exit 2` and it worked instantly.

**2. Unquoted shell variables.** `$VAR` breaks on spaces. Always use `"$VAR"`. This is especially important when handling file paths from [jq](https://jqlang.github.io/jq/) output.

**3. Overly broad matchers.** A PreToolUse hook matching all tools fires on every Read, Glob, and Grep call, slowing everything down. I measured this in one project: an empty-matcher hook added ~80ms per tool call, and Claude makes 15-30 tool calls per prompt. That's 1-2 seconds of overhead per interaction, compounding across a session. Match only the tools you need: `"Bash"`, `"Edit|Write"`, or specific MCP tool patterns like `"mcp__github__.*"`.

**4. Missing `stop_hook_active` check.** If your Stop hook tells Claude to keep working, and Claude's next response triggers the Stop event again, you get an infinite loop. I hit this on my first prompt-based Stop hook. Claude generated 11 responses before I killed the session. Always check the `stop_hook_active` field in Stop hook input and exit 0 if it's true.

**5. Not testing with `--debug`.** Hooks fail silently by default. Run `claude --debug` to see full hook execution output, or toggle verbose mode with `Ctrl+O` during a session. This is the first thing to try when a hook isn't working.

## What Changes With Hooks

Guardrails for AI coding agents are not optional. The [2025 Stack Overflow Developer Survey](https://survey.stackoverflow.co/2025/) found that 84% of developers use or plan to use AI tools, yet only 33% trust the accuracy of AI-generated output. Two-thirds reported frustration with "AI solutions that are almost right, but not quite." Hooks bridge that gap by enforcing review constraints programmatically. After six months of running the three core hooks across four TypeScript and Python projects, here's what I've measured:

| Metric | Before Hooks | After Hooks |
|--------|-------------|-------------|
| Accidental destructive commands per month | 2-3 | 0 |
| Manual Prettier/Black runs per session | 5-10 | 0 |
| `.env` or lock file edits caught in review | ~1/week | 0 (blocked at source) |
| Average hook overhead per tool call | N/A | ~60ms |
| Time to set up hooks for a new project | N/A | 10 minutes |

## Quick-Start Configuration

Here's a complete `.claude/settings.json` with three practical hooks. Copy it, adjust the paths, and you're running:

```json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": ".claude/hooks/block-destructive.sh",
            "timeout": 5
          }
        ]
      },
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": ".claude/hooks/protect-files.sh",
            "timeout": 5
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write 2>/dev/null || true",
            "statusMessage": "Formatting..."
          }
        ]
      }
    ],
    "Notification": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "notify-send 'Claude Code' 'Needs your attention'"
          }
        ]
      }
    ]
  }
}
```

Create the hook scripts referenced above (`block-destructive.sh`, `protect-files.sh`), make them executable with `chmod +x`, and restart Claude Code. The hooks take effect on the next session.

Type `/hooks` inside Claude Code to see all active hooks and their sources.

## FAQ

### Do hooks work with sub-agents?

Yes. Hooks fire for tool calls made by sub-agents, not just the main session. A PreToolUse hook blocking `rm -rf` applies to sub-agent Bash calls too.

### Can I modify tool input with hooks?

Yes, since v2.0.10 (October 2025). Return `updatedInput` in your PreToolUse hook's JSON output to change tool parameters before execution.

### What happens if a hook times out?

The hook is killed and treated as a non-blocking failure. Claude continues as if the hook didn't exist. Set appropriate timeouts (5-10 seconds for simple checks, 30+ seconds for LLM-based hooks).

### Where should I put hook scripts?

`.claude/hooks/` in your project root. This keeps them version-controlled alongside your settings. Use `"$CLAUDE_PROJECT_DIR"` in hook commands for reliable path resolution.

### Can hooks run asynchronously?

Yes. Set `"async": true` on a command hook to run it in the background. Claude continues immediately. Async hooks can't block or modify tool calls. They're for logging, notifications, and background tasks.

### How do I debug hooks that aren't firing?

1. Run `claude --debug` to see hook execution logs
1. Check your matcher regex (it's case-sensitive)
1. Verify the hook script is executable (`chmod +x`)
1. Check the JSON structure in your settings file. A missing comma breaks everything.
1. Type `/hooks` to verify Claude Code loaded your hooks

### What is a Claude Code hook?

A Claude Code hook is a user-defined event handler (a shell command, LLM prompt, or agentic verifier) that fires automatically at a specific point in Claude Code's tool-call lifecycle. Hooks enforce rules deterministically, unlike [CLAUDE.md instructions](/posts/how-to-write-claude-md) which the LLM may occasionally skip. Claude Code supports 17 hook events as of early 2026, with PreToolUse and PostToolUse being the most widely used.

### How do I use Stop hooks without creating an infinite loop?

Check the `stop_hook_active` field in your Stop hook's stdin JSON. If it's `true`, exit 0 immediately. That means your hook already triggered a continuation and Claude is stopping again. Without this check, your Stop hook tells Claude to keep working, which triggers another Stop event, creating an infinite loop. Here's the guard pattern:

```bash
INPUT=$(cat)
ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active // false')
if [ "$ACTIVE" = "true" ]; then
  exit 0
fi
# Your actual stop hook logic here
```

## Start Using Claude Code Hooks Today

Claude Code hooks, especially PreToolUse and PostToolUse, are the enforcement layer that turns CLAUDE.md guidelines into guarantees. Start with the three-hook configuration above: blocking destructive commands, protecting sensitive files, and auto-formatting on every write. Those three alone will prevent the most common agent mistakes.

Once you're comfortable, explore prompt and agent hooks for judgment-based verification. Add more hooks as you discover patterns in your workflow. Every time Claude makes a mistake that a deterministic check could have caught, that's a new hook waiting to be written.

For the foundation that hooks build on, see [how to write a CLAUDE.md file](/posts/how-to-write-claude-md). CLAUDE.md provides the guidelines; hooks provide the guardrails.
