cheat sheet

Claude Code Hooks

Automate Claude Code lifecycle events with shell hooks — PreToolUse, PostToolUse, UserPromptSubmit, Notification, Stop, SubagentStop, and PreCompact. Covers configuration, environment variables, JSON I/O, exit codes, and blocking examples.

Claude Code Hooks

What it is

Claude Code hooks are shell commands configured in .claude/settings.json that the Claude Code harness executes automatically at specific lifecycle events — before or after tool calls, when the user submits a prompt, on notifications, on session stop, on subagent stop, and before context compaction. They run outside Claude's reasoning loop, so they enforce policies and log activity reliably without depending on Claude's memory or instruction-following. Reach for hooks to block dangerous operations, audit file writes, send notifications, gate destructive commands behind external policy, or trigger CI on every tool invocation.

Hook types

HookWhen it firesCan block?
PreToolUseBefore Claude executes a toolYes — non-zero exit blocks
PostToolUseAfter a tool completesNo — informational only
UserPromptSubmitWhen the user presses EnterYes — can rewrite or block
NotificationWhen Claude sends a notificationNo — informational only
StopWhen Claude finishes a turnNo — informational only
SubagentStopWhen a subagent finishesNo — informational only
PreCompactBefore /compact runsYes — exit non-zero to skip compaction

Configuration

Hooks are defined in settings.json under the "hooks" key. Each hook type holds a list of matcher groups, and each matcher group holds one or more command definitions.

json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "/path/to/bash-guard.sh"
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Edit|Write|MultiEdit",
        "hooks": [
          {
            "type": "command",
            "command": "python3 /path/to/log_edit.py"
          }
        ]
      }
    ],
    "Notification": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "python3 /path/to/notify.py"
          }
        ]
      }
    ],
    "Stop": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "python3 /path/to/on_stop.py"
          }
        ]
      }
    ]
  }
}

Matchers

The matcher field is a string matched against the tool name (for PreToolUse and PostToolUse) or left empty for session-wide hooks (Notification, Stop, UserPromptSubmit, PreCompact).

text
"matcher": "Bash"          // matches the Bash tool
"matcher": "Edit"          // matches the Edit tool
"matcher": "Write"         // matches the Write tool
"matcher": "Bash|Edit"     // matches either (pipe-delimited regex)
"matcher": ".*"            // matches all tools
"matcher": "mcp__github__" // matches any GitHub MCP tool (prefix)
"matcher": ""              // matches all events (use for non-tool hooks)

The matcher is treated as a JavaScript regex anchored at start of string, so Edit also matches a hypothetical EditPro tool. Use ^Edit$ to anchor strictly.

Environment variables in hooks

Claude Code injects environment variables into every hook command. These are the legacy and most-portable way to read the hook context — though newer hooks should read JSON from stdin (see next section).

VariableAvailable inContents
CLAUDE_TOOL_NAMEPreToolUse, PostToolUseTool name (e.g. "Bash")
CLAUDE_TOOL_INPUTPreToolUse, PostToolUseJSON-encoded tool input
CLAUDE_TOOL_RESULTPostToolUseJSON-encoded tool output
CLAUDE_USER_PROMPTUserPromptSubmitThe raw user prompt text
CLAUDE_NOTIFICATION_MESSAGENotificationNotification text
CLAUDE_SESSION_IDAll hooksUnique session identifier
CLAUDE_TRANSCRIPT_PATHAll hooksPath to the session transcript JSONL
CLAUDE_PROJECT_DIRAll hooksWorking directory of the session

JSON input on stdin

Each hook also receives a structured JSON payload on stdin. This is the recommended way to read hook context — it carries more information than the env vars and stays in sync with future fields.

json
{
  "session_id": "sess_01abc...",
  "transcript_path": "/home/alice/.claude/projects/<hash>/sess_01abc.jsonl",
  "cwd": "/home/alice/Code/myproject",
  "hook_event_name": "PreToolUse",
  "tool_name": "Bash",
  "tool_input": {
    "command": "rm -rf /tmp/cache",
    "description": "Clear the local cache"
  }
}

Read it in any language:

python
#!/usr/bin/env python3
import json, sys
event = json.load(sys.stdin)
print(event["tool_input"]["command"])

Output:

