Hooks in Claude Code: A Practical Guide with Real Examples

Practical guide to Claude Code hooks: 3 hook types, 5 essential hooks, advanced patterns and common pitfalls. With copy-paste ready examples.

You probably don't need hooks in Claude Code.

At least that's what I thought. I've been using Claude Code daily for months, and everything worked. I tell it what to do, it does it, I review, I move on.

But "works" and "works well" are different things. The difference, in my case, was discovering that the instructions I wrote in CLAUDE.md were not deterministic. I could write "run vue-tsc after every change" and Claude would do it... when it deemed relevant. Or when the context hadn't been compacted and lost that instruction. Or when it didn't decide "this time it's not necessary."

A hook doesn't negotiate. It runs every time. Without exception.

That distinction — probabilistic instruction vs deterministic execution — is what makes hooks one of Claude Code's most underrated features. They don't show up during onboarding, the official docs present them as a dense technical reference, and most users don't discover them until something breaks in production for the third time.

This article is not a reference dump. It's the guide I wish I'd had: which hooks to install, in what order, what types exist, what goes wrong, and when NOT to use them.

Minimal anatomy

A hook is a command that runs automatically at a specific point in Claude Code's lifecycle. The configuration has three levels:

  1. Event: when it fires (before a tool call, after, on stop, on compaction...)
  2. Matcher: regex that filters whether the hook applies (tool name, event type)
  3. Handler: the command or prompt that runs
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "your-script.sh",
            "timeout": 30
          }
        ]
      }
    ]
  }
}

Where to configure

Location Scope Shareable
~/.claude/settings.json All your projects No
.claude/settings.json Current project Yes (goes to git)
.claude/settings.local.json Current project No (gitignored)

You can also use /hooks in Claude Code to manage them visually without editing JSON.

How your script responds

  • Exit 0: all good, proceed
  • Exit 2: blocking error — stderr is sent to Claude as feedback
  • Any other: non-blocking error, logged in verbose mode (Ctrl+O)

That's enough. Let's get to the real stuff.

The 3 hook types (same use case, 3 approaches)

This is something no guide explains well. Claude Code has three types of handlers, not just bash scripts. Let's see all three with the same use case: "ensure tests pass before Claude stops working."

Command hook (bash script)

The most direct. A script that runs tests and blocks if they fail:

#!/bin/bash
# Block Stop if tests don't pass

INPUT=$(cat)

# Prevent infinite loop — critical
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
  exit 0
fi

OUTPUT=$(npm test 2>&1)
if [ $? -ne 0 ]; then
  echo "Tests failing. Fix before stopping: $OUTPUT" >&2
  exit 2
fi
exit 0
{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": ".claude/hooks/test-gate.sh",
            "timeout": 120
          }
        ]
      }
    ]
  }
}

Prompt hook (single-turn LLM evaluation)

Instead of a script, you ask a model to evaluate whether Claude should stop. Useful when the decision isn't binary:

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "prompt",
            "prompt": "Evaluate whether Claude should stop working. Context: $ARGUMENTS\n\nCheck:\n1. All requested tasks are complete\n2. No pending errors\n3. No follow-up work remaining\n\nRespond with JSON: {\"ok\": true} to allow stopping, or {\"ok\": false, \"reason\": \"explanation\"} to continue.",
            "timeout": 30
          }
        ]
      }
    ]
  }
}

Agent hook (multi-turn subagent with tools)

The most powerful. A subagent that can read files, run grep, and reason across multiple turns:

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "agent",
            "prompt": "Verify that all unit tests pass. Run the test suite, read the results, and confirm there are no failures. $ARGUMENTS",
            "timeout": 120
          }
        ]
      }
    ]
  }
}

When to use each type

Aspect Command Prompt Agent
Speed Fast (ms-seconds) Medium (1-5s) Slow (5-30s)
Cost Zero LLM tokens More LLM tokens
Flexibility Rigid (exit codes) Medium (simple judgment) High (reasoning + tools)
Best for Binary checks (pass/fail) Subjective evaluations Checks that require reading code

My recommendation: start with command hooks always. Only upgrade to prompt or agent when you need judgment, not verification.

