· claude-code / hooks / automation

Claude Code Hooks: The Power-User Playbook

Hooks let you attach shell commands to 29 Claude Code lifecycle events — auto-format on save, block commits when tests fail, ping Slack when a long run ends. Five working recipes and the gotchas to know before you ship them.

By Ethan

2,159 words · 11 min read

Hooks are the right answer every time you catch yourself monitoring Claude and doing something after it finishes. Auto-format changed files. Block the commit if tests are red. Log token usage per session. Ping your phone when a long run ends. All of that is a few lines of shell and one settings.json entry away.

This guide assumes you already run Claude Code daily and want to stop babysitting it. If you are still evaluating whether Claude Code is worth using, start with the Claude Code vs Codex comparison.

Why hooks exist

Before hooks, the only way to run something after Claude touched a file was to either watch Claude’s output and react manually, or wire up external filesystem watches that ran independently of Claude’s actual execution flow. Both approaches are fragile.

Hooks solve three concrete problems:

  1. Audit trail. You want a record of every tool call, token cost, or session boundary — and you want it to happen reliably, not only when you remember to check the transcript.
  2. Auto-cleanup. Claude writes correct logic but does not always format to your project’s style. A PostToolUse hook on Edit|Write calls your formatter on every file Claude touches, so you never review a PR with mixed indentation.
  3. Enforced guardrails. Claude can be instructed not to do things, but instructions drift. A PreToolUse hook that blocks destructive shell patterns and returns an error message is enforced by the runtime, not by Claude’s judgment.

The current event table covers 29 lifecycle events.

Hook anatomy

Lifecycle events

All 29 events fit into five timing buckets:

CadenceEvents
Once per sessionSessionStart, SessionEnd
Once per turnUserPromptSubmit, Stop, StopFailure
On every tool callPreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest, PermissionDenied
On batch completionPostToolBatch
On system eventsWorktreeCreate, WorktreeRemove, PreCompact, PostCompact, ConfigChange, CwdChanged, FileChanged, InstructionsLoaded, Notification, SubagentStart, SubagentStop, TaskCreated, TaskCompleted, UserPromptExpansion, Setup, TeammateIdle, Elicitation, ElicitationResult

The events that matter most day-to-day: PreToolUse (block before execution), PostToolUse (react after), and Stop (react when Claude finishes a turn).

PostToolUse cannot undo what the tool already did. If you need to prevent an action, use PreToolUse — that is the only point where blocking has effect.

Config file locations

FileScopeCommit to repo?
~/.claude/settings.jsonAll your projectsNo
.claude/settings.jsonThis projectYes
.claude/settings.local.jsonThis projectNo (gitignored)

The canonical structure inside any of those files:

{
  "hooks": {
    "EventName": [
      {
        "matcher": "ToolName|OtherTool",
        "hooks": [
          {
            "type": "command",
            "command": "path/to/script.sh",
            "timeout": 60,
            "async": false
          }
        ]
      }
    ]
  }
}

matcher is a pipe-separated list of tool names — exact match, case-sensitive. matcher: "edit" never fires; it must be "Edit".

What every hook receives on stdin

{
  "session_id": "abc123",
  "transcript_path": "/Users/.../.claude/projects/.../session.jsonl",
  "cwd": "/current/working/dir",
  "permission_mode": "default",
  "hook_event_name": "PreToolUse",
  "effort": { "level": "medium" }
}

PreToolUse and PostToolUse also include tool_name and tool_input in the payload.

Exit codes and response format

Exit codeMeaning
0Success; stdout is parsed as JSON
2Hard block; stderr is shown to Claude as an error message
OtherNon-blocking warning; execution continues

When exiting 0, you can return a JSON object from stdout to influence Claude’s behavior:

{
  "continue": true,
  "decision": "block",
  "reason": "Why action was blocked",
  "systemMessage": "Warning shown in transcript"
}

Handler types

Five types ship today:

  • command — shell command; the one you will use 90% of the time
  • http — POST to a URL; response body parsed as JSON above
  • mcp_tool — call a tool on a connected MCP server
  • prompt — single-turn LLM eval returning {"ok": true/false, "reason": "..."}
  • agent — subagent with tool access, 60-second timeout, 50 tool turns (experimental)

Stick with command unless you have a specific reason for the others. agent hooks are powerful on paper; in practice their 60-second limit makes them fragile on anything but fast checks.

Five working recipes

1. Auto-format on every edit

Add to .claude/settings.json (project-scoped, commit it):

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

