Hook Creator (Tier 3 - Full Reference)
Persona: Defensive programmer creating fail-safe hooks - prioritizes silent failure over breaking workflows.
Create native Claude Code hooks - Python scripts that run before/after tool calls.
Note: Core workflow is in
instructions.md. This file contains detailed templates, context fields, and registration details.
Hook Types
| Event | When It Runs | Common Uses |
|-------|--------------|-------------|
| PreToolUse | Before tool executes | Block, warn, suggest alternatives |
| PostToolUse | After tool completes | Cache results, chain to other tools, log |
| UserPromptSubmit | When user sends message | Context monitoring, input validation |
| Stop | When Claude stops | Session persistence, uncommitted reminders |
Critical: Output Format
All hooks must return JSON with hookSpecificOutput:
# PreToolUse - approve/deny/block
result = {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "approve", # or "deny", "block"
"permissionDecisionReason": "Message shown to Claude"
}
}
# PostToolUse - informational message
result = {
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"message": "Message shown to Claude"
}
}
# UserPromptSubmit - same as PreToolUse format
result = {
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"permissionDecision": "approve",
"permissionDecisionReason": "Message if not approved"
}
}
WRONG formats (will cause errors):
# BAD - old format
{"decision": "approve", "message": "..."}
# BAD - missing hookSpecificOutput wrapper
{"permissionDecision": "approve", "permissionDecisionReason": "..."}
Context Fields Available
| Field | Available In | Contains |
|-------|--------------|----------|
| tool_name | Pre/Post | Tool name: "Bash", "Edit", "Task", etc. |
| tool_input | Pre/Post | Tool parameters (dict) |
| tool_result | PostToolUse only | Tool output (dict or str) |
| cwd | All | Current working directory |
| session_id | All | Session identifier |
Detect Pre vs Post:
if "tool_result" in ctx:
# PostToolUse
else:
# PreToolUse
Hook Template
#!/usr/bin/env python3
"""
{Description of what this hook does}.
{Event} hook for {Tool} tool.
"""
import json
import sys
def main():
try:
ctx = json.load(sys.stdin)
except json.JSONDecodeError:
sys.exit(0) # Silent failure
tool_name = ctx.get("tool_name", "")
tool_input = ctx.get("tool_input", {})
# Early exit if not our target tool
if tool_name != "TargetTool":
sys.exit(0)
# Your logic here
should_warn = False # Replace with actual check
if should_warn:
result = {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "approve",
"permissionDecisionReason": "Warning message here"
}
}
print(json.dumps(result))
sys.exit(0)
if __name__ == "__main__":
main()
Settings.json Configuration
Add hook to ~/.claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|Edit|Write",
"hooks": [
{
"type": "command",
"command": "$HOME/.claude/hooks/my_hook.py",
"timeout": 1,
"once": true
}
]
}
]
}
}
Hook options:
type:"command"(required)command: Path to hook script (required)timeout: Max execution time in seconds (default: 30)once: Iftrue, hook runs only once per session (NEW)
Matcher patterns:
- Single tool:
"Bash" - Multiple tools:
"Bash|Edit|Write" - All tools:
"*"or omit matcher
PreToolUse Middleware Pattern (NEW)
Hooks can now return updatedInput with ask permission to modify tool input while still requesting user consent:
result = {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "ask", # Request user consent
"permissionDecisionReason": "Modified input - please confirm",
"updatedInput": {
"command": "safer_command_here"
}
}
}
Permission Decisions
| Decision | Effect |
|----------|--------|
| approve | Allow tool, show message |
| deny | Block tool, show reason |
| block | Hard block (same as deny) |
| ask | Request user consent (can include updatedInput) |
| (no output) | Silent approval |
Should NOT Attempt
- Complex logic that might timeout (keep under 1s)
- Side effects in PreToolUse hooks (only decide, don't act)
- Blocking without clear reason (frustrates workflow)
- Denying based on uncertain heuristics
- Network calls in hooks (too slow, may fail)
- Reading large files (use caching instead)
Failure Behavior
Always fail silently. A broken hook should never block work:
def main():
try:
ctx = json.load(sys.stdin)
# ... your logic ...
except Exception:
pass # Silent failure
finally:
sys.exit(0) # Always exit 0
Never:
- Exit with non-zero codes
- Print error messages to stdout
- Let exceptions propagate
Escalation Triggers
| Situation | Escalate To |
|-----------|-------------|
| Hook denies frequently | Rethink rule - consider skill or agent instead |
| Logic too complex for 1s timeout | agent-creator skill for subagent |
| Multiple hooks conflict | User to resolve priority/ordering |
| Requires human judgment | User clarification or manual intervention |
Best Practices
- Silent on success: Return nothing if no action needed
- Timeout of 1s: Keep hooks fast, use 1-5s timeout max
- Graceful errors: Catch all exceptions, exit 0 on failure
- No side effects in Pre: PreToolUse should only decide, not act
- Cache expensive ops: Use /tmp for caching
Examples
Block Dangerous Commands (PreToolUse)
#!/usr/bin/env python3
import json, sys, re
DANGEROUS = [r"rm\s+-rf\s+/", r"chmod\s+777", r">\s*/dev/sd"]
try:
ctx = json.load(sys.stdin)
if ctx.get("tool_name") != "Bash":
sys.exit(0)
cmd = ctx.get("tool_input", {}).get("command", "")
for pattern in DANGEROUS:
if re.search(pattern, cmd):
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": f"Blocked dangerous command: {cmd[:50]}"
}
}))
break
except Exception:
pass
sys.exit(0)
Log Tool Usage (PostToolUse)
#!/usr/bin/env python3
import json, sys
from datetime import datetime
from pathlib import Path
try:
ctx = json.load(sys.stdin)
log = Path("/tmp/claude_tool_log.jsonl")
entry = {
"time": datetime.now().isoformat(),
"tool": ctx.get("tool_name"),
"success": "error" not in str(ctx.get("tool_result", "")).lower()
}
with open(log, "a") as f:
f.write(json.dumps(entry) + "\n")
except Exception:
pass
sys.exit(0)
Unified Pre/Post Hook
#!/usr/bin/env python3
import json, sys
try:
ctx = json.load(sys.stdin)
tool_name = ctx.get("tool_name", "")
if "tool_result" in ctx:
# PostToolUse logic
pass
else:
# PreToolUse logic
pass
except Exception:
pass
sys.exit(0)
Validation
Test hook before adding to settings:
echo '{"tool_name": "Bash", "tool_input": {"command": "ls"}}' | python3 ~/.claude/hooks/my_hook.py
Verify syntax:
python3 -m py_compile ~/.claude/hooks/my_hook.py
Common Mistakes
| Mistake | Fix |
|---------|-----|
| Wrong output format | Use hookSpecificOutput wrapper |
| Checking hook_type field | Check for tool_result instead |
| Using tool_response | Use tool_result |
| Exit code 1/2 on error | Always sys.exit(0) |
| Long timeouts | Keep under 5s, prefer 1s |
| Printing debug output | Only print JSON result |
| No exception handling | Wrap everything in try/except |
Troubleshooting
Hook Not Triggered
# 1. Check hook is registered in settings.json
jq '.hooks' ~/.claude/settings.json
# 2. Verify matcher pattern matches tool
# "Bash" matches Bash tool, "Bash|Edit" matches both
# 3. Check hook is executable
ls -la ~/.claude/hooks/my_hook.py
chmod +x ~/.claude/hooks/my_hook.py
# 4. Test hook manually
echo '{"tool_name": "Bash", "tool_input": {"command": "ls"}}' | python3 ~/.claude/hooks/my_hook.py
Hook Not Producing Output
# 1. Verify JSON output format
echo '{"tool_name": "Bash", "tool_input": {"command": "rm -rf /"}}' | python3 ~/.claude/hooks/my_hook.py | jq .
# 2. Check for exceptions silently swallowed
# Add temporary logging:
import sys
sys.stderr.write(f"Debug: {variable}\n")
# 3. Verify hookSpecificOutput wrapper is present
# Must be: {"hookSpecificOutput": {...}}
# NOT: {"decision": ...} or {"permissionDecision": ...}
Hook Timing Out
# 1. Check current timeout in settings.json
jq '.hooks.PreToolUse[].hooks[].timeout' ~/.claude/settings.json
# 2. Profile hook execution time
time echo '{"tool_name": "Bash"}' | python3 ~/.claude/hooks/my_hook.py
# 3. Move slow operations to PostToolUse or async
# PreToolUse should complete in <100ms
Common Errors
| Symptom | Cause | Fix |
|---------|-------|-----|
| "Hook failed" in logs | Non-zero exit or exception | Add try/except, always exit(0) |
| No effect from hook | Wrong output format | Use hookSpecificOutput wrapper |
| Hook blocks everything | Matcher too broad | Use specific tool name |
| Permission denied | Script not executable | chmod +x hook.py |
| ModuleNotFoundError | Wrong Python env | Check shebang, use venv |
Debugging Commands
# View hook execution logs
tail -100 ~/.claude/data/hook-events.jsonl | jq .
# Benchmark hook latency
~/.claude/scripts/diagnostics/hook-benchmark.sh my_hook.py
# Test all hooks
~/.claude/scripts/diagnostics/test-hooks.sh
# Check hook status
~/.claude/scripts/diagnostics/hook-cli.sh status my_hook
Related Skills
- skill-creator: Create skills that use hooks
- agent-creator: Create agents that complement hooks
- command-creator: Create commands that trigger hooks
When Blocked
If unable to create a working hook:
- Verify the hook event type exists
- Check if the use case is better suited for an agent
- Consider if a command/skill is more appropriate
- Simplify the logic to meet timeout constraints
Design Patterns
| Do | Don't | |----|-------| | Use dispatchers for PreToolUse/PostToolUse | Register hooks individually | | Fail gracefully (hook_utils.py patterns) | Let errors crash | | Target specific tools only | Watch all tools unnecessarily | | Keep under 100ms latency | Block on slow operations | | Log to hook-events.jsonl | Print to stdout |