| name | hook-development |
| description | This skill should be used when writing Claude Code plugin hooks or debugging the "operation was aborted" error. Teaches the critical "immediate response" pattern that prevents Claude Code's internal timeout from aborting hooks. Essential knowledge for plugin developers creating PreToolUse, PostToolUse, UserPromptSubmit, Stop, or SessionStart hooks. |
| triggers | write a hook, create a hook, hook development, hook best practices, operation was aborted, hook timeout, hook aborted, PreToolUse hook, PostToolUse hook, UserPromptSubmit hook, Stop hook, SessionStart hook |
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)
#!/usr/bin/env python3
import json
import sys
def main():
# Read stdin
input_data = json.load(sys.stdin)
# DO WORK HERE (takes 100-500ms)
process_data(input_data) # TOO SLOW!
do_more_work() # STILL WORKING...
# Output JSON - TOO LATE! Already aborted!
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)
#!/usr/bin/env python3
import json
import os
import subprocess
import sys
from pathlib import Path
def main():
# Step 1: Read stdin IMMEDIATELY
try:
input_data = json.load(sys.stdin)
except json.JSONDecodeError:
print(json.dumps({"event": "UserPromptSubmit"}), flush=True)
sys.exit(0)
# Step 2: Output JSON IMMEDIATELY (satisfies Claude Code's timeout)
print(json.dumps({"event": "UserPromptSubmit"}), flush=True)
sys.stdout.flush()
# Step 3: Fork subprocess to do actual work
env = os.environ.copy()
env["MY_DATA"] = json.dumps(input_data) # Pass data via env var
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, # Detach from parent
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
python3prefix (not just the script path) ${CLAUDE_PLUGIN_ROOT}is expanded by Claude Codetimeoutis YOUR timeout (in seconds), separate from Claude Code's internal timeout- Don't use
suppressOutput: trueduring 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:
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "requests>=2.31.0",
# ]
# ///
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 // REMOVE THIS WHEN DEBUGGING!
}
suppressOutput: true- Hides all output (clean but hides errors)suppressOutput: falseor omitted - Shows errors (use during development)
Debugging workflow:
- Remove
suppressOutputto see errors - Fix the errors
- Add
suppressOutput: trueback 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) # Exit code 2 = block
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):
# Via stderr (recommended for errors)
print("Warning: something happened", file=sys.stderr)
# Via stdout text (before JSON)
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 |
# Allow prompt but add context for Claude
print(json.dumps({
"additionalContext": "User is working on production database"
}), flush=True)
sys.exit(0)
# Block prompt completely (erased from history!)
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 |
# Block dangerous command
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 |
# Add context about what happened
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 |
# Add reminder 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 |
# Welcome message and context
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)
#!/usr/bin/env python3
"""UserPromptSubmit hook that logs messages without getting aborted."""
import json
import os
import subprocess
import sys
from pathlib import Path
def main():
# CRITICAL: Read stdin immediately
try:
input_data = json.load(sys.stdin)
except json.JSONDecodeError:
print(json.dumps({"event": "UserPromptSubmit"}), flush=True)
sys.exit(0)
# CRITICAL: Output JSON immediately to prevent abort
print(json.dumps({"event": "UserPromptSubmit"}), flush=True)
sys.stdout.flush()
# Fork the actual logging work
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)
#!/usr/bin/env python3
"""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: trueduring 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
# Check shebang is correct (no backslash!)
head -1 scripts/my_hook.py # Should show: #!/usr/bin/env python3
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: truehiding errors- Worker subprocess failing silently
- Wrong working directory
Fix:
- Remove
suppressOutputtemporarily - 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
python3or 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 patternscripts/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.jsonto point to your hook script
Related Resources
- Claude Code Hooks Documentation
- GitHub Issue #5468 - AbortError investigation
- GHE Plugin source code:
plugins/ghe/scripts/capture_user.py