Requires jq and npx prettier. Swap in black, gofmt -w, or rustfmt for other languages. The hook runs after the file is saved; Claude does not see the reformatted content unless it reads the file again.

Source: official hooks guide — auto-format section.

2. Git-commit guard (block if tests fail)

Save as .claude/hooks/guard-commit.sh, then chmod +x:

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

if ! echo "$COMMAND" | grep -qE '^git commit'; then
  exit 0
fi

echo "Running tests before commit..." >&2

if ! npm test --silent 2>&1; then
  echo "Tests failed — commit blocked. Fix failures before committing." >&2
  exit 2
fi

exit 0

Register in .claude/settings.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "if": "Bash(git commit*)",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/guard-commit.sh"
          }
        ]
      }
    ]
  }
}

The if field (v2.1.85+) filters the hook so it only spawns when the Bash command starts with git commit, saving overhead on every other shell call. exit 2 sends the error text to Claude, which will try to fix the failing tests before retrying.

Use $CLAUDE_PROJECT_DIR rather than relative paths — hooks run with a non-interactive shell where the working directory is not guaranteed. Swap npm test --silent for pytest -q, go test ./..., or cargo test as needed.

3. Token usage tracker

Claude Code stores each session as a JSONL file at transcript_path. Lines with message.usage contain input_tokens, output_tokens, cache_creation_input_tokens, and cache_read_input_tokens. The Stop hook can read those and append a log entry.

Save as ~/.claude/hooks/cost-tracker.sh, then chmod +x:

#!/bin/bash
INPUT=$(cat)
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id')
TRANSCRIPT=$(echo "$INPUT" | jq -r '.transcript_path')
LOG_FILE="$HOME/.claude/cost-log.jsonl"

[ -f "$TRANSCRIPT" ] || exit 0

