Skip to content

claude-code-hooks

Use when creating, debugging, or modifying Claude Code hooks — event types, handler formats, exit codes, hookSpecificOutput schema, scope precedence

ModelSource
inheritpack: claude-code-internals

Tools: Read, Grep, Glob, WebFetch, WebSearch

Full Reference

┏━ 🔧 claude-code-hooks ━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ your friendly armadillo is here to serve you ┃ ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

Hooks are user-defined shell commands, HTTP endpoints, LLM prompts, or subagents that execute automatically at specific lifecycle points in Claude Code sessions. This is the authoritative reference — start here before writing, modifying, or debugging any hook.

Sources: code.claude.com/docs/en/hooks · platform.claude.com/docs/en/agent-sdk/hooks (verified 2026-03-01)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

EventWhen it firesSupports MatcherCan Block?
SessionStartSession begins or resumesyes (startup/resume/clear/compact)no
UserPromptSubmitUser submits a prompt, before Claude processes itnoyes
PreToolUseBefore a tool call executesyes (tool name)yes
PermissionRequestWhen a permission dialog appearsyes (tool name)yes
PostToolUseAfter a tool call succeedsyes (tool name)no (feedback only)
PostToolUseFailureAfter a tool call failsyes (tool name)no (feedback only)
NotificationWhen Claude Code sends a notificationyes (notification type)no
SubagentStartWhen a subagent is spawnedyes (agent type)no
SubagentStopWhen a subagent finishesyes (agent type)yes
StopWhen Claude finishes respondingnoyes
TeammateIdleWhen an agent team teammate is about to go idlenoyes
TaskCompletedWhen a task is being marked as completednoyes
ConfigChangeWhen a configuration file changes mid-sessionyes (config source)yes (except policy_settings)
WorktreeCreateWhen a worktree is being creatednoyes (any non-zero exit fails)
WorktreeRemoveWhen a worktree is being removednono
PreCompactBefore context compactionyes (manual/auto)no
SessionEndWhen a session terminatesyes (exit reason)no

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Where you define a hook determines its scope. Hooks at higher levels take precedence:

LocationScopeShareableLabel in /hooks menu
Managed policy settingsOrganization-wideAdmin-controlled[Managed]
~/.claude/settings.jsonAll your projectsNo (machine-local)[User]
.claude/settings.jsonSingle projectYes (committable)[Project]
.claude/settings.local.jsonSingle projectNo (gitignored)[Local]
Plugin hooks/hooks.jsonWhen plugin is enabledYes (bundled)[Plugin]
Skill/agent frontmatterWhile component is activeYes (in component file)n/a

Precedence order (highest to lowest): managed → user → project → local → plugin → skill/agent

Security note: Admins can set allowManagedHooksOnly: true to block user, project, and plugin hooks. disableAllHooks: true in user/project/local settings cannot disable managed hooks.

Important: Hook changes during a session don’t take effect immediately. Claude Code snapshots hooks at startup. If hooks are modified externally, Claude Code warns you and requires review in /hooks before changes apply.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Three levels of nesting:

{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/my-check.sh",
"timeout": 30
}
]
}
]
}
}
  1. PreToolUse — the hook event
  2. { "matcher": "Bash", "hooks": [...] } — the matcher group
  3. { "type": "command", "command": "..." } — the hook handler

For skill/agent frontmatter, use YAML:

hooks:
PreToolUse:
- matcher: "Bash"
hooks:
- type: command
command: "./scripts/security-check.sh"

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Executes a shell command. Input arrives on stdin as JSON. Output communicated via exit codes and stdout.

{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/check.sh",
"async": false,
"timeout": 60
}
FieldRequiredDescription
typeyes"command"
commandyesShell command to execute
asyncnoIf true, runs in background without blocking. Decision fields have no effect
timeoutnoSeconds before cancel. Default: 600
statusMessagenoCustom spinner message while hook runs
oncenoIf true, runs only once per session then removed (skills only)

POSTs event JSON to a URL. Response body uses same JSON output format as command hooks.

{
"type": "http",
"url": "http://localhost:8080/hooks/pre-tool-use",
"headers": {
"Authorization": "Bearer $MY_TOKEN"
},
"allowedEnvVars": ["MY_TOKEN"],
"timeout": 30
}
FieldRequiredDescription
typeyes"http"
urlyesURL to POST to
headersnoAdditional headers. Supports $VAR_NAME interpolation for vars in allowedEnvVars
allowedEnvVarsnoVars that may be interpolated into headers. Required for any env var interpolation
timeoutnoSeconds before cancel. Default: 600
statusMessagenoCustom spinner message

