Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
직접 명령은 검토 Prompt를 거치지 않습니다. 실행하기 전에 소스를 확인하세요.
npx skills add https://github.com/tomevault-io/skills-registry --skill hook-development명령은 한 줄로 유지됩니다. 복사하기 전에 가로로 스크롤해 전체 내용을 확인하세요.
로컬 사본을 원하시나요? SkillsMP에서 현재 제공할 수 있는 파일을 다운로드하세요.
| Use when this capability is needed.
> Use when this capability is needed.
Review architecture and API design for the vfs-s3 project. Use when the user mentions @architect, asks to review an issue's design, discuss module boundaries, API shape, or architectural decisions for vfs-s3. Also trigger when the user wants to create an ADR (Architecture Decision Record) or evaluate a technical approach for the project. Intended for dispatch from Codex automation or Claude routines; GitHub trigger phrase: @vfs-s3-bot please prepare design doc Use when this capability is needed.
SOC 직업 분류 기준
SKILL.md 표시 중
| name | hook-development |
| description | | Use when this capability is needed. |
This skill teaches you how to write hooks that never get aborted by Claude Code's internal timeout mechanism.
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 fix is simple but critical: Output JSON immediately after reading stdin, then fork the actual work to a subprocess.
#!/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)
#!/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
{
"hooks": {
"UserPromptSubmit": [
{
"matcher": ".*",
"hooks": [
{
"type": "command",
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/scripts/my_hook.py",
"timeout": 30
}
]
}
]
}
}
Important notes:
python3 prefix (not just the script path)${CLAUDE_PLUGIN_ROOT} is expanded by Claude Codetimeout is YOUR timeout (in seconds), separate from Claude Code's internal timeoutsuppressOutput: true during debugging - you need to see errorsCRITICAL: All hook scripts must be executable!
chmod +x scripts/*.py
Without this, you'll get cryptic errors like "command not found" or "permission denied".
There are three ways to invoke Python hooks. Each has tradeoffs:
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
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/my_hook.py"
Pros: Cleaner command
Cons: Requires executable permission, relies on shebang
Shebang: #!/usr/bin/env python3 (REQUIRED)
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.
| 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!
{
"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: false or omitted - Shows errors (use during development)Debugging workflow:
suppressOutput to see errorssuppressOutput: true back for production| 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) |
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 |
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 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 |
"suppressOutput": true
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")
# 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)
print(json.dumps({
"additionalContext": "This info goes to Claude's context only"
}), flush=True)
| 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 |
| 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} |
IMPORTANT: Each event type handles JSON output differently!
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!
| 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)
| 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)
| 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)
| 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 | 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 |
#!/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()
#!/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()
suppressOutput: true during development to see errorsecho '{"prompt": "test"}' | python3 my_hook.py
tail -f .claude/hook_debug.log
| 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 |
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.
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
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"
Causes:
suppressOutput: true hiding errorsFix:
suppressOutput temporarily${CLAUDE_PLUGIN_ROOT}Cause: Different environment (PATH, working directory, etc.)
Fix:
python3 or full paths to executablesstart_new_session=TrueThis 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 processingTo use these templates:
scripts/ directoryhooks.json to point to your hook scriptplugins/ghe/scripts/capture_user.pyConverted and distributed by TomeVault — claim your Tome and manage your conversions.