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
| Hook | When it fires | Can block? |
|---|---|---|
PreToolUse | Before Claude executes a tool | Yes — non-zero exit blocks |
PostToolUse | After a tool completes | No — informational only |
UserPromptSubmit | When the user presses Enter | Yes — can rewrite or block |
Notification | When Claude sends a notification | No — informational only |
Stop | When Claude finishes a turn | No — informational only |
SubagentStop | When a subagent finishes | No — informational only |
PreCompact | Before /compact runs | Yes — 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.
{
"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).
"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).
| Variable | Available in | Contents |
|---|---|---|
CLAUDE_TOOL_NAME | PreToolUse, PostToolUse | Tool name (e.g. "Bash") |
CLAUDE_TOOL_INPUT | PreToolUse, PostToolUse | JSON-encoded tool input |
CLAUDE_TOOL_RESULT | PostToolUse | JSON-encoded tool output |
CLAUDE_USER_PROMPT | UserPromptSubmit | The raw user prompt text |
CLAUDE_NOTIFICATION_MESSAGE | Notification | Notification text |
CLAUDE_SESSION_ID | All hooks | Unique session identifier |
CLAUDE_TRANSCRIPT_PATH | All hooks | Path to the session transcript JSONL |
CLAUDE_PROJECT_DIR | All hooks | Working 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.
{
"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:
#!/usr/bin/env python3
import json, sys
event = json.load(sys.stdin)
print(event["tool_input"]["command"])
Output:
rm -rf /tmp/cache
Exit codes
Hooks use exit codes to communicate decisions back to the harness. For blocking hooks (PreToolUse, UserPromptSubmit, PreCompact):
| Exit code | Effect |
|---|---|
0 | Allow the operation to proceed |
1 (or any non-zero except 2) | Block silently; Claude sees stderr |
2 | Block 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:
{
"decision": "allow" | "deny" | "ask",
"reason": "Human-readable explanation",
"modified_input": { /* override tool_input */ }
}
#!/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:
{"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.
#!/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:
chmod +x ~/.claude/hooks/bash-guard.sh
Output: (none — exits 0 on success)
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [{"type": "command", "command": "~/.claude/hooks/bash-guard.sh"}]
}
]
}
}
When Claude tries rm -rf /tmp/cache:
Output:
[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.
#!/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")
{
"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.
#!/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])
{
"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.
#!/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)
{
"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.
#!/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-testsenvironment 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.
#!/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:
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.
#!/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:
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.
#!/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:
# 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:
Blocked: recursive force delete is not allowed
Exit code: 1
The harness also writes hook execution events to the session transcript. Inspect with:
tail -n 5 ~/.claude/projects/<hash>/<session>.jsonl | jq 'select(.type=="hook")'
Output:
{"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
- Path expansion —
~is sometimes not expanded inhooks.*.command. Prefer absolute paths or wrap withbash -c "$HOME/.claude/hooks/foo.sh". - Non-executable scripts — forgetting
chmod +xcauses the hook to fail silently; use#!/usr/bin/env bashand verify withls -l. - Stderr swallowed in non-blocking hooks — only
PreToolUse/UserPromptSubmit/PreCompactshow stderr to Claude; for other hooks, write logs to a file. - Regex matcher surprises —
"Edit"matches anything starting withEdit(e.g. a futureEditExtendedtool). Use^Edit$to be strict. - 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.
- Hooks called per turn, not per tool —
PostToolUsefires per tool invocation, butStopfires per turn; counting tools by countingStopevents is wrong. - Settings collisions — both
.claude/settings.jsonand~/.claude/settings.jsondefinehooks; 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.
#!/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)
{
"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
#!/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.
#!/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:
src/api/users.py:42:5: F401 'os' imported but unused
Block edits to protected paths
#!/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)