HTTP error handling: non-2xx status, connection failures, and timeouts are all non-blocking. To block via HTTP, return a 2xx response with the appropriate JSON decision body.

HTTP hooks must be configured by editing settings JSON directly — the /hooks interactive menu only supports command hooks.

Sends a prompt + hook input to a Claude model (Haiku by default) for single-turn yes/no evaluation.

{
"type": "prompt",
"prompt": "Evaluate if this tool use is appropriate: $ARGUMENTS. Return JSON with ok and reason fields.",
"model": "claude-haiku-4-5",
"timeout": 30
}
FieldRequiredDescription
typeyes"prompt"
promptyesPrompt text. Use $ARGUMENTS placeholder for hook input JSON
modelnoModel to use. Defaults to a fast model
timeoutnoDefault: 30
statusMessagenoCustom spinner message
oncenoSkills only

Model response must be JSON:

{ "ok": true }
{ "ok": false, "reason": "Explanation shown to Claude" }

Supported events: PermissionRequest, PostToolUse, PostToolUseFailure, PreToolUse, Stop, SubagentStop, TaskCompleted, UserPromptSubmit

Spawns a subagent with tool access (Read, Grep, Glob) for multi-turn verification. Can inspect actual files and test output. Up to 50 turns before returning a decision.

{
"type": "agent",
"prompt": "Verify all unit tests pass before Claude stops. Run the test suite and check results. $ARGUMENTS",
"model": "claude-haiku-4-5",
"timeout": 120
}
FieldRequiredDescription
typeyes"agent"
promptyesPrompt. Use $ARGUMENTS placeholder for hook input JSON
modelnoDefaults to a fast model
timeoutnoDefault: 60
statusMessagenoCustom spinner message
oncenoSkills only

Response schema same as prompt hooks: { "ok": true } or { "ok": false, "reason": "..." }

Supported events: same as prompt hooks — PermissionRequest, PostToolUse, PostToolUseFailure, PreToolUse, Stop, SubagentStop, TaskCompleted, UserPromptSubmit

Command-only events (only type: "command" supported): ConfigChange, Notification, PreCompact, SessionEnd, SessionStart, SubagentStart, TeammateIdle, WorktreeCreate, WorktreeRemove

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Exit CodeMeaningBehavior
0SuccessClaude Code parses stdout for JSON. JSON only processed on exit 0
2Blocking errorstdout ignored. stderr fed back to Claude as error message. Effect is event-specific (see table below)
any otherNon-blocking errorstderr shown in verbose mode (Ctrl+O). Execution continues
Hook eventCan block?What happens on exit 2
PreToolUseyesBlocks the tool call
PermissionRequestyesDenies the permission
UserPromptSubmityesBlocks prompt processing and erases the prompt
StopyesPrevents Claude from stopping, continues conversation
SubagentStopyesPrevents the subagent from stopping
TeammateIdleyesPrevents the teammate from going idle (teammate continues working)
TaskCompletedyesPrevents the task from being marked as completed
ConfigChangeyesBlocks config change from taking effect (except policy_settings)
PostToolUsenoShows stderr to Claude (tool already ran)
PostToolUseFailurenoShows stderr to Claude (tool already failed)
NotificationnoShows stderr to user only
SubagentStartnoShows stderr to user only
SessionStartnoShows stderr to user only
SessionEndnoShows stderr to user only
PreCompactnoShows stderr to user only
WorktreeCreateyesAny non-zero exit causes worktree creation to fail
WorktreeRemovenoFailures logged in debug mode only

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Every hook receives these fields as JSON on stdin (command) or POST body (http):

FieldDescription
session_idCurrent session identifier
transcript_pathPath to conversation JSONL file
cwdCurrent working directory when hook is invoked
permission_mode"default", "plan", "acceptEdits", "dontAsk", or "bypassPermissions"
hook_event_nameName of the event that fired

On exit 0, output a JSON object to stdout. These fields work across all events:

FieldDefaultDescription
continuetrueIf false, Claude stops processing entirely. Takes precedence over event-specific decisions
stopReasonnoneMessage shown to user when continue is false. Not shown to Claude
suppressOutputfalseIf true, hides stdout from verbose mode output
systemMessagenoneWarning message shown to the user

