Hooks System

Description

The hooks system exposes 27 lifecycle events that allow users and extensions to intercept, modify, or log Claude Code's behavior at well-defined boundaries. Hooks are configured in settings.json (at project, user, or enterprise scope) and can execute as shell commands, HTTP requests, LLM-based verifiers, or full agent workflows. Each hook receives structured JSON describing the event context and can approve, block, or modify the operation.

Primary implementation: src/utils/hooks.ts (3789 lines)

Lifecycle events (27 total)

Tool & permissions: | Event | Description | |-------|-------------| | PreToolUse | Before tool execution — can block or modify the call | | PostToolUse | After successful tool execution | | PostToolUseFailure | When a tool fails | | PermissionRequest | When permission dialog shown — can auto-allow/deny | | PermissionDenied | When auto-mode classifier denies |

User input & session: | Event | Description | |-------|-------------| | UserPromptSubmit | When user submits a prompt — can block or modify | | SessionStart | New session started (startup/resume/clear/compact) | | SessionEnd | Session ending (tight 1.5s timeout) | | Setup | Repo setup (init/maintenance triggers) |

Stop & completion: | Event | Description | |-------|-------------| | Stop | Right before Claude concludes response | | StopFailure | When API error ends the turn (fire-and-forget) |

Subagents & tasks: | Event | Description | |-------|-------------| | SubagentStart | Agent tool call starts | | SubagentStop | Right before subagent concludes | | TeammateIdle | Teammate about to go idle | | TaskCreated | Task being created | | TaskCompleted | Task being marked complete |

Compaction & MCP: | Event | Description | |-------|-------------| | PreCompact | Before conversation compaction — can block | | PostCompact | After conversation compaction | | Elicitation | MCP server requests user input | | ElicitationResult | After user responds to MCP elicitation |

Configuration & files: | Event | Description | |-------|-------------| | ConfigChange | Config files change during session | | InstructionsLoaded | CLAUDE.md or rule files loaded (observability-only) | | CwdChanged | Working directory changes | | FileChanged | Watched files change (requires CLAUDE_ENV_FILE) | | Notification | Notifications sent |

Worktree management: | Event | Description | |-------|-------------| | WorktreeCreate | Create isolated worktree | | WorktreeRemove | Remove worktree |

Hook types

Four execution types, configured via the type field:

Command hooks (default)

Shell commands spawned as child processes. On Windows, uses Git Bash explicitly (not cmd.exe) with POSIX paths.

{
  "type": "command",
  "command": "my-linter --check",
  "shell": "bash",
  "timeout": 30,
  "async": false
}

PowerShell alternative available via "shell": "powershell" — uses -NoProfile -NonInteractive.

HTTP hooks

POST event JSON to a URL. SSRF-guarded: validates resolved IPs, blocks private ranges. Supports env var interpolation in headers (only vars listed in allowedEnvVars). Policy allowlist via allowedHttpHookUrls.

Prompt hooks

LLM-based verification using Haiku (or custom model). Returns { ok: true } or { ok: false, reason: "..." }. Blocks if not ok. Default timeout: 30s.

Agent hooks

Full agentic verification — multi-turn agent with tool access. Can read transcript via transcript_path. Must use SyntheticOutputTool to return { ok: true|false, reason? }. Default timeout: 60s.

JSON protocol

Stdin — hook receives structured JSON:

{
  "session_id": "abc...",
  "transcript_path": "/path/to/transcript",
  "cwd": "/current/working/directory",
  "permission_mode": "default",
  "hook_event_name": "PreToolUse",
  "tool_name": "Bash",
  "tool_input": { "command": "git status" },
  "tool_use_id": "uuid"
}

Event-specific fields vary: PostToolUse adds tool_response, SessionStart adds source (startup/resume/clear/compact), etc.

Stdout — hook may emit JSON response:

{
  "continue": true,
  "decision": "approve",
  "reason": "Command is safe",
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "allow",
    "updatedInput": { "command": "git status --short" },
    "additionalContext": "Note: limited to short format"
  }
}

Exit codes: | Code | Behavior | |------|----------| | 0 | Success. JSON parsed if present. stdout/stderr not shown to user. | | 2 | Blocking error. stderr shown to the model. Operation prevented. | | Other | Non-blocking error. stderr shown to user only. Operation continues. |

PreToolUse blocking and modification

PreToolUse hooks have three control mechanisms:

  1. Exit code 2 — blocks the tool call. stderr is sent to the model so it can retry or handle the error.
  2. permissionDecision — override the permission system with "allow", "deny", or "ask".
  3. updatedInput — modify the tool's input arguments before execution. The modified input replaces the original.

These take precedence over the built-in permission system.

Async hooks

A hook can declare itself async by emitting {"async": true} as its first line of stdout. The hook is then backgrounded to the AsyncHookRegistry (src/utils/hooks/AsyncHookRegistry.ts) and the operation proceeds immediately.

Default async timeout: 15s (configurable per hook via asyncTimeout field).

Async rewake (asyncRewake: true): a special mode where the hook runs in the background indefinitely. On completion with exit code 2, it enqueues a task-notification that wakes the model — used for long-running verification that shouldn't block the conversation.

Configuration

Priority order (highest to lowest): 1. User settings (~/.claude/settings.json) 2. Project settings (.claude/settings.json) 3. Local settings (.claude/settings.local.json) 4. Managed/policy settings (~/.claude/settings/policy.json) 5. Plugin hooks 6. Built-in hooks (internal callbacks)

Schema (src/schemas/hooks.ts:176-222):

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash(git *)",  // Permission rule syntax
        "hooks": [
          { "type": "command", "command": "verify-git-cmd", "timeout": 10 }
        ]
      }
    ]
  }
}

Matchers support: exact match ("Write"), pipe-separated ("Write|Edit"), regex (^Write.*), and permission rule syntax in if field (Bash(git *)).

Policy controls: - allowManagedHooksOnly: true — blocks user/project/local hooks; only admin hooks run - Managed hooks run in all modes (interactive, SDK, etc.)

Timeouts

Hook category Default timeout
Tool hooks (PreToolUse, PostToolUse, etc.) 10 minutes (600s)
SessionEnd hooks 1.5 seconds
Async hooks 15 seconds
Prompt hooks 30 seconds
Agent hooks 60 seconds
Individual override timeout field in config (seconds)

Timeout fires an AbortSignal that kills the child process. The operation proceeds as if the hook wasn't present (fail-open).

Session hooks and function hooks

In addition to config-based hooks, the system supports ephemeral hooks:

Environment variable injection

Hooks for SessionStart, Setup, CwdChanged, and FileChanged receive CLAUDE_ENV_FILE. Bash hooks can write shell exports (export VAR=value), which accumulate into a session environment script injected into subsequent Bash tool commands. PowerShell hooks skip this (syntax incompatible).

Trust and security

What depends on it

Design trade-offs

Decision Trade-off
Fail-open on timeout/error Operations never permanently blocked by broken hooks, but misbehaving hooks silently fail
Exit code 2 for blocking Simple protocol, but only one blocking code — no way to distinguish "denied" from "error"
All matching hooks run in parallel Fast, but hooks can't depend on each other's output
JSON on stdin/stdout Language-agnostic, but requires JSON parsing and can't handle binary data
10-minute default timeout for tool hooks Generous for complex checks, but a hung hook blocks the tool for 10 minutes
Async rewake via task-notification Non-blocking long verification, but complex implementation with multiple code paths

Key claims

Relations

Sources

src-20260409-a5fc157bc756, source code at src/utils/hooks.ts, src/utils/hooks/, src/schemas/hooks.ts