Claude Code Hooks#
Hooks are shell commands that run automatically at specific points in the Claude Code lifecycle. They let you enforce policies, log activity, notify external systems, or block dangerous operations β without modifying Claudeβs prompt.
Hook types#
| Hook | When it fires | Can block? |
|---|---|---|
PreToolUse | Before Claude executes a tool | Yes β non-zero exit blocks the tool |
PostToolUse | After a tool completes | No β informational only |
Notification | When Claude sends a notification | No β informational only |
Stop | When Claude finishes a turn | No β informational only |
Configuration#
Hooks are defined in settings.json under a "hooks" key. Each hook is a list of matchers with shell commands.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "/path/to/bash-guard.sh"
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit",
"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/PostToolUse) or is empty for session-wide hooks (Notification, Stop).
"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)
"matcher": "" // matches all events (use for Notification/Stop)
Environment variables in hooks#
Claude Code injects these environment variables into every hook command:
| 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_NOTIFICATION_MESSAGE | Notification | Notification text |
CLAUDE_SESSION_ID | All hooks | Unique session identifier |
Exit codes#
For PreToolUse hooks:
| Exit code | Effect |
|---|---|
0 | Allow the tool call to proceed |
1 (or any non-zero) | Block the tool call; Claude sees the hookβs stderr as an error message |
2 | Block and show hookβs stdout to the user as a warning (not as an error) |
Example: block dangerous bash commands#
#!/bin/bash
# ~/.claude/hooks/bash-guard.sh
COMMAND=$(echo "$CLAUDE_TOOL_INPUT" | python3 -c "import sys, json; d=json.load(sys.stdin); print(d.get('command',''))")
# Block rm -rf
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 git push --force
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
# Allow everything else
exit 0
Make it executable:
chmod +x ~/.claude/hooks/bash-guard.sh
Configure in settings.json:
{
"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#
#!/usr/bin/env python3
# ~/.claude/hooks/log_edit.py
import json, os, datetime
tool_input = json.loads(os.environ.get("CLAUDE_TOOL_INPUT", "{}"))
session_id = os.environ.get("CLAUDE_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 = os.path.expanduser("~/.claude/edit_log.jsonl")
with open(log_path, "a") as f:
f.write(json.dumps(log_entry) + "\n")
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit",
"hooks": [{"type": "command", "command": "python3 ~/.claude/hooks/log_edit.py"}]
}
]
}
}
Example: desktop notification on task completion#
#!/usr/bin/env python3
# ~/.claude/hooks/notify.py
import os, subprocess, platform
msg = os.environ.get("CLAUDE_NOTIFICATION_MESSAGE", "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: require tests pass before file writes#
#!/bin/bash
# .claude/hooks/require-tests.sh
# PreToolUse hook that runs before any Write/Edit
# Only enforce during "safe" hours (optional policy example)
FILE=$(echo "$CLAUDE_TOOL_INPUT" | python3 -c "import sys, json; d=json.load(sys.stdin); print(d.get('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
[!WARNING] 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 only for certain team members.
Example: session cost summary on stop#
#!/usr/bin/env python3
# ~/.claude/hooks/on_stop.py
import os, json, datetime
session_id = os.environ.get("CLAUDE_SESSION_ID", "unknown")
log_path = os.path.expanduser("~/.claude/sessions.jsonl")
entry = {
"ts": datetime.datetime.now().isoformat(),
"session": session_id,
}
with open(log_path, "a") as f:
f.write(json.dumps(entry) + "\n")
print(f"Session {session_id[:12]}... ended and logged.")
Debugging hooks#
# Test a hook manually
CLAUDE_TOOL_NAME="Bash" \
CLAUDE_TOOL_INPUT='{"command":"rm -rf /tmp"}' \
CLAUDE_SESSION_ID="test-session" \
bash ~/.claude/hooks/bash-guard.sh
echo "Exit code: $?"
Output:
Blocked: recursive force delete is not allowed
Exit code: 1
[!TIP] 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.