This skill should be used when users want to create, configure, or debug Claude Code hooks...
This skill provides guidance for creating effective Claude Code hooks - shell commands that execute automatically at specific points in Claude Code's lifecycle. Hooks enable deterministic control over Claude's behavior, ensuring certain actions always happen rather than relying on the LLM to choose them.
Before creating a hook, gather information:
Choose the appropriate hook event based on timing needs:
| Event | When It Runs | Common Use Cases |
|---|---|---|
PreToolUse |
Before tool executes | Block operations, validate inputs, auto-approve |
PostToolUse |
After tool completes | Format files, log operations, validate output |
Stop |
When Claude finishes | Remind to store learnings, validate completion |
SubagentStop |
When subagent completes | Validate subagent output |
UserPromptSubmit |
When user submits prompt | Add context, validate prompts |
Notification |
On notifications | Custom notifications |
SessionStart |
Session begins | Load context, set environment |
SessionEnd |
Session ends | Cleanup, logging |
PreCompact |
Before context compact | Save important context |
All hooks receive JSON input with common fields:
{
"session_id": "abc123",
"transcript_path": "/path/to/transcript.jsonl",
"cwd": "/current/directory",
"permission_mode": "default",
"hook_event_name": "EventName",
// Event-specific fields...
}
Simple: Exit Codes
Advanced: JSON Output (exit code 0)
{
"decision": "block",
"reason": "Explanation for Claude",
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": "Extra info for Claude"
}
}
For simple operations, use inline bash/jq:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.command' >> ~/.claude/bash-log.txt"
}
]
}
]
}
}
For complex logic, create a Python script:
#!/usr/bin/env python3
"""Hook script template for Claude Code."""
import json
import sys
def main():
try:
input_data = json.load(sys.stdin)
except json.JSONDecodeError as e:
print(f"Invalid JSON: {e}", file=sys.stderr)
sys.exit(1)
# Extract common fields
hook_event = input_data.get("hook_event_name", "")
tool_name = input_data.get("tool_name", "")
tool_input = input_data.get("tool_input", {})
# Your logic here...
# Option 1: Allow (exit 0, no output)
sys.exit(0)
# Option 2: Block with message to Claude (exit 2)
# print("Error message for Claude", file=sys.stderr)
# sys.exit(2)
# Option 3: JSON output for advanced control
# output = {"decision": "block", "reason": "My reason"}
# print(json.dumps(output))
# sys.exit(0)
if __name__ == "__main__":
main()
Add hook to ~/.claude/settings.json (user) or .claude/settings.json (project):
{
"hooks": {
"EventName": [
{
"matcher": "ToolPattern",
"hooks": [
{
"type": "command",
"command": "/path/to/hook-script.py",
"timeout": 30
}
]
}
]
}
}
Matcher patterns:
"Write" matches only Write tool"Edit|Write" matches Edit or Write"*" or ""chmod +x /path/to/hook.pyecho '{"tool_name":"Write"}' | /path/to/hook.pyclaude --debugCtrl+O in Claude CodeBlock edits to sensitive files:
#!/usr/bin/env python3
import json
import sys
PROTECTED_PATTERNS = ['.env', 'package-lock.json', '.git/', 'credentials']
input_data = json.load(sys.stdin)
file_path = input_data.get('tool_input', {}).get('file_path', '')
if any(p in file_path for p in PROTECTED_PATTERNS):
print(f"Protected file: {file_path}", file=sys.stderr)
sys.exit(2)
sys.exit(0)
Auto-format files after editing:
#!/usr/bin/env python3
import json
import sys
import subprocess
input_data = json.load(sys.stdin)
file_path = input_data.get('tool_input', {}).get('file_path', '')
if file_path.endswith('.py'):
subprocess.run(['black', file_path], capture_output=True)
elif file_path.endswith(('.ts', '.tsx', '.js', '.jsx')):
subprocess.run(['npx', 'prettier', '--write', file_path], capture_output=True)
sys.exit(0)
Remind to store learnings:
#!/usr/bin/env python3
import json
import sys
import random
input_data = json.load(sys.stdin)
# Prevent infinite loops
if input_data.get('stop_hook_active'):
sys.exit(0)
# Trigger 30% of the time
if random.random() < 0.3:
output = {
"decision": "block",
"reason": "Consider storing any valuable learnings from this session using --store"
}
print(json.dumps(output))
sys.exit(0)
Load context at session start:
#!/usr/bin/env python3
import json
import sys
import os
# Read recent git changes
result = os.popen('git log --oneline -5 2>/dev/null').read()
output = {
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": f"Recent commits:\\n{result}"
}
}
print(json.dumps(output))
sys.exit(0)
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow|deny|ask",
"permissionDecisionReason": "Reason shown to user/Claude",
"updatedInput": {"field": "modified_value"}
}
}
{
"decision": "block",
"reason": "Reason shown to Claude",
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": "Extra context for Claude"
}
}
{
"decision": "block",
"reason": "Must continue because..."
}
For hooks that need to track state across invocations:
#!/usr/bin/env python3
import json
import sys
import os
from datetime import datetime
STATE_FILE = os.path.expanduser("~/.claude/hook_state.json")
def load_state():
if os.path.exists(STATE_FILE):
with open(STATE_FILE) as f:
return json.load(f)
return {"invocations": 0, "last_run": None}
def save_state(state):
with open(STATE_FILE, 'w') as f:
json.dump(state, f)
state = load_state()
state["invocations"] += 1
state["last_run"] = datetime.now().isoformat()
save_state(state)
# Use state in hook logic...
This skill includes real working hooks from production use. Read these files for complete, battle-tested implementations.
A sophisticated Stop hook that reminds Claude to store learnings after completing tasks.
Key Features:
Configuration:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "~/.claude/hooks/memory_store_reminder.py",
"timeout": 10
}
]
}
]
}
}
Test command:
echo '{"session_id": "test", "stop_hook_active": false}' | ./examples/memory_store_reminder.py
A PostToolUse hook that detects the first TodoWrite call for each new task and triggers memory recall.
Key Features:
oldTodos is emptyhookSpecificOutputConfiguration:
{
"hooks": {
"PostToolUse": [
{
"matcher": "TodoWrite",
"hooks": [
{
"type": "command",
"command": "~/.claude/hooks/todowrite_first_call.py",
"timeout": 5
}
]
}
]
}
}
Test command:
echo '{"tool_input": {"todos": [{"status": "pending"}]}, "tool_response": {"oldTodos": []}}' | ./examples/todowrite_first_call.py
Documentation explaining the workflow these hooks support, configuration details, and troubleshooting tips.
Complete Claude Code hooks documentation:
cc_hooks_getting_started.md - Quickstart guide with practical examplescc_hooks_ref.md - Complete reference documentation with all hook events, input/output formats, and advanced patternsTo get detailed information about specific hook events or patterns, read these reference files.
Production-ready hook implementations:
memory_store_reminder.py - Stop hook with probability execution and state managementtodowrite_first_call.py - PostToolUse hook with first-call detectionhooks_readme.md - Documentation for the example hooksThese examples demonstrate advanced patterns like state persistence, cooldowns, probability-based execution, and structured JSON output.
"$VAR" not $VAR.. in paths.env, credentials, keys