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:
{
"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):
{
"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:
{
"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):
{
"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:
{
"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):
{
"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:
{
"session_id": "string",
"transcript_path": "string",
"cwd": "string",
"permission_mode": "string",
"hook_event_name": "PermissionRequest",
"tool_name": "string",
"tool_input": {}
}
Output:
{
"hookSpecificOutput": {
"hookEventName": "PermissionRequest",
"decision": {
"behavior": "allow|deny",
"updatedInput": {},
"message": "string",
"interrupt": false
}
}
}
SessionStart
Purpose: Initialize session, load context, set environment variables.
Input:
{
"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):
{
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": "string"
},
"suppressOutput": true
}
Plain text stdout is added as context.
SessionEnd
Purpose: Cleanup, save state, log session.
Input:
{
"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:
{
"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:
{
"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:
{
"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:
{
"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:
{
"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:
{
"continue": true,
"systemMessage": "string"
}
Notification
Purpose: Custom notification handling.
Input:
{
"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:
{
"continue": true,
"suppressOutput": true,
"systemMessage": "string"
}
Registration in settings.json
Standard Structure
{
"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
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [{ "type": "command", "command": "/path/to/hook.sh" }]
}
]
}
}
Hook Types
Command Hooks (type: "command")
Default type. Executes bash commands or scripts.
{
"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.
{
"type": "prompt",
"prompt": "Evaluate if Claude should stop. Context: $ARGUMENTS. Check if all tasks are complete.",
"timeout": 30
}
Response schema:
{
"decision": "approve" | "block",
"reason": "Explanation",
"continue": false,
"stopReason": "Message to user",
"systemMessage": "Warning"
}
MCP Tool Naming
MCP tools use pattern mcp__<server>__<tool>:
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
#!/bin/bash
set -e
cd "$CLAUDE_PROJECT_DIR/.claude/hooks"
cat | npx tsx src/my-hook.ts
Or for bundled:
#!/bin/bash
set -e
cd "$HOME/.claude/hooks"
cat | node dist/my-hook.mjs
TypeScript Handler Pattern
import { readFileSync } from 'fs';
interface HookInput {
session_id: string;
hook_event_name: string;
tool_name?: string;
tool_input?: Record<string, unknown>;
tool_response?: Record<string, unknown>;
// ... 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
# 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
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)
#!/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)
#!/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)
#!/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)
#!/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
Field names matter - tool_response not tool_result
Output format - decision: "block" + reason for blocking
Exit code 2 - stderr goes to Claude/user, stdout IGNORED
Rebuild bundles - TypeScript source edits don't auto-apply
Test manually - echo '{}' | ./hook.sh before relying on it
Check outputs first - ls .claude/cache/ before editing code
Detached spawn hides errors - add logging to debug
See Also
/debug-hooks - Systematic debugging workflow
.claude/rules/hooks.md - Hook development rules
1ddon't have the plugin yet? install it then click "run inline in claude" again.
separated inputs, procedure, decision points sections; added edge cases (rate limits, auth expiry, infinite loops via stop_hook_active); clarified all 11 hook type schemas with field names; provided structured testing commands and debugging checklist; added mcp tool naming patterns.
master claude code hooks to intercept, validate, and modify tool execution, user prompts, and session lifecycle events. use this when building hook handlers that block tool calls, inject context, automate permissions, or enforce custom logic before claude acts. hooks run synchronously as bash or llm-based evaluators, making them critical for security gates, auto-formatting, context injection, and workflow enforcement.
hook event system (all hooks receive):
external connections & setup:
event-specific inputs:
PreToolUse: tool_name, tool_input (contains file_path, command, etc.), tool_use_id
PostToolUse: tool_name, tool_input, tool_response (contains filePath, success boolean, output string, exitCode integer) - note: field is tool_response, NOT tool_result
UserPromptSubmit: prompt (string, the user's input)
PermissionRequest: tool_name, tool_input
SessionStart: source (one of "startup", "resume", "clear", "compact")
SessionEnd: reason (one of "clear", "logout", "prompt_input_exit", "other")
Stop / SubagentStop: stop_hook_active (boolean, true if hook already ran once this session - critical to check to prevent infinite loops)
SubagentStart: agent_id (string)
PreCompact: trigger (one of "manual", "auto"), custom_instructions (string)
Notification: message (string), notification_type (one of "permission_prompt", "idle_prompt", "auth_success", "elicitation_dialog")
command hooks (bash/python/typescript): execute synchronously, receive json stdin, output json or plain text stdout. default type. use for simple filtering, file checks, env var setup.
prompt hooks (llm-based): send context + prompt template to haiku, get structured decision. use for complex reasoning (e.g., "should claude stop based on task completion?").
structure depends on whether hook requires a matcher (tool pattern):
for hooks with matchers (PreToolUse, PostToolUse, PermissionRequest, Notification, SessionStart, PreCompact):
{
"hooks": {
"EventName": [
{
"matcher": "ToolPattern",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/my-hook.sh",
"timeout": 60
}
]
}
]
}
}
matcher patterns are case-sensitive:
for hooks without matchers (UserPromptSubmit, SessionEnd, Stop, SubagentStart, SubagentStop):
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [{ "type": "command", "command": "/path/to/hook.sh" }]
}
]
}
}
shell wrapper (for typescript):
#!/bin/bash
set -e
cd "$CLAUDE_PROJECT_DIR/.claude/hooks"
cat | npx tsx src/my-hook.ts
or bundled:
#!/bin/bash
set -e
cd "$HOME/.claude/hooks"
cat | node dist/my-hook.mjs
typescript pattern:
import { readFileSync } from 'fs';
interface HookInput {
session_id: string;
hook_event_name: string;
tool_name?: string;
tool_input?: Record<string, unknown>;
tool_response?: Record<string, unknown>;
prompt?: string;
[key: string]: unknown;
}
function readStdin(): string {
return readFileSync(0, 'utf-8');
}
async function main() {
const input: HookInput = JSON.parse(readStdin());
// your logic here
const output = {
decision: 'block',
reason: 'your reason'
};
console.log(JSON.stringify(output));
}
main().catch(console.error);
PreToolUse (blocks or modifies tool calls before execution):
input: session_id, transcript_path, cwd, permission_mode, hook_event_name, tool_name, tool_input, tool_use_id
output 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.
PostToolUse (react to tool results, provide feedback):
input: session_id, transcript_path, cwd, permission_mode, hook_event_name, tool_name, tool_input, tool_response, tool_use_id
output json:
{
"decision": "block",
"reason": "string",
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": "string"
},
"continue": true,
"stopReason": "string",
"suppressOutput": true
}
decision: "block" with reason prompts claude to address the issue. exit code 2 passes stderr to claude.
UserPromptSubmit (validate user prompts, inject context):
input: session_id, transcript_path, cwd, permission_mode, hook_event_name, prompt
output plain text or json:
PermissionRequest (automate permission dialog decisions):
input: session_id, transcript_path, cwd, permission_mode, hook_event_name, tool_name, tool_input
output json:
{
"hookSpecificOutput": {
"hookEventName": "PermissionRequest",
"decision": {
"behavior": "allow|deny",
"updatedInput": {},
"message": "string",
"interrupt": false
}
}
}
SessionStart (initialize session, load context, set env vars):
input: session_id, transcript_path, cwd, permission_mode, hook_event_name, source
output plain text or json:
{
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": "string"
},
"suppressOutput": true
}
plain text stdout is added as context. write env vars to $CLAUDE_ENV_FILE as export VAR=value lines to persist across sessions.
SessionEnd (cleanup, save state, log session):
input: session_id, transcript_path, cwd, permission_mode, hook_event_name, reason
output: cannot affect session (already ending). use for cleanup and logging only.
Stop (control when claude stops, force continuation):
input: session_id, transcript_path, cwd, permission_mode, hook_event_name, stop_hook_active
output json:
{
"decision": "block",
"reason": "string"
}
decision: "block" forces claude to continue with reason as prompt. critical: check stop_hook_active: true to prevent infinite loops.
SubagentStart (run when task tool spawns subagent):
input: session_id, transcript_path, cwd, permission_mode, hook_event_name, agent_id
output: context injection only (cannot block).
SubagentStop (control when subagents finish):
input: session_id, transcript_path, cwd, permission_mode, hook_event_name, stop_hook_active
output: same as Stop. check stop_hook_active to avoid loops.
PreCompact (save state before context compaction):
input: session_id, transcript_path, cwd, permission_mode, hook_event_name, trigger, custom_instructions
output json:
{
"continue": true,
"systemMessage": "string"
}
Notification (custom notification handling):
input: session_id, transcript_path, cwd, permission_mode, hook_event_name, message, notification_type
output json:
{
"continue": true,
"suppressOutput": true,
"systemMessage": "string"
}
# PreToolUse (Bash)
echo '{"tool_name":"Bash","tool_input":{"command":"ls"},"session_id":"test"}' | \
.claude/hooks/my-hook.sh
# PostToolUse (Write)
echo '{"tool_name":"Write","tool_input":{"file_path":"test.md"},"tool_response":{"success":true},"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
# PermissionRequest
echo '{"hook_event_name":"PermissionRequest","tool_name":"Bash","tool_input":{"command":"rm -rf /"},"session_id":"test"}' | \
.claude/hooks/permission-hook.sh
# Stop (check stop_hook_active to prevent loops)
echo '{"hook_event_name":"Stop","stop_hook_active":false,"session_id":"test"}' | \
.claude/hooks/stop-hook.sh
cd .claude/hooks
npx esbuild src/my-hook.ts \
--bundle --platform=node --format=esm \
--outfile=dist/my-hook.mjs
typescript edits do not auto-apply. rebuild is required.
mcp tools use pattern mcp__
if blocking a tool call: use PreToolUse with permissionDecision: "deny" or exit code 2. stderr is shown to claude.
if blocking a user prompt: use UserPromptSubmit with decision: "block". reason is shown to user only, not claude. prompt is erased.
if blocking a stop: check stop_hook_active: true in Stop or SubagentStop hooks. if true, don't block (return empty {}) to prevent infinite loops. if false and conditions met, return decision: "block" with reason.
if you need to inject context: use UserPromptSubmit (context added before prompt processing) or SessionStart (context added at session init). plain text stdout is injected automatically.
if you need to react to tool results: use PostToolUse. tool already executed, but you can block continuation with decision: "block" to ask claude to address issues.
if you need environment variables to persist: use SessionStart and write to $CLAUDE_ENV_FILE. each line must be export VAR=value.
if deciding based on complex logic: use type: "prompt" hooks (Stop, SubagentStop, PreToolUse). haiku llm evaluates context + your prompt template, returns structured decision.
if tool_response parsing fails: check that field name is tool_response, not tool_result. this is a common gotcha.
if hook times out: increase timeout in settings.json (default 60 seconds for command hooks, 30 for prompt hooks). network calls, file i/o, or bundling might exceed default.
if stderr from exit code 2 doesn't appear: verify hook is registered correctly and shell script has +x permission. run manual test first.
success looks like:
data format expectations:
file locations:
you know the hook worked when:
debugging checklist:
common patterns to copy:
block dangerous files (PreToolUse): check file_path for .env, secrets.json, .git/, deny if match found.
auto-format files (PostToolUse): parse tool_input.file_path, run prettier/black if .ts/.py, no blocking.
inject git context (UserPromptSubmit): output git status and recent commits as plain text, automatically injected.
force test verification (Stop): run npm test, block if exit code nonzero, reason tells claude tests are failing.
auto-approve safe tools (PermissionRequest): check tool_name and tool_input, return behavior: "allow" for safe patterns.
credits: original reference by parcadei. enriched for implexa standards with explicit decision points, edge case handling, and structured testing guidance.