Stop Claude entirely regardless of event:

{ "continue": false, "stopReason": "Build failed — fix errors before continuing" }

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

The canonical format for fine-grained control. Must include hookEventName matching the event that fired.

PreToolUse — allow/deny/ask with optional input modification

Section titled “PreToolUse — allow/deny/ask with optional input modification”
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Reason shown to Claude on deny; shown to user on allow/ask",
"updatedInput": {
"field_to_modify": "new_value"
},
"additionalContext": "Context injected into Claude before the tool executes"
}
}
FieldDescription
permissionDecision"allow" bypasses permission system, "deny" blocks tool call, "ask" prompts user
permissionDecisionReasonFor allow/ask: shown to user, not Claude. For deny: shown to Claude
updatedInputModifies tool input before execution. Combine with "allow" to auto-approve with modified input
additionalContextString injected into Claude’s context before tool executes

Deprecated: PreToolUse previously used top-level decision: "approve"/"block". These still work ("approve""allow", "block""deny") but use hookSpecificOutput.permissionDecision instead.

PermissionRequest — allow/deny on behalf of user

Section titled “PermissionRequest — allow/deny on behalf of user”
{
"hookSpecificOutput": {
"hookEventName": "PermissionRequest",
"decision": {
"behavior": "allow",
"updatedInput": { "command": "npm run lint" },
"updatedPermissions": [{ "type": "toolAlwaysAllow", "tool": "Bash" }]
}
}
}

For deny:

{
"hookSpecificOutput": {
"hookEventName": "PermissionRequest",
"decision": {
"behavior": "deny",
"message": "Database writes are not allowed in this context",
"interrupt": true
}
}
}
{
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": "Current sprint: Sprint 42. Focus area: auth refactor."
}
}

UserPromptSubmit — block or inject context

Section titled “UserPromptSubmit — block or inject context”
{
"decision": "block",
"reason": "Explanation shown to user",
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": "Additional context added to Claude's context"
}
}
{
"decision": "block",
"reason": "Lint errors found — fix before proceeding",
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": "Lint output: ...",
"updatedMCPToolOutput": "replacement output for MCP tools only"
}
}
{
"hookSpecificOutput": {
"hookEventName": "PostToolUseFailure",
"additionalContext": "This command commonly fails due to missing env vars. Check .env.example."
}
}
{
"decision": "block",
"reason": "Tests must pass before finishing. Run: npm test"
}

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

EventsDecision patternKey fields
UserPromptSubmit, PostToolUse, PostToolUseFailure, Stop, SubagentStop, ConfigChangeTop-level decisiondecision: "block", reason
TeammateIdle, TaskCompletedExit code onlyExit 2 blocks; stderr is feedback
PreToolUsehookSpecificOutputpermissionDecision (allow/deny/ask), permissionDecisionReason
PermissionRequesthookSpecificOutputdecision.behavior (allow/deny)
WorktreeCreatestdout pathHook prints absolute path to created worktree. Non-zero exit fails creation
WorktreeRemove, Notification, SessionEnd, PreCompactnoneSide effects only — no decision control

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Matchers are regex strings. Empty string, "*", or omitted matcher fires on every occurrence.

EventWhat matcher filtersExample values
PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequesttool nameBash, Edit|Write, mcp__.*, Notebook.*
SessionStarthow session startedstartup, resume, clear, compact
SessionEndwhy session endedclear, logout, prompt_input_exit, bypass_permissions_disabled, other
Notificationnotification typepermission_prompt, idle_prompt, auth_success, elicitation_dialog
SubagentStart, SubagentStopagent typeBash, Explore, Plan, or custom agent names
PreCompactwhat triggered compactionmanual, auto
ConfigChangeconfiguration sourceuser_settings, project_settings, local_settings, policy_settings, skills
UserPromptSubmit, Stop, TeammateIdle, TaskCompleted, WorktreeCreate, WorktreeRemoveno matcher supportmatcher field silently ignored

MCP tools follow the pattern mcp__<server>__<tool>:

mcp__memory__create_entities → Memory server's create_entities tool
mcp__filesystem__read_file → Filesystem server's read_file tool
mcp__github__search_repositories → GitHub server's search

Regex patterns for MCP:

mcp__memory__.* → all tools from memory server
mcp__.*__write.* → any tool containing "write" from any server
mcp__.* → all MCP tools

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