text
rm -rf /tmp/cache

Exit codes

Hooks use exit codes to communicate decisions back to the harness. For blocking hooks (PreToolUse, UserPromptSubmit, PreCompact):

Exit codeEffect
0Allow the operation to proceed
1 (or any non-zero except 2)Block silently; Claude sees stderr
2Block and show stdout to the user as a warning

For non-blocking hooks (PostToolUse, Notification, Stop), any non-zero exit is logged but does not affect the session.

JSON output for advanced control

For richer responses, hooks can write a JSON object to stdout. The harness parses it and acts on the embedded decision. Available fields:

json
{
  "decision": "allow" | "deny" | "ask",
  "reason": "Human-readable explanation",
  "modified_input": { /* override tool_input */ }
}
python
#!/usr/bin/env python3
import json, sys
event = json.load(sys.stdin)
cmd = event["tool_input"].get("command", "")

if "rm -rf /" in cmd:
    print(json.dumps({"decision": "deny", "reason": "Refusing to run rm -rf /"}))
    sys.exit(0)

# Rewrite the command to add safety
event["tool_input"]["command"] = cmd.replace("rm ", "rm -i ")
print(json.dumps({"decision": "allow", "modified_input": event["tool_input"]}))

Output:

text
{"decision": "allow", "modified_input": {"command": "rm -i -rf /tmp/cache"}}

Example: block dangerous bash commands

A canonical use case — intercept Bash calls and refuse destructive patterns before Claude ever runs them.

bash
#!/bin/bash
# ~/.claude/hooks/bash-guard.sh

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

# Block recursive force delete
if echo "$COMMAND" | grep -qE 'rm\s+-[a-zA-Z]*r[a-zA-Z]*f|rm\s+--recursive.*--force'; then
  echo "Blocked: recursive force delete is not allowed" >&2
  exit 1
fi

# Block force push
if echo "$COMMAND" | grep -qE 'git\s+push\s+.*--force|git\s+push\s+.*-f\b'; then
  echo "Blocked: force push is not allowed" >&2
  exit 1
fi

# Block writing to system paths
if echo "$COMMAND" | grep -qE '> */etc/|> */usr/'; then
  echo "Blocked: redirect to system path is not allowed" >&2
  exit 1
fi

exit 0

Output: (none — exits 0 on success)

Make it executable, then wire it up:

bash
chmod +x ~/.claude/hooks/bash-guard.sh

Output: (none — exits 0 on success)

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

When Claude tries rm -rf /tmp/cache:

Output:

text
[Hook blocked: Blocked: recursive force delete is not allowed]
Claude will now try a different approach...

Example: log all file edits

A PostToolUse hook that appends a JSON line to a daily log every time Claude edits a file.

python
#!/usr/bin/env python3
# ~/.claude/hooks/log_edit.py
import json, os, sys, datetime, pathlib

event = json.load(sys.stdin)
tool_input = event.get("tool_input", {})
session_id = event.get("session_id", "unknown")
timestamp = datetime.datetime.now().isoformat()

log_entry = {
    "ts": timestamp,
    "session": session_id,
    "file": tool_input.get("file_path", ""),
    "old_len": len(tool_input.get("old_string", "")),
    "new_len": len(tool_input.get("new_string", "")),
}

log_path = pathlib.Path.home() / ".claude" / "edit_log.jsonl"
log_path.parent.mkdir(parents=True, exist_ok=True)
with open(log_path, "a") as f:
    f.write(json.dumps(log_entry) + "\n")
json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write|MultiEdit",
        "hooks": [{"type": "command", "command": "python3 ~/.claude/hooks/log_edit.py"}]
      }
    ]
  }
}

Example: desktop notification on task completion

A Notification hook that pops a macOS or Linux notification when Claude finishes a task or asks for input.

python
#!/usr/bin/env python3
# ~/.claude/hooks/notify.py
import json, sys, subprocess, platform

event = json.load(sys.stdin)
msg = event.get("message") or "Claude Code task complete"

if platform.system() == "Darwin":
    subprocess.run(["osascript", "-e", f'display notification "{msg}" with title "Claude Code"'])
elif platform.system() == "Linux":
    subprocess.run(["notify-send", "Claude Code", msg])