My 5 essential hooks

These are the hooks I install on every project. In this order, because each solves a more specific problem than the previous one.

1. Notification when Claude needs attention

The simplest and most useful hook. If you work with Claude in the background while doing something else, you need to know when it needs you:

{
  "hooks": {
    "Notification": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "osascript -e 'display notification \"Claude needs your attention\" with title \"Claude Code\"'"
          }
        ]
      }
    ]
  }
}

On Linux, replace osascript with notify-send "Claude Code" "Claude needs your attention".

2. Block destructive commands

This should be enabled by default. It isn't:

#!/bin/bash
# Block rm -rf, DROP TABLE, force push, and similar

INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')

BLOCKED_PATTERNS=("rm -rf /" "DROP TABLE" "DROP DATABASE" "git push --force" "git push -f" "> /dev/sda")

for pattern in "${BLOCKED_PATTERNS[@]}"; do
  if echo "$COMMAND" | grep -qi "$pattern"; then
    echo "Blocked by security hook: contains '$pattern'" >&2
    exit 2
  fi
done
exit 0
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": ".claude/hooks/block-destructive.sh"
          }
        ]
      }
    ]
  }
}

Note it's PreToolUse, not PostToolUse. We want to block BEFORE execution, not after.

3. Auto-format after every edit

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write 2>/dev/null",
            "timeout": 15
          }
        ]
      }
    ]
  }
}

Always exit 0 — we don't want a formatting issue to block Claude. If Prettier can't resolve something, you'll see it in your IDE. We redirect stderr to /dev/null so Claude doesn't get distracted.

4. Type-check after every edit

#!/bin/bash
# Run tsc/vue-tsc after every edit

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

# Only TS/Vue files
if [[ "$FILE_PATH" != *.ts && "$FILE_PATH" != *.tsx && "$FILE_PATH" != *.vue ]]; then
  exit 0
fi

OUTPUT=$(npx vue-tsc --noEmit 2>&1)
if [ $? -ne 0 ]; then
  echo "$OUTPUT" >&2
  exit 2
fi
exit 0

This one DOES block (exit 2). When vue-tsc fails, Claude gets the error and fixes it before moving on. It's the difference between a junior who hands you broken code and a teammate who builds before pushing.

Adapt vue-tsc to your stack: tsc for plain TypeScript, tsc --build for monorepos.

5. Re-inject context after compaction

The least known and most impactful hook in long sessions. When Claude compacts context, it can lose instructions from your CLAUDE.md. This hook re-injects them:

{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "compact",
        "hooks": [
          {
            "type": "command",
            "command": "echo 'Post-compaction reminder: use pnpm, not npm. Run pnpm test before committing. Follow patterns in /src/composables for new composables.'"
          }
        ]
      }
    ]
  }
}

The text you print to stdout is added as context for Claude. Simple, but it saves sessions.

Advanced patterns

Async test runner

For test suites that take more than a few seconds, use async: true so Claude doesn't wait:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": ".claude/hooks/run-tests.sh",
            "async": true,
            "timeout": 120
          }
        ]
      }
    ]
  }
}

Claude keeps working. When tests finish, results arrive as context on the next turn. If they failed, Claude knows and acts.

Only type: "command" supports async. Prompt and agent hooks cannot run asynchronously.

File protection

Block edits to files that shouldn't be touched:

#!/bin/bash
# Block edits to protected files

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

PROTECTED=(".env" "package-lock.json" "pnpm-lock.yaml" ".git/")
for pattern in "${PROTECTED[@]}"; do
  if [[ "$FILE_PATH" == *"$pattern"* ]]; then
    echo "Blocked: $FILE_PATH is a protected file" >&2
    exit 2
  fi
done
exit 0

Hooks in skills

If you create skills for Claude Code, you can attach hooks to the YAML frontmatter:

---
name: deploy-check
hooks:
  PreToolUse:
    - matcher: "Bash"
      hooks:
        - type: command
          command: "./scripts/verify-env.sh"
---

The hook only activates while the skill is in use. When it finishes, it disappears.

What goes wrong (and how to fix it)

The Stop hook infinite loop