VariableAvailable InDescription
CLAUDE_PROJECT_DIRall command hooksProject root directory. Wrap in quotes for paths with spaces
Plugin root env varplugin hooks onlyPlugin’s root directory. Use for portable script paths
CLAUDE_ENV_FILESessionStart onlyFile path where you can persist env vars for subsequent Bash commands
CLAUDE_CODE_REMOTEall command hooksSet to "true" in remote web environments. Unset in local CLI
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/check-style.sh"
}
#!/bin/bash
if [ -n "$CLAUDE_ENV_FILE" ]; then
echo 'export NODE_ENV=production' >> "$CLAUDE_ENV_FILE"
echo 'export DEBUG_LOG=true' >> "$CLAUDE_ENV_FILE"
# Use append (>>) to preserve vars set by other hooks
fi
exit 0

Variables written to CLAUDE_ENV_FILE are available in all subsequent Bash commands during the session. This variable is ONLY available in SessionStart hooks.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Debug mechanismHow it works
stderr → Claude contextOn exit 2, stderr is fed back to Claude. Use this to explain why something was blocked
stderr → verbose modeOn non-zero exits (not 2), stderr appears in verbose mode (Ctrl+O)
stdout → transcriptOn exit 0, stdout appears in the transcript (unless suppressOutput: true)
stdout for SessionStart/UserPromptSubmitstdout added as context Claude can see and act on
/hooks menuView, add, delete hooks. Shows source labels: [User], [Project], [Local], [Plugin]
disableAllHooks: trueTemporarily disable all hooks in settings or via toggle in /hooks menu

JSON validation failure: If your shell profile prints text on startup, it can interfere with JSON parsing. Keep startup scripts clean or redirect noise to stderr.

Mutual exclusion: Choose one approach per hook — either exit codes alone, or exit 0 + JSON output. Claude Code only processes JSON on exit 0. JSON in exit 2 output is ignored.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Fires when a session begins or resumes. Keep these hooks fast — they run on every session.

Input fields (in addition to common fields):

FieldDescription
source"startup", "resume", "clear", or "compact"
modelModel identifier
agent_typeAgent name if started with claude --agent <name> (optional)

Decision: cannot block. Can inject additionalContext into Claude’s context.


Fires when user submits a prompt, before Claude processes it.

Input fields:

FieldDescription
promptThe text the user submitted

Decision: decision: "block" with reason. Or add additionalContext on allow.


Fires after Claude creates tool parameters, before the tool call executes. Matches on tool name.

Supported tools: Bash, Edit, Write, Read, Glob, Grep, Agent, WebFetch, WebSearch, and any MCP tool.

Input fields:

FieldDescription
tool_nameName of the tool
tool_use_idUnique identifier for this tool call
tool_inputTool-specific parameters (see below)

Key tool_input schemas:

Bash: command (string), description (string, optional), timeout (ms, optional), run_in_background (bool, optional)

Write: file_path (absolute path), content (string)

Edit: file_path, old_string, new_string, replace_all (bool, optional)

Read: file_path, offset (line number, optional), limit (lines, optional)

Agent: prompt, description, subagent_type, model (optional)

Decision: hookSpecificOutput.permissionDecision — allow/deny/ask. Can modify input with updatedInput.


Fires when Claude Code is about to show the user a permission dialog. Matches on tool name.

Difference from PreToolUse: fires specifically when a permission dialog would appear, while PreToolUse fires before every tool execution regardless of permission status.

Input fields:

FieldDescription
tool_nameName of the tool
tool_inputTool parameters
permission_suggestionsArray of “always allow” options the user would normally see (optional)

Decision: hookSpecificOutput.decision.behavior — allow or deny.


Fires immediately after a tool completes successfully. Cannot prevent what already happened.

Input fields:

FieldDescription
tool_nameName of the tool
tool_use_idUnique identifier
tool_inputParameters sent to the tool
tool_responseResult returned by the tool

Decision: decision: "block" with reason provides feedback to Claude. Also supports additionalContext and updatedMCPToolOutput (MCP tools only).


Fires when a tool execution fails. Use for logging, alerts, corrective feedback.

Input fields:

FieldDescription
tool_nameName of the tool
tool_use_idUnique identifier
tool_inputParameters sent to the tool
errorString describing what went wrong
is_interruptWhether failure was caused by user interruption (optional bool)

Decision: cannot block. Can inject additionalContext.


