--- name: hook-developer description: Complete Claude Code hooks reference - input/output schemas, registration, testing patterns --- # Hook Developer Complete reference for developing Claude Code hooks. Use this to write hooks with correct input/output schemas. ## When to Use - Creating a new hook - Debugging hook input/output format - Understanding what fields are available - Setting up hook registration in settings.json - Learning what hooks can block vs inject context ## Quick Reference | Hook | Fires When | Can Block? | Primary Use | |------|-----------|------------|-------------| | **PreToolUse** | Before tool executes | YES | Block/modify tool calls | | **PostToolUse** | After tool completes | Partial | React to tool results | | **UserPromptSubmit** | User sends prompt | YES | Validate/inject context | | **PermissionRequest** | Permission dialog shows | YES | Auto-approve/deny | | **SessionStart** | Session begins | NO | Load context, set env vars | | **SessionEnd** | Session ends | NO | Cleanup/save state | | **Stop** | Agent finishes | YES | Force continuation | | **SubagentStart** | Subagent spawns | NO | Pattern coordination | | **SubagentStop** | Subagent finishes | YES | Force continuation | | **PreCompact** | Before compaction | NO | Save state | | **Notification** | Notification sent | NO | Custom alerts | **Hook type options:** `type: "command"` (bash) or `type: "prompt"` (LLM evaluation) --- ## Hook Input/Output Schemas ### PreToolUse **Purpose:** Block or modify tool execution before it happens. **Input:** ```json { "session_id": "string", "transcript_path": "string", "cwd": "string", "permission_mode": "default|plan|acceptEdits|bypassPermissions", "hook_event_name": "PreToolUse", "tool_name": "string", "tool_input": { "file_path": "string", "command": "string" }, "tool_use_id": "string" } ``` **Output (JSON):** ```json { "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "allow|deny|ask", "permissionDecisionReason": "string", "updatedInput": {} }, "continue": true, "stopReason": "string", "systemMessage": "string", "suppressOutput": true } ``` **Exit code 2:** Blocks tool, stderr shown to Claude. **Common matchers:** `Bash`, `Edit|Write`, `Read`, `Task`, `mcp__.*` --- ### PostToolUse **Purpose:** React to tool execution results, provide feedback to Claude. **Input:** ```json { "session_id": "string", "transcript_path": "string", "cwd": "string", "permission_mode": "string", "hook_event_name": "PostToolUse", "tool_name": "string", "tool_input": {}, "tool_response": { "filePath": "string", "success": true, "output": "string", "exitCode": 0 }, "tool_use_id": "string" } ``` **CRITICAL:** The response field is `tool_response`, NOT `tool_result`. **Output (JSON):** ```json { "decision": "block", "reason": "string", "hookSpecificOutput": { "hookEventName": "PostToolUse", "additionalContext": "string" }, "continue": true, "stopReason": "string", "suppressOutput": true } ``` **Blocking:** `"decision": "block"` with `"reason"` prompts Claude to address the issue. **Common matchers:** `Edit|Write`, `Bash` --- ### UserPromptSubmit **Purpose:** Validate user prompts, inject context before Claude processes. **Input:** ```json { "session_id": "string", "transcript_path": "string", "cwd": "string", "permission_mode": "string", "hook_event_name": "UserPromptSubmit", "prompt": "string" } ``` **Output (Plain text):** ``` Any stdout text is added to context for Claude. ``` **Output (JSON):** ```json { "decision": "block", "reason": "string", "hookSpecificOutput": { "hookEventName": "UserPromptSubmit", "additionalContext": "string" } } ``` **Blocking:** `"decision": "block"` erases prompt, shows `"reason"` to user only (not Claude). **Exit code 2:** Blocks prompt, shows stderr to user only. --- ### PermissionRequest **Purpose:** Automate permission dialog decisions. **Input:** ```json { "session_id": "string", "transcript_path": "string", "cwd": "string", "permission_mode": "string", "hook_event_name": "PermissionRequest", "tool_name": "string", "tool_input": {} } ``` **Output:** ```json { "hookSpecificOutput": { "hookEventName": "PermissionRequest", "decision": { "behavior": "allow|deny", "updatedInput": {}, "message": "string", "interrupt": false } } } ``` --- ### SessionStart **Purpose:** Initialize session, load context, set environment variables. **Input:** ```json { "session_id": "string", "transcript_path": "string", "cwd": "string", "permission_mode": "string", "hook_event_name": "SessionStart", "source": "startup|resume|clear|compact" } ``` **Environment variable:** `CLAUDE_ENV_FILE` - write `export VAR=value` to persist env vars. **Output (Plain text or JSON):** ```json { "hookSpecificOutput": { "hookEventName": "SessionStart", "additionalContext": "string" }, "suppressOutput": true } ``` Plain text stdout is added as context. --- ### SessionEnd **Purpose:** Cleanup, save state, log session. **Input:** ```json { "session_id": "string", "transcript_path": "string", "cwd": "string", "permission_mode": "string", "hook_event_name": "SessionEnd", "reason": "clear|logout|prompt_input_exit|other" } ``` **Output:** Cannot affect session (already ending). Use for cleanup only. --- ### Stop **Purpose:** Control when Claude stops, force continuation. **Input:** ```json { "session_id": "string", "transcript_path": "string", "cwd": "string", "permission_mode": "string", "hook_event_name": "Stop", "stop_hook_active": false } ``` **CRITICAL:** Check `stop_hook_active: true` to prevent infinite loops! **Output:** ```json { "decision": "block", "reason": "string" } ``` **Blocking:** `"decision": "block"` forces Claude to continue with `"reason"` as prompt. --- ### SubagentStart **Purpose:** Run when a subagent (Task tool) is spawned. **Input:** ```json { "session_id": "string", "transcript_path": "string", "cwd": "string", "permission_mode": "string", "hook_event_name": "SubagentStart", "agent_id": "string" } ``` **Output:** Context injection only (cannot block). --- ### SubagentStop **Purpose:** Control when subagents (Task tool) stop. **Input:** ```json { "session_id": "string", "transcript_path": "string", "cwd": "string", "permission_mode": "string", "hook_event_name": "SubagentStop", "stop_hook_active": false } ``` **Output:** Same as Stop. --- ### PreCompact **Purpose:** Save state before context compaction. **Input:** ```json { "session_id": "string", "transcript_path": "string", "cwd": "string", "permission_mode": "string", "hook_event_name": "PreCompact", "trigger": "manual|auto", "custom_instructions": "string" } ``` **Matchers:** `manual`, `auto` **Output:** ```json { "continue": true, "systemMessage": "string" } ``` --- ### Notification **Purpose:** Custom notification handling. **Input:** ```json { "session_id": "string", "transcript_path": "string", "cwd": "string", "permission_mode": "string", "hook_event_name": "Notification", "message": "string", "notification_type": "permission_prompt|idle_prompt|auth_success|elicitation_dialog" } ``` **Matchers:** `permission_prompt`, `idle_prompt`, `auth_success`, `elicitation_dialog`, `*` **Output:** ```json { "continue": true, "suppressOutput": true, "systemMessage": "string" } ``` --- ## Registration in settings.json ### Standard Structure ```json { "hooks": { "EventName": [ { "matcher": "ToolPattern", "hooks": [ { "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/my-hook.sh", "timeout": 60 } ] } ] } } ``` ### Matcher Patterns | Pattern | Matches | |---------|---------| | `Bash` | Exactly Bash tool | | `Edit\|Write` | Edit OR Write | | `Read.*` | Regex: Read* | | `mcp__.*__write.*` | MCP write tools | | `*` | All tools | **Case-sensitive:** `Bash` ≠ `bash` ### Events Requiring Matchers - PreToolUse - YES (required) - PostToolUse - YES (required) - PermissionRequest - YES (required) - Notification - YES (optional) - SessionStart - YES (`startup|resume|clear|compact`) - PreCompact - YES (`manual|auto`) ### Events Without Matchers ```json { "hooks": { "UserPromptSubmit": [ { "hooks": [{ "type": "command", "command": "/path/to/hook.sh" }] } ] } } ``` --- ## Hook Types ### Command Hooks (type: "command") Default type. Executes bash commands or scripts. ```json { "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/my-hook.sh", "timeout": 60 } ``` ### Prompt-Based Hooks (type: "prompt") Uses LLM (Haiku) for context-aware decisions. Best for Stop/SubagentStop. ```json { "type": "prompt", "prompt": "Evaluate if Claude should stop. Context: $ARGUMENTS. Check if all tasks are complete.", "timeout": 30 } ``` **Response schema:** ```json { "decision": "approve" | "block", "reason": "Explanation", "continue": false, "stopReason": "Message to user", "systemMessage": "Warning" } ``` ## MCP Tool Naming MCP tools use pattern `mcp____`: | Pattern | Matches | |---------|---------| | `mcp__memory__.*` | All memory server tools | | `mcp__.*__write.*` | All MCP write tools | | `mcp__github__.*` | All GitHub tools | --- ## Environment Variables ### Available to All Hooks | Variable | Description | |----------|-------------| | `CLAUDE_PROJECT_DIR` | Absolute path to project root | | `CLAUDE_CODE_REMOTE` | "true" if remote/web, empty if local CLI | ### SessionStart Only | Variable | Description | |----------|-------------| | `CLAUDE_ENV_FILE` | Path to write `export VAR=value` lines | ### Plugin Hooks Only | Variable | Description | |----------|-------------| | `CLAUDE_PLUGIN_ROOT` | Absolute path to plugin directory | --- ## Exit Codes | Exit Code | Behavior | stdout | stderr | |-----------|----------|--------|--------| | **0** | Success | JSON processed | Ignored | | **2** | Blocking error | IGNORED | Error message | | **Other** | Non-blocking error | Ignored | Verbose mode | ### Exit Code 2 by Hook | Hook | Effect | |------|--------| | PreToolUse | Blocks tool, stderr to Claude | | PostToolUse | stderr to Claude (tool already ran) | | UserPromptSubmit | Blocks prompt, stderr to user only | | Stop | Blocks stop, stderr to Claude | --- ## Shell Wrapper Pattern ```bash #!/bin/bash set -e cd "$CLAUDE_PROJECT_DIR/.claude/hooks" cat | npx tsx src/my-hook.ts ``` Or for bundled: ```bash #!/bin/bash set -e cd "$HOME/.claude/hooks" cat | node dist/my-hook.mjs ``` --- ## TypeScript Handler Pattern ```typescript import { readFileSync } from 'fs'; interface HookInput { session_id: string; hook_event_name: string; tool_name?: string; tool_input?: Record; tool_response?: Record; // ... other fields per hook type } function readStdin(): string { return readFileSync(0, 'utf-8'); } async function main() { const input: HookInput = JSON.parse(readStdin()); // Process input const output = { decision: 'block', // or undefined to allow reason: 'Why blocking' }; console.log(JSON.stringify(output)); } main().catch(console.error); ``` --- ## Testing Hooks ### Manual Test Commands ```bash # PostToolUse (Write) echo '{"tool_name":"Write","tool_input":{"file_path":"test.md"},"tool_response":{"success":true},"session_id":"test"}' | \ .claude/hooks/my-hook.sh # PreToolUse (Bash) echo '{"tool_name":"Bash","tool_input":{"command":"ls"},"session_id":"test"}' | \ .claude/hooks/my-hook.sh # SessionStart echo '{"hook_event_name":"SessionStart","source":"startup","session_id":"test"}' | \ .claude/hooks/session-start.sh # SessionEnd echo '{"hook_event_name":"SessionEnd","reason":"clear","session_id":"test"}' | \ .claude/hooks/session-end.sh # UserPromptSubmit echo '{"prompt":"test prompt","session_id":"test"}' | \ .claude/hooks/prompt-submit.sh ``` ### Rebuild After TypeScript Edits ```bash cd .claude/hooks npx esbuild src/my-hook.ts \ --bundle --platform=node --format=esm \ --outfile=dist/my-hook.mjs ``` --- ## Common Patterns ### Block Dangerous Files (PreToolUse) ```python #!/usr/bin/env python3 import json, sys data = json.load(sys.stdin) path = data.get('tool_input', {}).get('file_path', '') BLOCKED = ['.env', 'secrets.json', '.git/'] if any(b in path for b in BLOCKED): print(json.dumps({ "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": f"Blocked: {path} is protected" } })) else: print('{}') ``` ### Auto-Format Files (PostToolUse) ```bash #!/bin/bash INPUT=$(cat) FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty') if [[ "$FILE" == *.ts ]] || [[ "$FILE" == *.tsx ]]; then npx prettier --write "$FILE" 2>/dev/null fi echo '{}' ``` ### Inject Git Context (UserPromptSubmit) ```bash #!/bin/bash echo "Git status:" git status --short 2>/dev/null || echo "(not a git repo)" echo "" echo "Recent commits:" git log --oneline -5 2>/dev/null || echo "(no commits)" ``` ### Force Test Verification (Stop) ```python #!/usr/bin/env python3 import json, sys, subprocess data = json.load(sys.stdin) # Prevent infinite loops if data.get('stop_hook_active'): print('{}') sys.exit(0) # Check if tests pass result = subprocess.run(['npm', 'test'], capture_output=True) if result.returncode != 0: print(json.dumps({ "decision": "block", "reason": "Tests are failing. Please fix before stopping." })) else: print('{}') ``` --- ## Debugging Checklist - [ ] Hook registered in settings.json? - [ ] Shell script has `+x` permission? - [ ] Bundle rebuilt after TS changes? - [ ] Using `tool_response` not `tool_result`? - [ ] Output is valid JSON (or plain text)? - [ ] Checking `stop_hook_active` in Stop hooks? - [ ] Using `$CLAUDE_PROJECT_DIR` for paths? --- ## Key Learnings from Past Sessions 1. **Field names matter** - `tool_response` not `tool_result` 2. **Output format** - `decision: "block"` + `reason` for blocking 3. **Exit code 2** - stderr goes to Claude/user, stdout IGNORED 4. **Rebuild bundles** - TypeScript source edits don't auto-apply 5. **Test manually** - `echo '{}' | ./hook.sh` before relying on it 6. **Check outputs first** - `ls .claude/cache/` before editing code 7. **Detached spawn hides errors** - add logging to debug ## See Also - `/debug-hooks` - Systematic debugging workflow - `.claude/rules/hooks.md` - Hook development rules