The most common mistake. Your Stop hook blocks Claude (exit 2), Claude tries to stop again, the hook blocks it again, infinitely.

The fix: always check stop_hook_active:

if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
  exit 0  # Let Claude stop
fi

If stop_hook_active is true, Claude already tried to stop and was blocked. Let it go.

Your .zshrc pollutes JSON output

If your shell profile prints anything at startup (welcome messages, nvm updates, etc.), that text mixes with your hook's JSON output and Claude Code can't parse it.

Fix: wrap any echo in your .zshrc or .bashrc with:

if [[ $- == *i* ]]; then
  echo "Welcome"  # Only in interactive shells
fi

jq is not installed

Many hook scripts use jq to parse stdin JSON. If you don't have it: brew install jq (macOS) or apt install jq (Linux).

Timeout kills the hook silently

If your hook takes longer than the configured timeout, Claude Code kills it without warning. Default behavior is as if the hook didn't exist. Adjust timeout (in seconds) for your operation.

PermissionRequest doesn't fire in -p mode

If you use claude -p "do something" (non-interactive mode), PermissionRequest hooks don't fire. Use PreToolUse instead for automated permission decisions.

Decision framework

The question that always comes up: should I use a hook, a CLAUDE.md instruction, an MCP server, or a skill?

flowchart TD
    Start[What do you need?] --> Q1{Must it run\nEVERY TIME, no exceptions?}
    Q1 -->|Yes| Hook[Hook]
    Q1 -->|No| Q2{Does it require judgment\nor interpretation?}
    Q2 -->|Yes| CLAUDE[CLAUDE.md]
    Q2 -->|No| Q3{Does it need to connect\nto an external service?}
    Q3 -->|Yes| MCP[MCP Server]
    Q3 -->|No| Q4{Is it a repeatable\nmulti-step flow?}
    Q4 -->|Yes| Skill[Skill]
    Q4 -->|No| CLAUDE
Aspect Hook CLAUDE.md MCP Skill
Reliability Deterministic Probabilistic Deterministic Deterministic
Flexibility Low High Medium High
Context cost Zero Tokens Tokens Tokens
Team shareable Yes (.claude/) Yes Yes Yes
Requires code Yes (bash/json) No Yes (server) No (markdown)
Best for Checks, formatting, security Guides, patterns, style External APIs, DBs, services Workflows, generation, processes

All 15 events at a glance

Event When it fires Can block? Typical use case
SessionStart Session begins/resumes No Re-inject context, load env vars
UserPromptSubmit Prompt submitted Yes Validate/transform input
PreToolUse Before tool execution Yes Security, permissions, blocking
PermissionRequest Permission dialog Yes Auto-approve/deny
PostToolUse After successful tool No* Formatting, lint, type-check
PostToolUseFailure After failed tool No Logging, additional context
Notification On notification No Desktop alerts
SubagentStart Subagent created No Logging, context injection
SubagentStop Subagent finished Yes Validate result
Stop Response finished Yes Quality gates, tests
TeammateIdle Teammate going idle Yes Keep active
TaskCompleted Task marked complete Yes Quality verification
ConfigChange Config file changed Yes Auditing
PreCompact Before compaction No Save state
SessionEnd Session terminates No Cleanup

* PostToolUse can't undo the action, but feedback (exit 2) reaches Claude.

Official docs: Hooks Reference · Hooks Guide

Closing

Hooks are the invisible infrastructure that turns Claude from "probably works" to "verified every time." They're not glamorous. They're not hard. And it's tempting to ignore them.

But after using them daily, I can't imagine working without them. I started with the notification. Added type-check after the third broken file nobody caught in time. The destructive command blocker came after a scare with an rm -rf that should never have happened. And the compaction hook... that one saved entire sessions.

Five hooks. Minimal setup. Maximum impact.

If you're interested in extending Claude Code with external tools, the MCP guide is the natural complement. For orchestrating complex tasks, the subagents guide. And if you want to build complete workflows with reusable instructions, the skills guide is the next step.

Get only what matters

If I have nothing worth saying, you won't hear from me. When I do, you'll be the first to know. 7,000+ professionals already trust this.

Are you a professional Web developer?
No

Unsubscribe at any time.