Fires when Claude Code sends a notification. Cannot block notifications.

Input fields:

FieldDescription
messageNotification text
titleOptional title
notification_typepermission_prompt, idle_prompt, auth_success, or elicitation_dialog

Decision: none. Can inject additionalContext.


Fires when a subagent is spawned via the Agent tool. Cannot block subagent creation.

Input fields:

FieldDescription
agent_idUnique identifier for the subagent
agent_typeBuilt-in (Bash, Explore, Plan) or custom agent name

Decision: none. Can inject additionalContext into the subagent’s context.


Fires when a subagent finishes. Uses same decision control as Stop.

Input fields:

FieldDescription
stop_hook_activetrue if already continuing due to a stop hook — check to prevent infinite loops
agent_idUnique identifier for the subagent
agent_typeAgent type (used for matcher filtering)
agent_transcript_pathSubagent’s own transcript in subagents/ folder
last_assistant_messageText content of subagent’s final response

Decision: decision: "block" with reason prevents the subagent from stopping.


Fires when the main Claude Code agent finishes responding. Does not fire on user interrupt.

Input fields:

FieldDescription
stop_hook_activetrue if Claude is already continuing due to a stop hook — ALWAYS check this
last_assistant_messageText content of Claude’s final response

Decision: decision: "block" with reason (required on block) prevents stopping.


Fires when an agent team teammate is about to go idle. Exit code only — no JSON decision control.

Input fields:

FieldDescription
teammate_nameName of the teammate going idle
team_nameName of the team

Decision: exit code 2 only. stderr fed back to teammate as feedback.


Fires when a task is being marked as completed. Exit code only — no JSON decision control.

Input fields:

FieldDescription
task_idIdentifier of the task
task_subjectTitle of the task
task_descriptionDetailed description (may be absent)
teammate_nameName of the completing teammate (may be absent)
team_nameName of the team (may be absent)

Decision: exit code 2 only. stderr fed back to model as feedback.


Fires when a configuration file changes mid-session. Matches on configuration source.

Matcher values: user_settings, project_settings, local_settings, policy_settings, skills

Input fields:

FieldDescription
sourceWhich config type changed
file_pathPath to the specific file modified (optional)

Decision: decision: "block" with reason. Note: policy_settings changes cannot be blocked.


Fires when claude --worktree or isolation: "worktree" triggers worktree creation. Replaces default git behavior — use to support non-git VCS.

Input fields:

FieldDescription
nameSlug identifier for the new worktree (e.g., bold-oak-a3f2 or user-specified)

Decision: hook must print absolute path of created worktree to stdout. Non-zero exit fails creation. Only type: "command" supported.


Cleanup counterpart to WorktreeCreate. Fires when worktree is being removed.

Input fields:

FieldDescription
worktree_pathAbsolute path to the worktree being removed

Decision: none. Cannot block removal. Failures logged in debug mode only. Only type: "command" supported.


Fires before Claude Code runs a context compaction operation.

Matcher values: manual (user ran /compact), auto (auto-compact when context window full)

Input fields:

FieldDescription
trigger"manual" or "auto"
custom_instructionsInstructions from /compact <text>. Empty string for auto

Decision: none. Use for side effects like preserving critical context.


Fires when a Claude Code session terminates.

Matcher values: clear, logout, prompt_input_exit, bypass_permissions_disabled, other

Input fields:

FieldDescription
reasonWhy the session ended (see matcher values)

Decision: none. Cannot block termination. Use for cleanup, logging, state preservation.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

{
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/task-completed.sh",
"timeout": 120
}
]
}
]
}
task-completed.sh
#!/bin/bash
INPUT=$(cat)
STOP_HOOK_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active')
# Always guard against infinite loops
if [ "$STOP_HOOK_ACTIVE" = "true" ]; then
exit 0
fi
if ! npm test 2>&1; then
echo "Tests failing. Fix before stopping." >&2
exit 2
fi
exit 0
#!/bin/bash
# Inject dynamic project context
OPEN_ISSUES=$(gh issue list --limit 5 --json title,number 2>/dev/null || echo "[]")
jq -n --arg ctx "Open issues: $OPEN_ISSUES" '{
hookSpecificOutput: {
hookEventName: "SessionStart",
additionalContext: $ctx
}
}'
exit 0
#!/bin/bash
# PreToolUse hook for Bash
COMMAND=$(jq -r '.tool_input.command' < /dev/stdin)
if echo "$COMMAND" | grep -qE '(rm -rf|DROP TABLE|git push --force)'; then
jq -n '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "Destructive operation blocked by policy"
}
}'
exit 0
fi
exit 0
#!/bin/bash
INPUT=$(cat)
TOOL=$(echo "$INPUT" | jq -r '.tool_name')
# Auto-approve read-only tools
if [[ "$TOOL" =~ ^(Read|Glob|Grep)$ ]]; then
jq -n '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "allow",
permissionDecisionReason: "Read-only operation auto-approved"
}
}'
exit 0
fi
exit 0
{
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/lint-check.sh",
"async": true,
"timeout": 60
}
]
}
]
}

