Claude Code hooks automation developer tools workflow CLAUDE.md

Claude Code Hooks: A Practical Guide with 10 Real-World Examples

The Prompt Shelf ·

Claude Code hooks let you run arbitrary code at specific points in the AI’s lifecycle. Format files automatically after every edit, block dangerous shell commands before they run, get notified when Claude needs input, inject fresh context after compaction — hooks make all of this deterministic.

The key word is deterministic. Without hooks, you are relying on Claude to remember to run your formatter, to avoid certain patterns, to reload context after a long session. Hooks remove that reliance. They run unconditionally, every time.

This guide covers the complete hooks system and gives you 10 patterns you can drop into your own projects today.

How Hooks Work

Hooks are shell commands (or HTTP endpoints, or LLM prompts) configured in a settings file. When Claude Code hits a specific lifecycle event, it fires the matching hooks, passes JSON data to stdin, and reads your response from stdout, stderr, and the exit code.

The exit code controls what happens next:

  • Exit 0 — allow the action to proceed. Anything written to stdout gets injected into Claude’s context (for UserPromptSubmit and SessionStart events).
  • Exit 2 — block the action. Write a reason to stderr; Claude receives it as feedback and adjusts.
  • Any other exit code — the action proceeds, but stderr is logged silently. Use Ctrl+O to see these messages.

For more nuanced control, exit 0 and write a JSON object to stdout instead of using exit codes alone.

Hook Events Reference

As of 2026, Claude Code supports 25 hook events:

EventWhen it fires
SessionStartSession begins or resumes
SessionEndSession terminates
UserPromptSubmitYou submit a prompt, before Claude processes it
PreToolUseBefore a tool call executes (can block)
PostToolUseAfter a tool call succeeds
PostToolUseFailureAfter a tool call fails
PermissionRequestWhen a permission dialog would appear
PermissionDeniedWhen a tool call is denied by the auto-mode classifier
NotificationWhen Claude sends a notification
StopWhen Claude finishes responding
StopFailureWhen a turn ends due to API error
SubagentStartWhen a subagent is spawned
SubagentStopWhen a subagent finishes
TaskCreatedWhen a task is created via TaskCreate
TaskCompletedWhen a task is marked complete
TeammateIdleWhen an agent team teammate goes idle
InstructionsLoadedWhen a CLAUDE.md or rules file is loaded
ConfigChangeWhen a configuration file changes during a session
CwdChangedWhen the working directory changes
FileChangedWhen a watched file changes on disk
WorktreeCreateWhen a worktree is being created
WorktreeRemoveWhen a worktree is being removed
PreCompactBefore context compaction
PostCompactAfter context compaction completes
ElicitationWhen an MCP server requests user input
ElicitationResultAfter a user responds to an MCP elicitation

The most commonly used events are PreToolUse, PostToolUse, SessionStart, Stop, and Notification.

Configuration

Hooks go in a hooks block inside a settings file. Two locations:

// ~/.claude/settings.json — applies to all your projects
// .claude/settings.json — applies to this project, committable to git

Basic structure:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "your-command-here"
          }
        ]
      }
    ]
  }
}

Each event takes an array of hook groups. Each group has a matcher (regex against the tool name, or event-specific field) and a hooks array. Use /hooks in Claude Code to browse all configured hooks and verify they appear correctly.

10 Real-World Hook Patterns

1. Auto-Format on Every File Edit

Run Prettier automatically after every file edit. Zero configuration required on Claude’s end.

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

Why || true? Prettier exits non-zero on unsupported file types (like .sh). Without || true, the hook would block every edit to non-JS files. Silently ignore unsupported types.

For Python, swap Prettier for Black:

{
  "type": "command",
  "command": "jq -r '.tool_input.file_path' | xargs -I{} sh -c 'echo {} | grep -q \"\\.py$\" && black {} 2>/dev/null || true'"
}

2. Block Dangerous Shell Commands

Prevent Claude from running commands that can cause irreversible damage. This runs before every Bash tool call.

Save this to .claude/hooks/guard-bash.sh:

#!/bin/bash
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command')

BLOCKED_PATTERNS=(
  "rm -rf /"
  "rm -rf \*"
  "git push --force"
  "git push -f"
  "DROP TABLE"
  "DROP DATABASE"
  "chmod -R 777"
  "> /dev/sda"
)

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

exit 0

Register it:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/guard-bash.sh"
          }
        ]
      }
    ]
  }
}