json
{
  "hooks": {
    "Notification": [
      {
        "matcher": "",
        "hooks": [{"type": "command", "command": "python3 ~/.claude/hooks/notify.py"}]
      }
    ]
  }
}

Example: validate user prompts

UserPromptSubmit fires before Claude sees the prompt. Use it to inject standard context, scrub secrets, or reject prompts that look dangerous.

python
#!/usr/bin/env python3
# ~/.claude/hooks/scrub-secrets.py
import json, sys, re

event = json.load(sys.stdin)
prompt = event.get("user_prompt", "")

# Detect anything that looks like an API key
if re.search(r'sk-[a-zA-Z0-9_\-]{20,}', prompt):
    print(json.dumps({
        "decision": "deny",
        "reason": "Prompt contains what looks like an API key. Strip it and retry."
    }))
    sys.exit(2)

sys.exit(0)

Output: (none — exits 0 on success)

json
{
  "hooks": {
    "UserPromptSubmit": [
      {
        "matcher": "",
        "hooks": [{"type": "command", "command": "python3 ~/.claude/hooks/scrub-secrets.py"}]
      }
    ]
  }
}

Example: require tests pass before file writes

A PreToolUse hook on Write and Edit that refuses to write source files when the test suite is broken.

bash
#!/bin/bash
# .claude/hooks/require-tests.sh

FILE=$(jq -r '.tool_input.file_path // ""')

# Only enforce on non-test source files
if echo "$FILE" | grep -qE '\.(py|ts|js)$' && ! echo "$FILE" | grep -q 'test'; then
  if ! python3 -m pytest tests/ -q --no-header 2>&1 | grep -q "passed"; then
    echo "Tests must pass before writing source files. Run pytest to see failures." >&2
    exit 1
  fi
fi

exit 0

Output: (none — exits 0 on success)

Running a full test suite before every file write is slow. Apply this pattern selectively — for example, only on files in a critical module, or gate by a --force-tests environment variable.

Example: session cost summary on stop

A Stop hook that logs session metadata to a daily ledger so you can audit total cost across sessions.

python
#!/usr/bin/env python3
# ~/.claude/hooks/on_stop.py
import json, sys, os, datetime, pathlib

event = json.load(sys.stdin)
session_id = event.get("session_id", "unknown")
transcript = event.get("transcript_path", "")

entry = {
    "ts": datetime.datetime.now().isoformat(),
    "session": session_id,
    "transcript": transcript,
    "cwd": event.get("cwd"),
}

log_path = pathlib.Path.home() / ".claude" / "sessions.jsonl"
with open(log_path, "a") as f:
    f.write(json.dumps(entry) + "\n")

print(f"Session {session_id[:12]}... ended and logged.")

Output:

text
Session sess_01abc... ended and logged.

Example: PreCompact safety net

PreCompact fires just before /compact runs. Use it to back up the full transcript before the harness summarises it away.

bash
#!/bin/bash
# ~/.claude/hooks/precompact-backup.sh

TRANSCRIPT=$(jq -r '.transcript_path')
SESSION=$(jq -r '.session_id')
BACKUP=~/.claude/backups/$SESSION-$(date +%s).jsonl

mkdir -p ~/.claude/backups
cp "$TRANSCRIPT" "$BACKUP"
echo "Backed up transcript to $BACKUP"

Output:

text
Backed up transcript to /home/alice/.claude/backups/sess_01abc...-1717689600.jsonl

Example: SubagentStop

SubagentStop fires when a subagent (Task tool) finishes. Use it to log subagent outcomes or to trigger downstream automation when a subagent returns.

python
#!/usr/bin/env python3
# ~/.claude/hooks/log-subagent.py
import json, sys, datetime, pathlib

event = json.load(sys.stdin)
entry = {
    "ts": datetime.datetime.now().isoformat(),
    "session": event.get("session_id"),
    "subagent": event.get("subagent_name", "general-purpose"),
    "outcome": event.get("outcome", "completed"),
}

log = pathlib.Path.home() / ".claude" / "subagents.jsonl"
with open(log, "a") as f:
    f.write(json.dumps(entry) + "\n")

Output: (none — exits 0 on success)

Debugging hooks

Hooks fail silently when the command is missing, not executable, or the JSON is malformed. Test manually with the same payload Claude Code sends:

