| name | hook-development |
| description | | Use when this capability is needed. |
Writing Reliable Claude Code Hooks
This skill teaches you how to write hooks that never get aborted by Claude Code's internal timeout mechanism.
The Problem: "Operation Was Aborted"
You've probably seen this error:
Plugin hook ... failed to start: The operation was aborted.
Check that the command exists and is executable.
This happens because Claude Code has an internal timeout (via JavaScript's AbortController) that expects hooks to respond quickly. If your hook doesn't output JSON fast enough, Claude Code aborts the process.
The Solution: Immediate Response Pattern
The fix is simple but critical: Output JSON immediately after reading stdin, then fork the actual work to a subprocess.
The Wrong Way (Gets Aborted)
import json
import sys
def main():
input_data = json.load(sys.stdin)
process_data(input_data)
do_more_work()
print(json.dumps({"event": "UserPromptSubmit"}))
if __name__ == "__main__":
main()
Timeline:
0ms - Hook starts
10ms - Reads stdin
100ms - Still doing work...
200ms - ABORTED by Claude Code's internal timeout
500ms - Would have output JSON (never reached)
The Right Way (Never Aborted)
import json
import os
import subprocess
import sys
from pathlib import Path
def main():
try:
input_data = json.load(sys.stdin)
except json.JSONDecodeError:
print(json.dumps({"event": "UserPromptSubmit"}), flush=True)
sys.exit(0)
print(json.dumps({"event": "UserPromptSubmit"}), flush=True)
sys.stdout.flush()
env = os.environ.copy()
env["MY_DATA"] = json.dumps(input_data)
worker_script = Path(__file__).parent / "worker.py"
subprocess.Popen(
[sys.executable, str(worker_script)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL,
start_new_session=True,
env=env,
)
sys.exit(0)
if __name__ == "__main__":
main()
Timeline:
0ms - Hook starts
10ms - Reads stdin
20ms - Outputs JSON (Claude Code satisfied!)
30ms - Forks subprocess
40ms - Hook exits cleanly
... - Worker does actual work in background
Hook Configuration (hooks.json)
{
"hooks": {
"UserPromptSubmit": [
{
"matcher": ".*",
"hooks": [
{
"type": "command",
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/scripts/my_hook.py",
"timeout": 30
}
]
}
]
}
}
Important notes:
- Use
python3 prefix (not just the script path)
${CLAUDE_PLUGIN_ROOT} is expanded by Claude Code
timeout is YOUR timeout (in seconds), separate from Claude Code's internal timeout
- Don't use
suppressOutput: true during debugging - you need to see errors
Script Setup Checklist
1. Make Scripts Executable
CRITICAL: All hook scripts must be executable!
chmod +x scripts/*.py
Without this, you'll get cryptic errors like "command not found" or "permission denied".
2. Choose Your Invocation Method
There are three ways to invoke Python hooks. Each has tradeoffs:
Method A: python3 prefix (RECOMMENDED)
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/scripts/my_hook.py"
Pros: Works reliably, python3 is always in PATH
Cons: None significant
Shebang: #!/usr/bin/env python3
Method B: Direct script execution
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/my_hook.py"
Pros: Cleaner command
Cons: Requires executable permission, relies on shebang
Shebang: #!/usr/bin/env python3 (REQUIRED)
Method C: uv run with inline dependencies (ADVANCED)
"command": "uv run ${CLAUDE_PLUGIN_ROOT}/scripts/my_hook.py"
Pros: Can specify dependencies inline via PEP 723
Cons: uv may not be in Claude Code's PATH on all systems
Shebang: Use PEP 723 format:
WARNING: On macOS, /opt/homebrew/bin may not be in Claude Code's PATH. If uv fails, fall back to Method A.
3. Shebang Reference
| Shebang | Use Case |
|---|
#!/usr/bin/env python3 | Standard Python script |
#!/usr/bin/env -S uv run --script | Script with inline dependencies |
#!/bin/bash | Shell script hooks |
Common mistake: Escaped shebangs like #\!/usr/bin/env python3 - the backslash breaks it!
4. The suppressOutput Setting
{
"type": "command",
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/scripts/my_hook.py",
"timeout": 30,
"suppressOutput": true
}
suppressOutput: true - Hides all output (clean but hides errors)
suppressOutput: false or omitted - Shows errors (use during development)
Debugging workflow:
- Remove
suppressOutput to see errors
- Fix the errors
- Add
suppressOutput: true back for production
Output Visibility Reference
Who Sees What
| Output Channel | Visible To | When | How to Use |
|---|
| stdout (JSON) | Claude Code | Always | print(json.dumps({...}), flush=True) |
| stdout (text) | User (in chat) | When suppressOutput: false | print("message") |
| stderr | User (in chat) | When suppressOutput: false | print("error", file=sys.stderr) |
| File log | Developer (via file) | Always | Write to .claude/hook_debug.log |
| Exit code | Claude Code | Always | sys.exit(0) or sys.exit(2) |
Output Behavior Matrix
suppressOutput | stdout JSON | stdout text | stderr | File log |
|---|
true | Parsed by Claude Code | Hidden | Hidden | Always works |
false/omitted | Parsed by Claude Code | Shown to user | Shown to user | Always works |
JSON Output Fields (stdout)
Claude Code parses JSON from stdout to control behavior:
| Field | Type | Effect |
|---|
decision | "block" | Blocks the operation (PreToolUse/UserPromptSubmit) |
reason | string | Shown to user when blocking |
additionalContext | string | Added to Claude's context |
suppressOutput | boolean | In JSON response, not hooks.json |
Example blocking response:
print(json.dumps({
"decision": "block",
"reason": "Operation not allowed: dangerous command detected"
}), flush=True)
sys.exit(2)
Example adding context:
print(json.dumps({
"additionalContext": "Note: User is in production environment"
}), flush=True)
sys.exit(0)
Exit Codes
| Exit Code | Meaning | Effect |
|---|
0 | Success | Operation proceeds normally |
2 | Block | Operation is blocked, stderr shown to user |
| Other | Error | Non-blocking error, stderr shown in verbose mode |
Redirecting Output
To hide all user-visible output:
"suppressOutput": true
To log to file (always works):
def debug_log(message: str) -> None:
log_file = Path(".claude/hook_debug.log")
log_file.parent.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now().isoformat()
with open(log_file, "a") as f:
f.write(f"[{timestamp}] {message}\n")
To show message to user (when not suppressed):
print("Warning: something happened", file=sys.stderr)
print("Info: processing...")
print(json.dumps({"event": "UserPromptSubmit"}), flush=True)
To send context to Claude (not shown to user):
print(json.dumps({
"additionalContext": "This info goes to Claude's context only"
}), flush=True)
Summary: Where to Put Output
| What You Want | Where to Put It |
|---|
| Debug during development | stderr + suppressOutput: false |
| Permanent debug log | File (.claude/hook_debug.log) |
| Block operation with message | JSON decision: "block" + reason + exit 2 |
| Add context for Claude | JSON additionalContext |
| Silent production operation | suppressOutput: true + file logging |
Hook Events Reference
| Event | When It Fires | Input Schema |
|---|
SessionStart | When Claude Code starts | {session_id, cwd} |
UserPromptSubmit | User sends a message | {prompt, transcript_path, session_id, cwd} |
Stop | Claude finishes responding | {transcript_path, stop_reason} |
PreToolUse | Before a tool runs | {tool_name, tool_input} |
PostToolUse | After a tool runs | {tool_name, tool_input, tool_output} |
Event-Specific Output Handling
IMPORTANT: Each event type handles JSON output differently!
UserPromptSubmit (SPECIAL HANDLING)
This event has unique output behavior:
| Exit Code | JSON Field | Effect |
|---|
0 | (none) | Prompt proceeds normally |
0 | additionalContext | Text added to Claude's context (user doesn't see) |
0 | systemMessage | Shown as system message in chat |
2 | decision: "block" | Prompt is erased from context, stderr shown to user |
2 | reason | Shown to user explaining why blocked |
print(json.dumps({
"additionalContext": "User is working on production database"
}), flush=True)
sys.exit(0)
print(json.dumps({
"decision": "block",
"reason": "Cannot execute destructive commands in production"
}), flush=True)
sys.exit(2)
Key difference: When UserPromptSubmit blocks with exit code 2, the prompt is erased from context - Claude never sees it!
PreToolUse
| Exit Code | JSON Field | Effect |
|---|
0 | (none) | Tool executes normally |
0 | permissionDecision: "allow" | Explicitly allow |
0 | permissionDecision: "deny" | Block tool, show reason |
2 | decision: "block" | Block tool execution |
2 | reason | Shown to user |
if "rm -rf" in tool_input.get("command", ""):
print(json.dumps({
"decision": "block",
"reason": "Dangerous command blocked: rm -rf"
}), flush=True)
sys.exit(2)
PostToolUse
| Exit Code | JSON Field | Effect |
|---|
0 | (none) | Continue normally |
0 | additionalContext | Add info about tool result |
print(json.dumps({
"additionalContext": f"Tool {tool_name} modified 5 files"
}), flush=True)
sys.exit(0)
Stop
| Exit Code | JSON Field | Effect |
|---|
0 | (none) | Session ends normally |
0 | additionalContext | Added for next turn |
print(json.dumps({
"additionalContext": "Remember to run tests before committing"
}), flush=True)
sys.exit(0)
SessionStart
| Exit Code | JSON Field | Effect |
|---|
0 | (none) | Session starts normally |
0 | additionalContext | Added to initial context |
0 | systemMessage | Shown to user at start |
print(json.dumps({
"systemMessage": "GHE plugin loaded. Transcription active.",
"additionalContext": "Project: my-app, Branch: feature/new-ui"
}), flush=True)
sys.exit(0)
Event Comparison Matrix
| Event | Can Block? | Erases Context? | additionalContext | systemMessage |
|---|
SessionStart | No | No | Yes | Yes |
UserPromptSubmit | Yes (exit 2) | Yes (when blocked) | Yes | Yes |
PreToolUse | Yes (exit 2) | No | Yes | No |
PostToolUse | No | No | Yes | No |
Stop | No | No | Yes | No |
Complete Example: Message Logger
Hook Script (log_messages.py)
"""UserPromptSubmit hook that logs messages without getting aborted."""
import json
import os
import subprocess
import sys
from pathlib import Path
def main():
try:
input_data = json.load(sys.stdin)
except json.JSONDecodeError:
print(json.dumps({"event": "UserPromptSubmit"}), flush=True)
sys.exit(0)
print(json.dumps({"event": "UserPromptSubmit"}), flush=True)
sys.stdout.flush()
prompt = input_data.get("prompt", "")
if prompt:
env = os.environ.copy()
env["LOG_MESSAGE"] = prompt
worker = Path(__file__).parent / "log_worker.py"
if worker.exists():
subprocess.Popen(
[sys.executable, str(worker)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL,
start_new_session=True,
env=env,
)
sys.exit(0)
if __name__ == "__main__":
main()
Worker Script (log_worker.py)
"""Background worker that does the actual logging."""
import os
from datetime import datetime
from pathlib import Path
def main():
message = os.environ.get("LOG_MESSAGE", "")
if not message:
return
log_file = Path(".claude/message_log.txt")
log_file.parent.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now().isoformat()
with open(log_file, "a") as f:
f.write(f"[{timestamp}] {message}\n")
if __name__ == "__main__":
main()
Debugging Tips
- Remove
suppressOutput: true during development to see errors
- Add debug logging to a file (stdout goes to Claude Code)
- Test hooks manually before deploying:
echo '{"prompt": "test"}' | python3 my_hook.py
- Check the debug log:
tail -f .claude/hook_debug.log
Common Mistakes
| Mistake | Fix |
|---|
| Doing work before outputting JSON | Output JSON first, fork work |
Using time.sleep() in hook | Never sleep in the main hook |
| Reading large files before responding | Fork to worker, read there |
| Making API calls in hook | Fork to worker, call API there |
Forgetting flush=True | Always flush stdout immediately |
Not using start_new_session=True | Worker might get killed with parent |
| Script not executable | Run chmod +x script.py |
Escaped shebang #\! | Use #! (no backslash) |
Using uv without checking PATH | Fall back to python3 prefix |
suppressOutput: true hiding errors | Remove it during debugging |
| Relying on shell environment | Claude Code has minimal PATH |
| Multiple plugins with same hook event | Can cause race conditions |
Troubleshooting Guide
Error: "failed to start: The operation was aborted"
Cause: Hook didn't output JSON fast enough before Claude Code's internal timeout.
Fix: Use the immediate response pattern - output JSON first, fork work to subprocess.
Error: "command not found" or "permission denied"
Cause: Script not executable or bad shebang.
Fix:
chmod +x scripts/*.py
head -1 scripts/my_hook.py
Error: "uv: command not found"
Cause: uv not in Claude Code's PATH (common on macOS with Homebrew).
Fix: Use python3 prefix instead:
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/scripts/my_hook.py"
Hook runs but nothing happens
Causes:
suppressOutput: true hiding errors
- Worker subprocess failing silently
- Wrong working directory
Fix:
- Remove
suppressOutput temporarily
- Add debug logging to worker script
- Use absolute paths or
${CLAUDE_PLUGIN_ROOT}
Hook works manually but fails in Claude Code
Cause: Different environment (PATH, working directory, etc.)
Fix:
- Don't rely on shell aliases or custom PATH entries
- Use
python3 or full paths to executables
- Pass data via environment variables, not stdin to subprocess
Key Takeaways
- Output JSON within 50ms - Claude Code's internal timeout is aggressive
- Fork heavy work - Use subprocess with
start_new_session=True
- Pass data via environment variables - Safer than command args
- Always exit 0 - Non-zero exit codes have special meanings
- Test manually first - Easier to debug outside Claude Code
Bundled Template Scripts
This skill includes ready-to-use template scripts in the scripts/ directory:
scripts/example_hook.py - Template hook with the immediate response pattern
scripts/example_worker.py - Template worker for background processing
To use these templates:
- Copy them to your plugin's
scripts/ directory
- Rename and customize for your use case
- Update
hooks.json to point to your hook script
Related Resources
Converted and distributed by TomeVault — claim your Tome and manage your conversions.