Important: exit 2 here blocks even in bypassPermissions mode. Hooks can enforce rules that users cannot bypass by changing permission settings.

3. Protect Sensitive Files from Edits

Block Claude from touching .env, lock files, and anything in .git/. Claude receives the reason and adjusts its approach.

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

PROTECTED=(
  ".env"
  ".env.local"
  ".env.production"
  "package-lock.json"
  "yarn.lock"
  "pnpm-lock.yaml"
  ".git/"
  "*.pem"
  "*.key"
)

for pattern in "${PROTECTED[@]}"; do
  if [[ "$FILE_PATH" == *"$pattern"* ]]; then
    echo "Blocked: $FILE_PATH is a protected file. Do not modify it." >&2
    exit 2
  fi
done

exit 0
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh"
          }
        ]
      }
    ]
  }
}

4. Desktop Notifications When Claude Needs Input

Stop watching the terminal. Get a native notification whenever Claude is waiting for you.

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

For Linux:

{
  "type": "command",
  "command": "notify-send 'Claude Code' 'Needs your attention'"
}

Gotcha on macOS: osascript routes through Script Editor, which needs notification permission. If nothing appears, run the command once manually in Terminal, then go to System Settings > Notifications > Script Editor and enable notifications.

5. Re-Inject Context After Compaction

When Claude’s context window fills up, compaction summarizes the conversation. Important details can get lost. This hook fires right after compaction and re-injects whatever you need Claude to remember.

{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "compact",
        "hooks": [
          {
            "type": "command",
            "command": "echo 'Context restored after compaction. Key constraints: use Bun (not npm), run bun test before every commit, never modify src/generated/. Current branch: $(git branch --show-current)'"
          }
        ]
      }
    ]
  }
}

Anything your command writes to stdout gets added to Claude’s context as a system reminder. You can make this dynamic — pull from a file, run git log --oneline -5, output environment status. The hook only fires on compact events, not regular session starts.

6. Run Tests After Relevant File Changes

Run the test suite automatically after Claude edits source files. Catch regressions immediately without asking Claude to remember.

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

# Only run for source files, not tests themselves or config
if echo "$FILE_PATH" | grep -qE '\.(ts|tsx|js|jsx|py)$' && \
   ! echo "$FILE_PATH" | grep -qE '\.(test|spec)\.' && \
   ! echo "$FILE_PATH" | grep -q '__tests__'; then
  
  echo "Running tests for changed file..." >&2
  
  if ! npm test --silent 2>&1 | tail -5; then
    echo "Tests failed after editing $FILE_PATH" >&2
    exit 2
  fi
fi

exit 0
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "timeout": 120,
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/run-tests.sh"
          }
        ]
      }
    ]
  }
}

Note the timeout: 120 — the default hook timeout is 10 minutes (600 seconds), but you can tune it per hook.

7. Enforce Commit Message Format

Block git commit calls that don’t follow Conventional Commits format. Use the if field (requires Claude Code v2.1.85+) to narrow the hook to git commits only, without spawning a process for every other Bash command.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "if": "Bash(git commit*)",
            "command": "INPUT=$(cat); CMD=$(echo \"$INPUT\" | jq -r '.tool_input.command'); if echo \"$CMD\" | grep -q 'git commit'; then MSG=$(echo \"$CMD\" | grep -oP '(?<=-m \")[^\"]+'); if ! echo \"$MSG\" | grep -qP '^(feat|fix|chore|docs|style|refactor|test|perf|ci)(\\(.+\\))?: .+'; then echo \"Commit message must follow Conventional Commits format: type(scope): description\" >&2; exit 2; fi; fi; exit 0"
          }
        ]
      }
    ]
  }
}

For readability, move the logic to a script file instead of inlining.

8. Audit All Tool Usage to a Log File

Keep a persistent log of every tool Claude uses during a session. Useful for debugging, auditing, or understanding Claude’s behavior in complex tasks.

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "jq -c '{ts: now | todate, tool: .tool_name, input: .tool_input}' >> ~/.claude/tool-audit.log"
          }
        ]
      }
    ]
  }
}

Empty matcher means “fire on every tool use.” The log format is one JSON object per line (JSONL), easy to grep or pipe to jq.

To view recent tool calls:

tail -20 ~/.claude/tool-audit.log | jq '.tool + ": " + (.input | tostring)'

9. Auto-Approve Plan Mode Exit

When you use Plan Mode, Claude presents a plan and then waits for approval before executing. If you always want to proceed, skip the dialog entirely with a PermissionRequest hook.