bash
# Simulate a PreToolUse Bash event
echo '{
  "session_id": "test",
  "tool_name": "Bash",
  "tool_input": {"command": "rm -rf /tmp"},
  "cwd": "/home/alice"
}' | bash ~/.claude/hooks/bash-guard.sh
echo "Exit code: $?"

Output:

text
Blocked: recursive force delete is not allowed
Exit code: 1

The harness also writes hook execution events to the session transcript. Inspect with:

bash
tail -n 5 ~/.claude/projects/<hash>/<session>.jsonl | jq 'select(.type=="hook")'

Output:

text
{"type":"hook","name":"PreToolUse:Bash","exit_code":1,"stderr":"Blocked: ...","duration_ms":12}

Test hooks with echo $? to verify the exit code before enabling them in settings. A hook that always exits non-zero will block every tool call of that type and silently break your session.

Common pitfalls

  1. Path expansion~ is sometimes not expanded in hooks.*.command. Prefer absolute paths or wrap with bash -c "$HOME/.claude/hooks/foo.sh".
  2. Non-executable scripts — forgetting chmod +x causes the hook to fail silently; use #!/usr/bin/env bash and verify with ls -l.
  3. Stderr swallowed in non-blocking hooks — only PreToolUse/UserPromptSubmit/PreCompact show stderr to Claude; for other hooks, write logs to a file.
  4. Regex matcher surprises"Edit" matches anything starting with Edit (e.g. a future EditExtended tool). Use ^Edit$ to be strict.
  5. JSON parse failures — when reading stdin with Python, wrap in try/except — a malformed payload crashes the hook and may block a tool call you didn't intend to block.
  6. Hooks called per turn, not per toolPostToolUse fires per tool invocation, but Stop fires per turn; counting tools by counting Stop events is wrong.
  7. Settings collisions — both .claude/settings.json and ~/.claude/settings.json define hooks; the harness merges them but only the first-defined matcher per event wins, not all of them. Test the merged behavior.

Real-world recipes

Audit trail for compliance

Combine PreToolUse and PostToolUse hooks to log every tool call to a signed append-only file.

bash
#!/bin/bash
# ~/.claude/hooks/audit.sh
EVENT=$(cat)
LOG=~/.claude/audit/$(date +%Y-%m-%d).jsonl
mkdir -p ~/.claude/audit
echo "$EVENT" | jq -c '. + {ts: now}' >> "$LOG"

Output: (none — exits 0 on success)

json
{
  "hooks": {
    "PreToolUse":  [{"matcher": ".*", "hooks": [{"type": "command", "command": "~/.claude/hooks/audit.sh"}]}],
    "PostToolUse": [{"matcher": ".*", "hooks": [{"type": "command", "command": "~/.claude/hooks/audit.sh"}]}]
  }
}

Slack notification when Claude needs input

python
#!/usr/bin/env python3
# ~/.claude/hooks/slack-notify.py
import json, sys, os, urllib.request

event = json.load(sys.stdin)
msg = event.get("message", "Claude Code needs input")

webhook = os.environ["SLACK_WEBHOOK_URL"]
payload = json.dumps({"text": f":robot_face: {msg}"}).encode()
urllib.request.urlopen(webhook, data=payload).read()

Output: (none — exits 0 on success)

Project-scoped lint gate

A PostToolUse hook on Edit/Write that runs the project linter on the modified file and prints results.

bash
#!/bin/bash
# .claude/hooks/lint-on-edit.sh

FILE=$(jq -r '.tool_input.file_path // ""')

case "$FILE" in
  *.py) ruff check "$FILE" ;;
  *.ts|*.js) eslint "$FILE" ;;
  *.go) gofmt -l "$FILE" ;;
esac

exit 0   # informational only

Output:

text
src/api/users.py:42:5: F401 'os' imported but unused

Block edits to protected paths

bash
#!/bin/bash
# .claude/hooks/protect-paths.sh

FILE=$(jq -r '.tool_input.file_path // ""')

case "$FILE" in
  */migrations/*|*/.github/workflows/*|*/secrets/*)
    echo "Refusing to edit protected path: $FILE" >&2
    exit 1
    ;;
esac

exit 0

Output: (none — exits 0 on success)