Skip to content

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.

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]

Claude Code defines a rich set of hook events. The core lifecycle hooks are:

EventWhenCan Modify
PreToolUseBefore a tool executesPermission decision, input, context
PostToolUseAfter a tool succeedsAdditional context, MCP output, continuation
PostToolUseFailureAfter a tool failsAdditional context, continuation
EventWhenCan Modify
SessionStartWhen a new session beginsAdditional context, initial message, watch paths
SessionEndWhen a session ends— (notification only)
SetupDuring initial setupAdditional context
EventWhen
StopWhen the model produces a final response (no tool calls)
StopFailureWhen stop hooks fail
NotificationWhen a notification is generated
SubagentStartWhen a sub-agent starts
SubagentStopWhen a sub-agent stops
PreCompactBefore context compaction
PostCompactAfter context compaction
UserPromptSubmitWhen the user submits a message
PermissionDeniedWhen a permission is denied
PermissionRequestWhen a permission prompt would be shown
TaskCreatedWhen a background task is created
TaskCompletedWhen a background task completes
ConfigChangeWhen settings change
CwdChangedWhen the working directory changes
FileChangedWhen a watched file changes
InstructionsLoadedWhen CLAUDE.md files are loaded
ElicitationWhen MCP elicitation is requested
ElicitationResultWhen MCP elicitation completes

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\"}}'"
}
]
}
]
}
}

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 matches git *

The pattern matching is handled by preparePermissionMatcher() on the tool definition.

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 commands
CLAUDE_SESSION_ID // Current session ID
CLAUDE_PROJECT_ROOT // Project root directory
CLAUDE_CWD // Current working directory
CLAUDE_TOOL_NAME // Name of the tool being invoked
CLAUDE_TOOL_USE_ID // Tool use ID
CLAUDE_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..."
}

Hooks communicate decisions via JSON on stdout:

// src/types/hooks.ts — Sync hook response
const 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(),
});

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.

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 results

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 run after tool execution succeeds:

src/services/tools/toolHooks.ts
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 };
}
}
}

Hooks have configurable timeouts:

src/utils/hooks.ts
const TOOL_HOOK_EXECUTION_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes for tool hooks
// SessionEnd hooks have a much tighter timeout
const 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;
}

Beyond shell commands, hooks can be registered as in-process callbacks:

src/types/hooks.ts
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

When multiple hooks match the same event, their results are aggregated:

src/types/hooks.ts
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
{
"hooks": {
"PostToolUse": [{
"matcher": "FileWrite",
"hooks": [{
"type": "command",
"command": "prettier --write \"$TOOL_INPUT_file_path\" 2>/dev/null; echo '{\"continue\":true,\"suppressOutput\":true}'"
}]
}]
}
}
{
"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\"}}'"
}]
}]
}
}
{
"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, sys
data = 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"
}
}))