{
  "hooks": {
    "PermissionRequest": [
      {
        "matcher": "ExitPlanMode",
        "hooks": [
          {
            "type": "command",
            "command": "echo '{\"hookSpecificOutput\": {\"hookEventName\": \"PermissionRequest\", \"decision\": {\"behavior\": \"allow\"}}}'"
          }
        ]
      }
    ]
  }
}

Keep the matcher scoped to ExitPlanMode. Using an empty matcher here would auto-approve every permission prompt, including file writes and shell commands.

10. Use an LLM to Check Task Completion

When Claude signals it’s done, use a Stop hook with type: "prompt" to ask an LLM whether all tasks are actually complete. If not, Claude receives the reason and keeps working.

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "prompt",
            "prompt": "Review the conversation. Are all tasks requested by the user fully complete, including tests and documentation? If anything is missing, respond with {\"ok\": false, \"reason\": \"what needs to be done\"}. If everything is done, respond with {\"ok\": true}."
          }
        ]
      }
    ]
  }
}

Infinite loop prevention: If your Stop hook returns ok: false, Claude continues working and will trigger another Stop event when it finishes. To prevent loops, parse the stop_hook_active field from stdin and exit 0 if it’s true. For command hooks:

#!/bin/bash
INPUT=$(cat)
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
  exit 0
fi
# rest of your logic

Prompt hooks handle this automatically — they only fire when stop_hook_active is false.

Beyond Command Hooks

Most examples above use "type": "command" — a shell script. But there are three other hook types worth knowing:

type: "prompt" — sends your prompt and the hook input to a Claude model (Haiku by default). Returns {"ok": true} or {"ok": false, "reason": "..."}. Use it when the decision requires judgment that a regex can’t make. See example 10 above.

type: "agent" — spawns a full subagent with tool access. The agent can read files, run commands, check test output, and make a multi-step determination before returning a decision. Use it when type: "prompt" isn’t enough because you need to inspect the actual state of the codebase.

type: "http" — POSTs event data to a URL. The endpoint receives the same JSON as a command hook would on stdin, and returns results in the HTTP response body. Useful for shared audit services or team-wide enforcement endpoints.

Common Gotchas

Shell profile pollution. Claude Code sources ~/.zshrc or ~/.bashrc before running hook commands. If your profile has unconditional echo statements, that output gets prepended to your hook’s JSON output and breaks parsing. Wrap echoes in an interactive-shell check:

if [[ $- == *i* ]]; then
  echo "Shell ready"
fi

Absolute paths. Hook commands run in the working directory with Claude’s environment. If your hook script isn’t on $PATH, use an absolute path or $CLAUDE_PROJECT_DIR:

"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/my-hook.sh"

Parallel hook execution. When multiple hooks match the same event, they all run in parallel. If two PreToolUse hooks both try to modify the tool’s input (using updatedInput), the last one to finish wins — non-deterministically. Don’t have multiple hooks rewrite the same tool’s input.

PostToolUse can’t undo. The tool has already executed. Use PostToolUse for side effects (formatting, logging) and PreToolUse for enforcement.

PermissionRequest doesn’t fire in headless mode. If you’re running Claude with -p (non-interactive), use PreToolUse for automated permission decisions instead.

Debugging Hooks

When a hook isn’t firing or behaving unexpectedly:

  1. Run /hooks inside Claude Code — verify the hook appears under the correct event with the right matcher.
  2. Toggle verbose mode with Ctrl+O to see hook output inline in the transcript.
  3. Test the hook manually by piping sample JSON:
echo '{"tool_name":"Bash","tool_input":{"command":"ls"},"session_id":"test","cwd":"/tmp"}' | ./.claude/hooks/my-hook.sh
echo "Exit code: $?"
  1. Run claude --debug for full execution details including which hooks matched.

Where to Go from Here

The 10 patterns above cover the most common use cases. The hooks system is much deeper — HTTP hooks, agent hooks, worktree hooks, MCP elicitation hooks, and the full input/output schema are documented in the Hooks reference.

For ready-to-use examples from the community, the karanb192/claude-code-hooks repository is a growing collection worth bookmarking.

And if you’re building out your Claude Code setup more broadly, our gallery has hundreds of real CLAUDE.md and AGENTS.md configurations from production codebases to learn from.

More from the blog

Explore the collection

Browse all AI coding rules — CLAUDE.md, .cursorrules, AGENTS.md, and more.

Browse Rules