TOTALS=$(jq -c '
  select(.message.usage != null)
  | select(.isSidechain != true)
  | select(.isApiErrorMessage != true)
  | .message.usage
' "$TRANSCRIPT" 2>/dev/null | jq -sc '
  {
    input_tokens: (map(.input_tokens // 0) | add),
    output_tokens: (map(.output_tokens // 0) | add),
    cache_write_tokens: (map(.cache_creation_input_tokens // 0) | add),
    cache_read_tokens: (map(.cache_read_input_tokens // 0) | add)
  }
')

echo "{\"timestamp\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"session_id\":\"$SESSION_ID\",\"usage\":$TOTALS}" >> "$LOG_FILE"

exit 0

Register in ~/.claude/settings.json:

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "\"$HOME\"/.claude/hooks/cost-tracker.sh",
            "async": true
          }
        ]
      }
    ]
  }
}

async: true means Claude stops immediately without waiting for the log write. Anthropic does not expose cost figures directly in hook payloads — no usage_data or cost fields exist in any event — so this log gives you raw token counts you can multiply by current pricing separately. The fields used above (session_id, message.usage, isSidechain, isApiErrorMessage) are observed from the JSONL transcript files Claude Code writes to disk.

4. Slack ping on stop

Save as ~/.claude/hooks/notify-stop.sh, then chmod +x:

#!/bin/bash
INPUT=$(cat)
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id')
CWD=$(echo "$INPUT" | jq -r '.cwd')
PROJECT=$(basename "$CWD")

[ -z "$SLACK_WEBHOOK_URL" ] && exit 0

PAYLOAD=$(jq -n \
  --arg text "Claude Code finished in *$PROJECT*" \
  --arg session "$SESSION_ID" \
  --arg cwd "$CWD" \
  '{
    text: $text,
    blocks: [
      { type: "section", text: { type: "mrkdwn", text: $text } },
      { type: "context", elements: [
        { type: "mrkdwn", text: ("Session: `" + $session + "`") },
        { type: "mrkdwn", text: ("Dir: `" + $cwd + "`") }
      ]}
    ]
  }')

curl -s -X POST -H 'Content-type: application/json' \
  --data "$PAYLOAD" "$SLACK_WEBHOOK_URL" >/dev/null 2>&1

exit 0

Same Stop hook config as recipe 3, with async: true so Claude does not block on the network call. Set SLACK_WEBHOOK_URL in your shell profile, not in settings.json. For macOS desktop notifications without Slack, the official docs show osascript -e 'display notification "Claude done" with title "Claude Code"' — no external dependencies.

If you use Warp, hooks can emit OSC 9 sequences via the terminalSequence field (v2.1.141+) to trigger Warp’s native desktop notification support without a curl call.

5. Danger-word scanner (hard block)

Adapted from the official bash validator example.

Save as .claude/hooks/danger-scanner.sh, then chmod +x:

#!/bin/bash
INPUT=$(cat)
TOOL=$(echo "$INPUT" | jq -r '.tool_name')

[ "$TOOL" != "Bash" ] && exit 0

COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
[ -z "$COMMAND" ] && exit 0

BLOCKED_PATTERNS=(
  "rm -rf /"
  "rm -rf ~"
  "rm -rf \$HOME"
  "> /dev/sd"
  "dd if=.* of=/dev/sd"
  "DROP DATABASE"
  "DROP TABLE"
  ":(){ :|:& };:"
)

for pattern in "${BLOCKED_PATTERNS[@]}"; do
  if echo "$COMMAND" | grep -qiE "$pattern"; then
    echo "BLOCKED: Potentially destructive pattern detected: '$pattern'" >&2
    exit 2
  fi
done

exit 0

Register in .claude/settings.json:

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

Write this in pure shell + jq. A Python cold start costs 200–400ms on every Bash call; a shell script runs in under 5ms. On big refactors where Claude issues dozens of Bash calls per turn, that 200ms difference adds up.

Composing hooks

Ordering and parallelism

Multiple hooks under one event run in parallel (for async) or in definition order (for synchronous). If two PreToolUse hooks both return updatedInput to rewrite tool arguments, the last one to finish wins — order is non-deterministic. Avoid having more than one hook modify the same tool’s input.

Environment variables

Claude Code sets $CLAUDE_PROJECT_DIR (project root) before spawning hooks. Your own environment variables — including secrets — are available if set before Claude Code starts. Set webhook URLs and API keys in your shell profile, not in .claude/settings.json, so they do not land in version control.

Idempotency

PostToolUse on Edit|Write fires on every file Claude touches. Write hooks to be idempotent: a formatter run twice on the same file is fine; a per-call append-to-log is not. Use Stop for per-session aggregates.

Gotchas

Shell profile contamination

When Claude Code spawns a command hook, it runs sh -c. If your ~/.bashrc or ~/.zshrc prints anything unconditionally, that output prepends to your hook’s stdout and breaks JSON parsing:

Shell ready on arm64
{"decision": "block", "reason": "Not allowed"}

Claude Code sees “Shell ready on arm64{…” and fails to parse it. Guard your profile output with an interactive-shell check:

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

This is the most common production bug when hooks “mysteriously stop working.”

The Stop hook infinite loop

A Stop hook that tells Claude to keep going will re-trigger Stop, which triggers the hook again. The official docs expose a stop_hook_active field in the Stop hook payload for exactly this case:

INPUT=$(cat)
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
  exit 0  # Already been told to continue once; let it stop
fi

Without this guard, any task-completion checker spins indefinitely.

Case-sensitive matchers

matcher: "edit" will never fire. Tool names are PascalCase: Edit, Write, Bash, Read. MCP tool matchers follow mcp__<server>__<tool> naming.

Timeout defaults

Hook typeDefault timeoutUserPromptSubmit override
command, http, mcp_tool10 minutes30 seconds
prompt30 seconds
agent60 seconds

Override with "timeout": N (in seconds) per hook definition. UserPromptSubmit hooks always get 30 seconds maximum, even for command type — they block Claude’s response to the user.

Security: project hooks are an attack surface

Hooks in .claude/settings.json execute arbitrary shell commands with your user permissions. CVE-2025-59536 and CVE-2026-21852 demonstrated that malicious settings.json files in cloned repos can run arbitrary code this way. Anthropic patched the auto-trust bypass, but the underlying model is unchanged: never git clone && cd into an untrusted repo while Claude Code is running. Source: CheckPoint Research advisory.

PermissionRequest skipped in headless mode

PermissionRequest does not fire in -p (non-interactive) mode. Use PreToolUse for permission decisions in CI or scripts.

Hooks vs. CLAUDE.md

When should you use a hook vs. adding an instruction to CLAUDE.md?

Use CLAUDE.md for intent: “always write tests before implementation,” “prefer functional patterns,” “do not import lodash.” These are preferences Claude can reason about and adapt to context.

Use hooks for enforcement: formatting, commit guards, cost logging, security scanners. If the consequence of Claude ignoring the instruction is anything worse than slightly wrong style, make it a hook. Instructions in CLAUDE.md are not enforced by the runtime; hooks are.

One useful pattern: put the human-readable rationale in CLAUDE.md (“we block destructive shell commands via a PreToolUse hook”) and the actual enforcement in the hook. Claude knows why it is being blocked, not just that it is.


Sources: hooks reference · hooks guide · official bash validator example · security advisory CVE-2025-59536