Hook System
Claude Code’s hook system allows users (and plugins) to inject custom logic at key points in the agentic lifecycle. Hooks are shell commands or callbacks that execute before/after tool calls, at session boundaries, and at various other lifecycle events. This chapter covers the hook architecture, event types, and execution model.
Hook Architecture
Section titled “Hook Architecture”graph TD A[Hook Configuration] --> B{Hook Source} B --> C[settings.json hooks] B --> D[Plugin hooks] B --> E[SDK callback hooks]
C --> F[Shell command execution] D --> G[Plugin callback execution] E --> H[In-process callback]
F --> I[JSON output parsing] G --> I H --> I
I --> J{Decision} J --> K[allow / deny / ask] J --> L[continue / block] J --> M[additional context] J --> N[modified input]Hook Event Types
Section titled “Hook Event Types”Claude Code defines a rich set of hook events. The core lifecycle hooks are:
Tool Lifecycle Hooks
Section titled “Tool Lifecycle Hooks”| Event | When | Can Modify |
|---|---|---|
PreToolUse | Before a tool executes | Permission decision, input, context |
PostToolUse | After a tool succeeds | Additional context, MCP output, continuation |
PostToolUseFailure | After a tool fails | Additional context, continuation |
Session Lifecycle Hooks
Section titled “Session Lifecycle Hooks”| Event | When | Can Modify |
|---|---|---|
SessionStart | When a new session begins | Additional context, initial message, watch paths |
SessionEnd | When a session ends | — (notification only) |
Setup | During initial setup | Additional context |
Other Hook Events
Section titled “Other Hook Events”| Event | When |
|---|---|
Stop | When the model produces a final response (no tool calls) |
StopFailure | When stop hooks fail |
Notification | When a notification is generated |
SubagentStart | When a sub-agent starts |
SubagentStop | When a sub-agent stops |
PreCompact | Before context compaction |
PostCompact | After context compaction |
UserPromptSubmit | When the user submits a message |
PermissionDenied | When a permission is denied |
PermissionRequest | When a permission prompt would be shown |
TaskCreated | When a background task is created |
TaskCompleted | When a background task completes |
ConfigChange | When settings change |
CwdChanged | When the working directory changes |
FileChanged | When a watched file changes |
InstructionsLoaded | When CLAUDE.md files are loaded |
Elicitation | When MCP elicitation is requested |
ElicitationResult | When MCP elicitation completes |
Hook Configuration
Section titled “Hook Configuration”Hooks are configured in settings.json (user, project, or enterprise scope):
{ "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", "command": "python3 /path/to/check_command.py" } ] } ], "PostToolUse": [ { "matcher": "FileWrite", "hooks": [ { "type": "command", "command": "npx prettier --write $TOOL_INPUT_file_path" } ] } ], "SessionStart": [ { "hooks": [ { "type": "command", "command": "echo '{\"hookSpecificOutput\":{\"hookEventName\":\"SessionStart\",\"additionalContext\":\"Custom context here\"}}'" } ] } ] }}Matcher Patterns
Section titled “Matcher Patterns”The matcher field determines which tool invocations trigger the hook:
- No matcher: Hook runs for ALL tool invocations of that event type
- Tool name:
"Bash"— runs only for Bash tool calls - Tool + pattern:
"Bash(git *)"— runs for Bash calls where the command matchesgit *
The pattern matching is handled by preparePermissionMatcher() on the tool definition.
Hook Execution
Section titled “Hook Execution”Shell Command Hooks
Section titled “Shell Command Hooks”The primary hook execution path is in src/utils/hooks.ts. Hooks are executed as shell commands that receive context via environment variables and stdin:
// Environment variables passed to hook commandsCLAUDE_SESSION_ID // Current session IDCLAUDE_PROJECT_ROOT // Project root directoryCLAUDE_CWD // Current working directoryCLAUDE_TOOL_NAME // Name of the tool being invokedCLAUDE_TOOL_USE_ID // Tool use IDCLAUDE_HOOK_EVENT // Hook event type (PreToolUse, PostToolUse, etc.)TOOL_INPUT_* // Tool input fields (e.g., TOOL_INPUT_command for Bash)The hook’s stdin receives a JSON object with full context:
{ "tool_name": "Bash", "tool_input": { "command": "git status" }, "tool_use_id": "toolu_01abc..."}Hook JSON Output Protocol
Section titled “Hook JSON Output Protocol”Hooks communicate decisions via JSON on stdout:
// src/types/hooks.ts — Sync hook responseconst syncHookResponseSchema = z.object({ continue: z.boolean().optional(), // Should Claude continue? (default: true) suppressOutput: z.boolean().optional(), // Hide stdout from transcript? stopReason: z.string().optional(), // Message when continue=false decision: z.enum(['approve', 'block']).optional(), reason: z.string().optional(), systemMessage: z.string().optional(), // Warning shown to user
hookSpecificOutput: z.union([ z.object({ hookEventName: z.literal('PreToolUse'), permissionDecision: z.enum(['allow', 'deny', 'ask']).optional(), permissionDecisionReason: z.string().optional(), updatedInput: z.record(z.string(), z.unknown()).optional(), additionalContext: z.string().optional(), }), z.object({ hookEventName: z.literal('PostToolUse'), additionalContext: z.string().optional(), updatedMCPToolOutput: z.unknown().optional(), }), // ... more event-specific shapes ]).optional(),});Async Hooks
Section titled “Async Hooks”Hooks can declare themselves async by returning {"async": true}:
const asyncHookResponseSchema = z.object({ async: z.literal(true), asyncTimeout: z.number().optional(),});Async hooks run in the background. They’re registered via registerPendingAsyncHook() and their results arrive later.
Hook Execution Flow (PreToolUse)
Section titled “Hook Execution Flow (PreToolUse)”The runPreToolUseHooks function in src/services/tools/toolHooks.ts orchestrates PreToolUse hooks:
sequenceDiagram participant TE as toolExecution participant TH as toolHooks participant H as hooks.ts participant CMD as Shell Command
TE->>TH: runPreToolUseHooks(tool, input, ...) TH->>H: executePreToolHooks(toolName, input, ...) H->>H: Find matching hooks (matcher check)
loop For each matching hook H->>CMD: Execute command (env vars + stdin) CMD-->>H: JSON stdout H->>H: Parse with hookJSONOutputSchema H-->>TH: Yield result (permission, context, etc.) end
TH-->>TE: Yield aggregated resultsPermission Resolution
Section titled “Permission Resolution”The resolveHookPermissionDecision function in toolHooks.ts resolves hook permissions with careful precedence:
export async function resolveHookPermissionDecision( hookPermissionResult, tool, input, toolUseContext, canUseTool, assistantMessage, toolUseID,): Promise<{ decision: PermissionDecision; input: Record<string, unknown> }> {
if (hookPermissionResult?.behavior === 'allow') { // Hook says allow, BUT settings.json deny/ask rules still apply const ruleCheck = await checkRuleBasedPermissions(tool, hookInput, toolUseContext); if (ruleCheck === null) { // No rule conflicts → allow return { decision: hookPermissionResult, input: hookInput }; } if (ruleCheck.behavior === 'deny') { // Deny rule overrides hook allow return { decision: ruleCheck, input: hookInput }; } // Ask rule → show dialog despite hook approval return { decision: await canUseTool(...), input: hookInput }; }
if (hookPermissionResult?.behavior === 'deny') { return { decision: hookPermissionResult, input }; }
// No hook or 'ask' → normal permission flow return { decision: await canUseTool(...), input };}PostToolUse Hooks
Section titled “PostToolUse Hooks”PostToolUse hooks run after tool execution succeeds:
export async function* runPostToolUseHooks( toolUseContext, tool, toolUseID, messageId, toolInput, toolResponse, requestId, mcpServerType,): AsyncGenerator<PostToolUseHooksResult> {
for await (const result of executePostToolHooks( tool.name, toolUseID, toolInput, toolOutput, toolUseContext, permissionMode, signal, )) { // Handle blocking errors if (result.blockingError) { yield { message: createAttachmentMessage({ type: 'hook_blocking_error', hookEvent: 'PostToolUse', blockingError: result.blockingError, })}; }
// Handle continuation prevention if (result.preventContinuation) { yield { message: createAttachmentMessage({ type: 'hook_stopped_continuation', message: result.stopReason || 'Execution stopped by PostToolUse hook', })}; return; // Stop processing further hooks }
// Handle additional context injection if (result.additionalContexts?.length > 0) { yield { message: createAttachmentMessage({ type: 'hook_additional_context', content: result.additionalContexts, })}; }
// Handle MCP tool output modification if (result.updatedMCPToolOutput && isMcpTool(tool)) { toolOutput = result.updatedMCPToolOutput; yield { updatedMCPToolOutput: toolOutput }; } }}Hook Timeouts
Section titled “Hook Timeouts”Hooks have configurable timeouts:
const TOOL_HOOK_EXECUTION_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes for tool hooks
// SessionEnd hooks have a much tighter timeoutconst SESSION_END_HOOK_TIMEOUT_MS_DEFAULT = 1500; // 1.5 seconds
export function getSessionEndHookTimeoutMs(): number { const raw = process.env.CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS; const parsed = raw ? parseInt(raw, 10) : NaN; return Number.isFinite(parsed) && parsed > 0 ? parsed : SESSION_END_HOOK_TIMEOUT_MS_DEFAULT;}Callback Hooks (SDK/Plugin)
Section titled “Callback Hooks (SDK/Plugin)”Beyond shell commands, hooks can be registered as in-process callbacks:
export type HookCallback = { type: 'callback'; callback: ( input: HookInput, toolUseID: string | null, abort: AbortSignal | undefined, hookIndex?: number, context?: HookCallbackContext, ) => Promise<HookJSONOutput>; timeout?: number; internal?: boolean; // Excluded from metrics};Callback hooks are used by:
- SDK consumers: Register hooks programmatically
- Plugins: Provide hooks via their configuration
- Internal analytics: Track session file access patterns
Hook Result Aggregation
Section titled “Hook Result Aggregation”When multiple hooks match the same event, their results are aggregated:
export type AggregatedHookResult = { message?: Message; blockingErrors?: HookBlockingError[]; preventContinuation?: boolean; stopReason?: string; permissionBehavior?: 'ask' | 'deny' | 'allow' | 'passthrough'; additionalContexts?: string[]; updatedInput?: Record<string, unknown>; updatedMCPToolOutput?: unknown;};Aggregation rules:
- Permission: Most restrictive wins (
deny>ask>allow) - Blocking errors: All collected and reported
- Additional context: All concatenated
- Updated input: Last hook’s update wins
- Continuation: Any hook can prevent continuation
Practical Use Cases
Section titled “Practical Use Cases”1. Auto-Format on Write
Section titled “1. Auto-Format on Write”{ "hooks": { "PostToolUse": [{ "matcher": "FileWrite", "hooks": [{ "type": "command", "command": "prettier --write \"$TOOL_INPUT_file_path\" 2>/dev/null; echo '{\"continue\":true,\"suppressOutput\":true}'" }] }] }}2. Block Dangerous Commands
Section titled “2. Block Dangerous Commands”{ "hooks": { "PreToolUse": [{ "matcher": "Bash(rm -rf *)", "hooks": [{ "type": "command", "command": "echo '{\"decision\":\"block\",\"reason\":\"Recursive force-delete is not allowed\"}'" }] }] }}3. Inject Project Context at Session Start
Section titled “3. Inject Project Context at Session Start”{ "hooks": { "SessionStart": [{ "hooks": [{ "type": "command", "command": "echo '{\"hookSpecificOutput\":{\"hookEventName\":\"SessionStart\",\"additionalContext\":\"This project uses pnpm, React 19, and TypeScript 5.7\"}}'" }] }] }}4. Custom Permission Logic
Section titled “4. Custom Permission Logic”{ "hooks": { "PreToolUse": [{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "python3 /path/to/security_check.py" }] }] }}The Python script receives the command via stdin and returns a permission decision:
import json, sysdata = json.load(sys.stdin)command = data['tool_input']['command']if 'sudo' in command: print(json.dumps({ "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "sudo is not allowed" } }))else: print(json.dumps({ "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "allow" } }))