Subagent context injection (SubagentStart)

Section titled “Subagent context injection (SubagentStart)”
{
"SubagentStart": [
{
"hooks": [
{
"type": "command",
"command": "echo '{\"hookSpecificOutput\":{\"hookEventName\":\"SubagentStart\",\"additionalContext\":\"Follow security policy: no hardcoded secrets, always use env vars.\"}}'"
}
]
}
]
}

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Infinite loop on Stop (most common mistake)

Section titled “Infinite loop on Stop (most common mistake)”
# WRONG — causes infinite loop
#!/bin/bash
npm test || { echo "Fix tests" >&2; exit 2; }
exit 0
# CORRECT — always check stop_hook_active
#!/bin/bash
INPUT=$(cat)
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
exit 0 # already continuing from a stop hook — don't loop
fi
npm test || { echo "Fix tests" >&2; exit 2; }
exit 0

Same pattern applies to SubagentStop.

// BAD — fires on every single tool call
{ "matcher": "*" }
// BETTER — target specific tools
{ "matcher": "Bash|Write|Edit" }
// BEST — target only what you need to control
{ "matcher": "Bash" }
Terminal window
# WRONG — JSON is ignored on exit 2
echo '{"hookSpecificOutput":{"permissionDecision":"deny"}}'
exit 2
# CORRECT — exit 0 + JSON for structured control
echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"Blocked"}}'
exit 0
# CORRECT — exit 2 for simple blocking with stderr message
echo "This operation is blocked" >&2
exit 2
Terminal window
# BAD — shell startup scripts that echo text will break JSON parsing
# ~/.zshrc: echo "Welcome to my shell!" ← this breaks hooks
# CORRECT — redirect non-JSON startup output to stderr or suppress it
# Or test your hook: echo '{}' | your-hook.sh | jq .
// WRONG — async hooks cannot make decisions
{
"type": "command",
"command": ".claude/hooks/block-unsafe.sh",
"async": true // decisions like permissionDecision have no effect
}
// CORRECT — use sync for blocking/control, async for side effects
{
"type": "command",
"command": ".claude/hooks/log-tool-use.sh",
"async": true // fine for logging
}

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Set "async": true on type: "command" hooks to run in background without blocking Claude. Use for long-running side effects (deployments, test suites, logging, notifications).

Critical: Async hooks cannot control Claude’s behavior. decision, permissionDecision, continue, and other response fields have no effect because the action they would have controlled has already completed.

When the async script finishes, its output is delivered on the next conversation turn.

{
"PostToolUse": [
{
"matcher": "Write",
"hooks": [
{
"type": "command",
"command": "/path/to/run-tests.sh",
"async": true,
"timeout": 120
}
]
}
]
}

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Hooks defined in skill/agent YAML frontmatter are scoped to that component’s lifetime and cleaned up when it finishes.

---
name: secure-operations
description: Perform operations with security checks
hooks:
PreToolUse:
- matcher: "Bash"
hooks:
- type: command
command: "./scripts/security-check.sh"
timeout: 30
Stop:
- hooks:
- type: command
command: "./scripts/verify-complete.sh"
---

Note: For subagents, Stop hooks in frontmatter are automatically converted to SubagentStop since that’s the event that fires when a subagent completes.

The once field (skills only) makes a hook run once per session then remove itself — useful for one-time setup:

hooks:
SessionStart:
- hooks:
- type: command
command: "./scripts/load-context.sh"
once: true

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

All matching hooks in a matcher group run in parallel. Identical handlers are deduplicated automatically:

  • Command hooks: deduplicated by command string
  • HTTP hooks: deduplicated by URL
  • Prompt/agent hooks: deduplicated by prompt string

Handlers run in the current directory with Claude Code’s environment variables.