Skip to content

Hooks reference

Every hook event, matcher, handler type, input field, exit code and JSON output field in Claude Code, with worked examples and the security rules that apply.

Hooks are handlers you configure to run automatically at fixed points in a Claude Code session: before a tool runs, after a file is written, when a session starts, when Claude tries to stop. A handler can be a shell command, an HTTP endpoint, an MCP tool call, a single LLM prompt or a small agent. Because they fire at fixed lifecycle points rather than when the model decides to, hooks are how you make behaviour deterministic: formatting after every edit, a hard block on git push --force, a notification when Claude needs you.

This page is the full reference. If you have never written a hook, start with the hooks guide, which builds a few useful ones step by step. The same events fire in the terminal, the IDE extensions, the desktop app and cloud sessions.

Plugins can also register hooks as JavaScript functions running inside Claude Code; those are mods, documented under mod events. Everything on this page keeps working alongside them.

How a hook fires

Three layers decide whether anything runs:

  1. Event: the lifecycle point, such as PreToolUse or Stop.
  2. Matcher group: a filter on that event (for tool events, the tool name).
  3. Handler: the thing that runs, optionally narrowed further with an if rule.

When an event fires and a matcher matches, Claude Code sends JSON describing the event to the handler (on stdin for commands, as the POST body for HTTP). The handler can stay silent, add context, or return a decision.

Here is a hook I use on client repositories to stop Claude pushing directly to main:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "if": "Bash(git push *)",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/guard-main.sh",
            "args": []
          }
        ]
      }
    ]
  }
}
#!/bin/bash
# .claude/hooks/guard-main.sh
cmd=$(jq -r '.tool_input.command')
if grep -Eq '(^| )(origin )?main( |$)|HEAD:main' <<<"$cmd"; then
  jq -n '{hookSpecificOutput: {hookEventName: "PreToolUse",
    permissionDecision: "deny",
    permissionDecisionReason: "Push to a feature branch and open a PR instead of pushing to main."}}'
fi
exit 0

When Claude runs git push origin main:

  1. PreToolUse fires with {"tool_name": "Bash", "tool_input": {"command": "git push origin main", ...}, ...}.
  2. The matcher Bash matches the tool name, so the group is active.
  3. The if rule Bash(git push *) matches a subcommand, so the script is spawned. For npm test it would not be, saving a process launch.
  4. The script prints a deny decision.
  5. Claude Code blocks the call and shows Claude the reason, and Claude opens a branch instead.

For git push origin feature/vat, the script prints nothing and exits 0. That means "no opinion", so the call continues through the normal permission flow. A silent hook never approves anything by itself.

The scripts on this page use jq; install it if you copy them.

Events at a glance

Events come in three rhythms: once per session (SessionStart, SessionEnd), once per turn (UserPromptSubmit, Stop, StopFailure), and once per tool call (PreToolUse, PostToolUse; EndConversation calls skip both). The rest fire when something specific happens.

EventFires whenMatcher filters onCan block?
SessionStartA session begins or resumesHow it started: startup, resume, clear, compact, forkNo
SetupLaunch with --init-only, or -p with --init or --maintenanceinit, maintenanceNo
InstructionsLoadedA CLAUDE.md or .claude/rules/*.md file loadsLoad reasonNo
UserPromptSubmitA prompt is submitted (including scheduled tasks, returning background subagents and cross-session messages)NoneYes
UserPromptExpansionA typed /command expands into a promptCommand nameYes
MessageDisplayAssistant text is streaming to the screenNoneNo (display only)
PreToolUseBefore a tool call runsTool nameYes
PermissionRequestClaude Code is about to ask for permissionTool nameVia decision object only
PermissionDeniedAuto mode denies a callTool nameNo
PostToolUseA tool call succeededTool nameNo (feedback only)
PostToolUseFailureA tool call that started has failedTool nameNo
PostToolBatchA batch of parallel tool calls has fully resolvedNoneYes (stops the loop)
NotificationClaude Code sends a notificationNotification typeNo
SubagentStartA subagent is spawned or resumed, or an in-process teammate takes a messageAgent typeNo
SubagentStopA subagent finishesAgent typeYes
TaskCreatedTaskCreate is creating a taskNoneYes
TaskCompletedA task is being marked completeNoneYes
StopClaude finishes responding (not on user interrupt)NoneYes
StopFailureThe turn ended on an API errorError typeNo
TeammateIdleAn agent team teammate is about to go idleNoneYes
ConfigChangeA settings, policy or skill file changes mid-sessionSourceYes (not policy)
CwdChangedThe working directory changesNoneNo
DirectoryAdded/add-dir or the SDK's register_repo_root adds a directoryslash_command, register_repo_rootNo
FileChangedA watched file changes on diskLiteral filenamesNo
WorktreeCreateA worktree is needed (--worktree, isolation: worktree, background sessions)NoneYes (any failure)
WorktreeRemoveA hook-created worktree is being removedNoneYes (any failure)
PreCompactBefore compactionmanual, autoYes
PostCompactAfter compactionmanual, autoNo
PreModelSwitchBefore a requested model switchTarget model's canonical nameYes
PostModelSwitchAfter the session's model changesTarget model's canonical nameNo
ElicitationAn MCP server asks for user inputMCP server nameYes
ElicitationResultAfter you answer an elicitationMCP server nameYes
SessionEndThe session endsReasonNo

Configuration

Where hooks live

LocationScopeShared?
~/.claude/settings.jsonAll your projectsNo
.claude/settings.jsonOne projectYes, committed
.claude/settings.local.jsonOne projectNo (git-ignored when Claude Code writes it)
Managed settingsOrganisationAdmin-controlled
A plugin's hooks/hooks.jsonWhile the plugin is enabledYes
Skill frontmatterRest of the session after the skill is invokedYes
Subagent frontmatterWhile that subagent runsYes

Notes on scope:

  • Hooks from settings, managed policy and plugins also fire inside subagents; tool events then carry agent_id and agent_type.
  • Cloud sessions do not read your local ~/.claude/settings.json. Self-hosted environments also run hooks seeded from the runner host's ~/.claude/ and, in some configurations, the runner image's managed settings. See Cloud environments.
  • Entries merge across levels rather than replacing each other. User, project and local hooks are added to managed ones, and disableAllHooks outside managed settings cannot switch managed hooks off.
  • Under the managed allowManagedHooksOnly setting, user, project, local and plugin hooks are blocked (except plugins force-enabled in managed enabledPlugins); statusLine, fileSuggestion and subagentStatusLine are restricted to managed settings; command-source plugins and marketplace headersHelper commands are blocked unless disableCommandPluginSources is explicitly false. See the settings reference.
  • allowedHttpHookUrls restricts HTTP hook URLs, and httpHookAllowedEnvVars restricts which variables may be interpolated into their headers, for hooks from every source.

Matchers

How a matcher string is interpreted depends on its characters:

MatcherTreated asExample
"*", "" or absentMatch everythingEvery occurrence
Only letters, digits, _, -, spaces, , and |Exact name, or a list separated by | or ,Edit|Write, Edit, Write, security-reviewer
Anything elseUnanchored JavaScript regex^Notebook, mcp__linear__.*

Regex matchers succeed on a match anywhere, so Edit.* also catches NotebookEdit; anchor with ^Edit$ if you mean it. FileChanged and StopFailure use a stricter exact set (letters, digits, _ and | only), so a hyphen, space or comma there sends the matcher down the regex path, and only | separates alternatives. Adding a matcher to an event that does not support one is silently ignored.

What each event's matcher is compared against:

EventCompared withValues
PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest, PermissionDeniedTool nameBash, Edit|Write, mcp__.*
SessionStartStart sourcestartup, resume, clear, compact, fork
SetupTrigger flaginit, maintenance
SessionEndEnd reasonclear, resume, logout, prompt_input_exit, other
NotificationTypepermission_prompt, idle_prompt, auth_success, elicitation_dialog, elicitation_url_dialog, elicitation_complete, elicitation_response, agent_needs_input, agent_completed, quota_auto_resume_fired, quota_auto_resume_stale, quota_auto_resume_disabled
SubagentStart, SubagentStopAgent typegeneral-purpose, Explore, Plan, custom names, ^my-plugin:reviewer$
PreCompact, PostCompactTriggermanual, auto
PreModelSwitch, PostModelSwitchCanonical target model nameclaude-opus-5, claude-opus-4-6|claude-opus-5, .*opus.*
ConfigChangeSourceuser_settings, project_settings, local_settings, policy_settings, skills
DirectoryAddedHow it was addedslash_command, register_repo_root
FileChangedFilenames to watch.envrc|.env
StopFailureError typerate_limit, overloaded, authentication_failed, oauth_org_not_allowed, account_on_hold, billing_error, invalid_request, model_not_found, server_error, max_output_tokens, cloud_credential_error (v2.1.267), unknown
InstructionsLoadedLoad reasonsession_start, nested_traversal, path_glob_match, include, compact
UserPromptExpansionCommand nameYour skill or command names
Elicitation, ElicitationResultMCP server nameYour server names
UserPromptSubmit, PostToolBatch, Stop, TeammateIdle, TaskCreated, TaskCompleted, WorktreeCreate, WorktreeRemove, MessageDisplay, CwdChangedNothingAlways fire

Matching MCP tools

MCP tools appear in tool events as mcp__<server>__<tool>, e.g. mcp__linear__create_issue. To match a whole server you must add .* (mcp__linear__.*): a bare mcp__linear contains only exact-match characters and therefore matches no tool. mcp__.*__delete.* catches deletes from any server.

Tools from a plugin-bundled server include the plugin in the server segment: mcp__plugin_<plugin>_<server>__<tool>. For a plugin crm-tools bundling a server keyed hubspot, use mcp__plugin_crm-tools_hubspot__.*. Use the same scoped name in if rules.

Handler types

Each object in a group's inner hooks array is a handler. All matching handlers run in parallel. An identical handler defined in several settings files runs once, though a plugin's or skill's copy stays separate.

typeWhat runsCommunicates through
commandA shell command or executableExit code, stdout, stderr
httpA POST to a URLResponse status and JSON body
mcp_toolA tool on a configured MCP serverThe tool's text output, read like stdout
promptA single-turn Claude evaluation{ "ok": ..., "reason": ... }
agentAn experimental verifier subagent with tools{ "ok": ..., "reason": ... }

Handlers run in the current directory with Claude Code's environment. If that directory has vanished (a deleted worktree, say), command hooks fall back to the first existing of: the session's start directory, the project root, your home directory, the system temp directory, with a warning in the debug log. $CLAUDE_CODE_REMOTE is "true" in cloud sessions, and $CLAUDE_CODE_BRIDGE_SESSION_ID is set while Remote Control is connected (v2.1.199).

Fields every handler accepts

FieldRequiredMeaning
typeyesOne of the five types
ifnoOne permission rule (e.g. "Bash(npm publish *)" or "Edit(*.sql)") that must match for the handler to run. Only evaluated on PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest and PermissionDenied; elsewhere a handler with if never runs
timeoutnoSeconds before cancelling. Defaults: 600 for command, http and mcp_tool; 30 for prompt; 60 for agent. Lowered to 30 on UserPromptSubmit, PreModelSwitch and PostModelSwitch, and 10 on MessageDisplay. Not enforced on async command hooks. SessionEnd has its own budget (below)
statusMessagenoSpinner text while it runs
oncenoRemove after the first successful run. Only honoured in skill frontmatter

if holds exactly one rule; there is no &&, || or list form, so define separate handlers for several conditions. A single-segment directory pattern such as Edit(app/**) matches only app at the top of the working directory; use Edit(**/app/**) for any depth (since v2.1.214).

How Bash if rules match. Leading VAR=value assignments are stripped, each subcommand of a compound command is checked, and commands inside $() and backticks are checked too:

ifCommandRuns?Why
Bash(git *)GIT_TRACE=1 git pushYesAssignment stripped
Bash(git *)pnpm test && git pushYesSecond subcommand matches
Bash(rm *)echo $(rm -rf dist)YesSubstitution contents checked
Bash(rm *)echo $(date)NoNothing matches
Bash(git push *)echo $(date)YesPatterns more specific than the command name run anyway when $(), backticks or $VAR appear

When Claude Code cannot work out what a Bash command will run, it runs the hook regardless. if is a performance filter, not a security boundary; enforce hard rules with permissions.

Command handlers

FieldRequiredMeaning
commandyesShell command, or with args, the executable to spawn
argsnoArgument vector; switches to exec form
asyncnoRun in the background without blocking
asyncRewakenoRun in the background and wake Claude if it exits 2, showing stderr (or stdout if stderr is empty) as a system reminder
shellno"bash" (default) or "powershell" (default on Windows without Git Bash). Ignored with args

Exec form versus shell form. With args, the hook is spawned directly: command is resolved on PATH, each args element is passed as exactly one argument, and placeholders like ${CLAUDE_PLUGIN_ROOT} are substituted as plain strings with no quoting needed and no shell interpretation of $, quotes or backticks. Without args, the string goes to a shell (sh -c on macOS and Linux, Git Bash on Windows, PowerShell without Git Bash) which tokenises it and handles pipes, &&, redirects and globs.

Use exec form whenever a hook references a path placeholder; use shell form when you need shell features.

{ "type": "command", "command": "node", "args": ["${CLAUDE_PLUGIN_ROOT}/hooks/lint-changed.mjs", "--fix"] }
{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}\"/hooks/lint-changed.mjs --fix" }

Both forms export CLAUDE_PROJECT_DIR, CLAUDE_PLUGIN_ROOT and CLAUDE_PLUGIN_DATA to the process.

On Windows, exec form needs a real executable. The .cmd and .bat shims in node_modules/.bin are not, so call the script through node ("command": "node", "args": ["<path>/node_modules/prettier/bin/prettier.cjs", ...]) or use shell form. A bare command containing spaces alongside args (such as "node script.js") logs a warning because no such executable exists; absolute paths with spaces like C:\Program Files\nodejs\node.exe are fine.

Plugin hooks substitute ${user_config.*} only in exec form. A shell-form plugin hook referencing it fails with an error instead (since v2.1.207); read $CLAUDE_PLUGIN_OPTION_<KEY> from the environment instead, e.g. $CLAUDE_PLUGIN_OPTION_SLACK_WEBHOOK for slack_webhook. See the manifest reference.

HTTP handlers

FieldRequiredMeaning
urlyesWhere to POST
headersnoExtra headers; values may use $VAR or ${VAR}
allowedEnvVarsnoVariables allowed in header interpolation; anything not listed becomes an empty string. Required for interpolation to work at all

The event JSON is sent as the body with Content-Type: application/json, and the response body uses the same JSON output format as command hooks.

{
  "type": "http",
  "url": "https://hooks.internal.example/claude/pre-tool",
  "timeout": 15,
  "headers": { "Authorization": "Bearer ${AUDIT_TOKEN}" },
  "allowedEnvVars": ["AUDIT_TOKEN"]
}

MCP tool handlers

FieldRequiredMeaning
serveryesConfigured server name. For plugin-bundled servers use plugin:<plugin>:<server>, e.g. plugin:crm-tools:hubspot
toolyesTool to call
inputnoArguments; string values can pull from the hook input with ${path}, e.g. "${tool_input.file_path}"
{
  "type": "mcp_tool",
  "server": "semgrep",
  "tool": "scan_file",
  "input": { "path": "${tool_input.file_path}" }
}

The tool's text is read exactly like command stdout; an isError: true result is a non-blocking error.

On events that can block or change outcomes (such as PreToolUse or Stop), Claude Code waits for a still-connecting server for up to MCP_TIMEOUT, within the hook's own timeout. On observational events (Notification, SessionEnd) it does not wait. A server in cached status connects when the hook calls it; if it still is not connected, that is a non-blocking error. Hooks never start OAuth, so authenticate in /mcp first.

SessionStart at launch (including --continue and --resume) and every Setup fire before MCP servers are available, so their mcp_tool hooks are skipped with a debug log line saying there is no MCP client context. Later SessionStart firings (after /clear or compaction) do run them. Use a command hook for launch-time work.

Prompt and agent handlers

FieldRequiredMeaning
promptyesPrompt text. $ARGUMENTS is replaced with the hook input JSON (appended if absent). Escape a literal dollar as \$5.00
modelnoDefaults to the model used for background work
continueOnBlocknoPrompt hooks only; see prompt-based hooks

Path placeholders

  • ${CLAUDE_PROJECT_DIR}: the project root where the session started (also set for stdio MCP servers and plugin LSP servers).
  • ${CLAUDE_PLUGIN_ROOT}: the plugin's installed directory, which changes with each version.
  • ${CLAUDE_PLUGIN_DATA}: the plugin's persistent data folder, which survives updates. See Plugin components.

If Claude moves into a worktree, ${CLAUDE_PROJECT_DIR} stays pointing at the original checkout (so your scripts are still found), while the cwd field in the hook input follows Claude into the worktree or wherever it has cd-ed. Read cwd when the hook needs to know where Claude is working.

Plugin hooks live in hooks/hooks.json, which may have a top-level description:

{
  "description": "Run Prettier on anything Claude edits",
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          { "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/bin/prettier-changed.sh", "args": [], "timeout": 45 }
        ]
      }
    ]
  }
}

Hooks in skills and subagents

Both accept a hooks block in their YAML frontmatter, in the same shape as settings.

  • Subagent hooks run only while that subagent runs. A Stop hook there is converted to SubagentStop.
  • Skill hooks are registered when the skill is invoked and keep running for the rest of the session, unless they set once: true.
---
name: db-migrations
description: Write and apply database migrations safely
hooks:
  PreToolUse:
    - matcher: "Bash"
      hooks:
        - type: command
          if: "Bash(*migrate*)"
          command: "./scripts/require-backup.sh"
---

Project skill frontmatter hooks follow the same trust rule as settings hooks and are registered on invocation, even in a -p run in an untrusted folder. Project subagent frontmatter hooks are stricter: they only run after the folder the agent file came from is trusted, and -p does not count (since v2.1.218). See Subagents and Permissions.

Inspecting and disabling hooks

/hooks opens a read-only browser listing each hook with its source (user, project, local, plugin, session); select one to see exactly what it runs and where it is defined, or choose All events to browse events with nothing configured.

To remove a hook, delete it from its file. To disable everything temporarily, set "disableAllHooks": true. The effective value follows settings precedence, so a project's false beats your user true; to force hooks off for one run regardless, use claude --settings '{"disableAllHooks": true}'. Only disableAllHooks in managed settings can disable managed hooks. There is no per-hook off switch. Edits to settings files are usually picked up automatically.

Hook input

Commands get JSON on stdin; HTTP hooks get it as the body. On macOS and Linux, command hooks run in their own session with no controlling terminal, so they cannot open /dev/tty. Use systemMessage to show text and terminalSequence for notifications or titles.

Fields common to most events

FieldMeaning
session_idSession id
prompt_idUUID of the prompt being processed (matches OpenTelemetry prompt.id); absent before the first input
transcript_pathTranscript file. Written asynchronously, so it may lag; use last_assistant_message on Stop/SubagentStop for the final text
cwdCurrent working directory
scratchpad_dirThe session's scratchpad folder, when there is one (v2.1.257)
permission_modedefault, plan, acceptEdits, auto, dontAsk or bypassPermissions (Manual arrives as default). Not on every event
effort{ "level": ... }, the effort actually in effect, on tool-context events when the model supports effort. Also $CLAUDE_EFFORT
hook_event_nameThe event
agent_idPresent inside a subagent
agent_typeThe agent name, with --agent or inside a subagent (subagent wins)

Only SessionStart may carry model; model-switch events carry from_model and to_model. There is no $CLAUDE_MODEL, and $ANTHROPIC_MODEL does not follow /model changes.

Hook processes inherit Claude Code's environment, minus the OTEL_* exporter variables, minus credentials when CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1, and minus Anthropic credentials under the HIPAA configuration.

A typical PreToolUse payload:

{
  "session_id": "8f1c2a",
  "prompt_id": "3d0b7c9e-6f41-4b0a-9f52-1c7e8a2d4b10",
  "transcript_path": "/Users/cam/.claude/projects/-Users-cam-dev-portal/8f1c2a.jsonl",
  "cwd": "/Users/cam/dev/portal",
  "permission_mode": "default",
  "hook_event_name": "PreToolUse",
  "tool_name": "Bash",
  "tool_input": { "command": "pnpm test --filter api", "description": "Run API tests", "timeout": 120000, "run_in_background": false },
  "tool_use_id": "toolu_01X9..."
}

Hook output

Exit codes

Exit codes and JSON work together: Claude Code reads JSON from stdout on every exit code, and for standard-decision events a valid object takes effect alongside the code. The one thing JSON cannot override is exit 2's block.

Exit 0: success. For most events stdout goes to the debug log only. For UserPromptSubmit, UserPromptExpansion, SessionStart and PostModelSwitch, plain-text stdout is added to Claude's context.

Whether stdout is read as JSON:

  • It starts with { and ends with } (ignoring surrounding whitespace): parsed as JSON. If it is several lines that each parse as JSON and none sets a known field, the whole thing is plain text; if one does set a field, it is a parse failure.
  • Starts with { but does not end with }, or starts with anything else (arrays and quoted strings included): plain text.

For standard-decision events, JSON that fails schema validation, or that cannot be parsed, is a non-blocking error on any code except 2: the action proceeds and the transcript shows <hook name> hook error with the message (and on the context-adding events the text is not added). Stderr on exit 0 goes only to the debug log; Claude never sees it.

Exit 2: block. On events that can block, exit 2 blocks even if your JSON says allow. Any valid JSON is still read (except hookSpecificOutput on the elicitation events). The block message is the reason from your JSON if it made a blocking decision, otherwise stderr. Invalid JSON with exit 2 still blocks, using stderr (since v2.1.214).

#!/bin/bash
# Refuse any Bash command that touches production terraform state
cmd=$(jq -r '.tool_input.command')
if [[ "$cmd" == *"terraform"*"-chdir=envs/prod"* ]]; then
  echo "Production terraform is applied by CI only." >&2
  exit 2
fi
exit 0

Any other code. With valid JSON (standard-decision events), the exit code is ignored and the JSON decides. With invalid or unparseable JSON, a non-blocking error. With plain text or no output, a non-blocking error showing Failed with non-blocking status code: and the first line of stderr. A hook that cannot even start (missing or non-executable script, exit 127) lands here too, which means a mistyped path silently disables a policy hook. Watch for that notice the first time a new gate runs.

Warning: Exit 1 does not block. If a hook enforces policy, use exit 2 (or a JSON decision). The worktree events are the exception: any non-zero exit fails them.

Events outside the standard decision model keep their own rules: WorktreeCreate fails on any non-zero exit whatever the JSON says, and events that discard output entirely (like StopFailure) ignore JSON on every code apart from terminalSequence.

Timeouts

A command, http or mcp_tool hook that reaches its timeout is cancelled and its output discarded (async command hooks excepted), so on most events it simply makes no decision. Two exceptions: on PreModelSwitch a timeout blocks the switch; and on PreToolUse, a timed-out command, http or mcp_tool hook does not block (the call continues through normal permissions), while an Agent SDK callback hook that times out does block.

What exit 2 does, per event

EventEffect of exit 2
PreToolUseBlocks the call; stderr becomes Claude's denial reason
PermissionRequestIgnored; use the decision object
UserPromptSubmitRejects the prompt
UserPromptExpansionBlocks the expansion
Stop, SubagentStopKeeps Claude (or the subagent) going
TeammateIdleTeammate keeps working
TaskCreatedRolls back the task
TaskCompletedTask not marked complete
ConfigChangeChange not applied (except policy_settings)
PostToolBatchStops the loop before the next model call
PreCompactBlocks compaction
PreModelSwitchBlocks the switch, showing stderr
ElicitationDenies the request
ElicitationResultResponse becomes decline
WorktreeCreateAny non-zero fails creation
WorktreeRemoveAny non-zero fails removal if the directory still exists
PostToolUse, PostToolUseFailureShows stderr to Claude; the tool already ran or failed
SessionStart, SubagentStart, PostModelSwitchShows stderr to you as a hook error notice (in the subagent's own transcript for SubagentStart); Claude does not see it
SessionEnd, CwdChanged, FileChanged, PostCompactStderr shown to you only
DirectoryAddedStderr to the debug log; already added
PermissionDeniedIgnored; use retry
Notification, Setup, InstructionsLoadedIgnored
StopFailureEverything ignored except terminalSequence
MessageDisplayOriginal text displayed

HTTP responses

ResponseTreated as
2xx, empty bodySuccess with no output
2xx, JSON objectParsed like command JSON; invalid schema is a non-blocking error
2xx, other bodyNon-blocking error; text is not added to context
Non-2xxNon-blocking error
Connection failureNon-blocking error
TimeoutCancelled as above

Status codes alone cannot block. To block or deny, return 2xx with the decision JSON. Events with their own failure rules (like WorktreeCreate) apply them to HTTP hooks too.

JSON output

Pick one style per hook: exit codes alone, or exit 0 with JSON. If you mix them, exit 2 still blocks and the JSON is still read. Stdout must contain only the JSON; a shell profile that prints on start-up will break parsing (see the hooks guide).

Size cap. additionalContext, systemMessage, initialUserMessage and plain stdout are each capped at 10,000 characters (measured per field, per hook). Anything longer is saved to a file in the session directory and replaced by its path plus a preview of up to 2,000 characters. Claude is not told to read the file, and nothing raises this cap.

Universal fields:

FieldDefaultMeaning
continuetruefalse stops Claude entirely after this hook, overriding any event decision. For PreToolUse and PostToolUse this applies even if the call fails or completes mid-stream
stopReasonnoneShown to you when continue is false; stays in the conversation
suppressOutputfalseAccepted, does nothing (stdout is never shown in the transcript anyway)
systemMessagenoneWarning shown to you; may arrive as an informational message in SDK and stream-json output
terminalSequencenoneAn allowlisted escape sequence for Claude Code to write for you
{ "continue": false, "stopReason": "Type check failed. Fix the errors in app/billing before continuing." }

Terminal notifications

Hooks cannot write to the terminal, so return terminalSequence and Claude Code writes it through its own output path, which works under tmux, screen and on Windows. Allowed: OSC 0, 1, 2 (titles); OSC 9 (iTerm2, ConEmu, Windows Terminal, WezTerm notifications, including 9;4 progress); OSC 99 (Kitty); OSC 777 (urxvt, Ghostty, Warp); and a bare BEL, terminated by BEL or ST. Anything else (CSI, palette, OSC 8 links, OSC 52 clipboard, OSC 1337) causes the field to be ignored. It is only written in interactive sessions with the UI on screen (never in -p or the SDK), and it works even on events that discard systemMessage, such as Notification and StopFailure. A command WorktreeCreate hook cannot use it (its stdout is the path), but an HTTP one can.

#!/bin/bash
# Notification hook: desktop alert via OSC 777, plus a window title
msg=$(jq -r '.message // "Claude Code is waiting"')
seq=$(printf '\033]777;notify;Claude Code;%s\007\033]2;Claude: needs input\007' "$msg")
jq -nc --arg s "$seq" '{terminalSequence: $s}'

Adding context for Claude

hookSpecificOutput.additionalContext puts text into Claude's context as a system reminder at the point the hook fired. It does not appear as a chat message.

{
  "hookSpecificOutput": {
    "hookEventName": "PostToolUse",
    "additionalContext": "app/generated/schema.ts is generated from prisma/schema.prisma. Edit the Prisma schema and run pnpm db:generate."
  }
}

Where it lands: at the start of the conversation for SessionStart and SubagentStart; alongside the prompt for UserPromptSubmit and UserPromptExpansion; next to the tool result for PreToolUse, PostToolUse, PostToolUseFailure and PostToolBatch; at the end of the turn (continuing it) for Stop and SubagentStop; with the next request for PostModelSwitch. Multiple hooks' values are all delivered.

Good uses: current branch or deploy target, rules that depend on which file was touched, data fetched from internal systems. For static rules, use CLAUDE.md. Write it as plain facts ("Staging is the deploy target for this branch"), because text dressed up as system commands can trip Claude's prompt-injection defences and get reported back to you instead.

The injected text is saved in the transcript. On resume, mid-session values are replayed as they were (so timestamps go stale), while SessionStart runs again with source of resume or fork and can refresh.

Decision control by event

EventsHow to decideFields
UserPromptSubmit, UserPromptExpansion, PostToolUse, PostToolUseFailure, PostToolBatch, Stop, SubagentStop, ConfigChange, PreCompactTop-level decisiondecision: "block" plus reason. Stop/SubagentStop also take additionalContext for non-error feedback
TeammateIdle, TaskCompletedExit code or continue: falseExit 2 blocks with stderr as feedback; continue: false stops the teammate (ignored by TaskCompleted when triggered by TaskUpdate)
TaskCreatedExit code or decisionExit 2 or decision: "block" cancels the task; continue: false ignored
PreToolUsehookSpecificOutputpermissionDecision (allow, deny, ask, defer), permissionDecisionReason, updatedInput, additionalContext
PreModelSwitchhookSpecificOutput or decisionpermissionDecision (allow, deny, ask) and reason; decision: "block" cancels
PermissionRequesthookSpecificOutput.decisionbehavior (allow, deny) and friends
PermissionDeniedhookSpecificOutputretry: true
WorktreeCreateThe pathCommand prints it; HTTP returns worktreePath
WorktreeRemoveExit codeJSON discarded
Elicitation, ElicitationResulthookSpecificOutputaction, content
MessageDisplayhookSpecificOutputdisplayContent (display only)
SessionStart, SubagentStart, PostModelSwitchContext onlyadditionalContext; SessionStart also initialUserMessage, watchPaths, sessionTitle, reloadSkills
Setup, Notification, SessionEnd, PostCompact, InstructionsLoaded, StopFailure, CwdChanged, DirectoryAdded, FileChangedNoneSide effects only

The only value of top-level decision is "block"; to allow, omit it.

Rewriting rather than deciding: PreToolUse can replace tool arguments with updatedInput; PermissionRequest can do so inside its decision; PostToolUse can replace the result with updatedToolOutput. UserPromptSubmit cannot rewrite the prompt, only add context. For redaction, intercept outbound data at PreToolUse and inbound results at PostToolUse. Anthropic's example Bash validator is a fuller reference implementation.

Event reference

SessionStart

Runs when a session starts or resumes. Good for loading dynamic context (open issues, recent commits) or setting up the environment; use CLAUDE.md for static context. It runs on every session, so keep it quick. Only command and mcp_tool handlers are supported.

SourceWhen
startupNew session
resume--resume, --continue or /resume
clear/clear
compactAfter compaction
fork--fork-session, /fork, /branch, or a conversation moved to the background (before v2.1.214, forks reported resume)

At launch, on resume-at-launch and after /clear, these hooks run in the background: you can type immediately and a resumed conversation appears straight away, but Claude's first response waits for them, and so does any prompt you send meanwhile (press Esc to take it back). An in-session /resume waits for them before switching. If you clear or switch while background hooks are running, their results are discarded.

Input: source; model (may be absent, e.g. after /clear); agent_type with --agent; session_title when a custom title exists (generated titles do not count). On resume or fork with at least one prior response (v2.1.251), also seconds_since_last_response, context_tokens, prompt_cache_likely_expired and estimated_cache_write_usd, so a hook can warn you what resuming a stale conversation will cost.

Output: plain stdout goes into context. JSON adds:

FieldEffect
additionalContextContext before the first prompt
initialUserMessageIn -p, becomes the first user turn (with or without a prompt; a given prompt follows it)
sessionTitleSets the title like /rename; applies on startup, resume and fork only
watchPathsAbsolute paths for FileChanged to watch
reloadSkillstrue rescans skill and command folders after the hooks finish, so skills the hook installed work from the first prompt
#!/bin/bash
# Name the session after the branch and give Claude the ticket context
branch=$(git branch --show-current 2>/dev/null)
ticket=$(grep -oE '[A-Z]+-[0-9]+' <<<"$branch" | head -1)
ctx="Branch: $branch"
[ -n "$ticket" ] && ctx="$ctx. Jira ticket: $ticket."
jq -nc --arg c "$ctx" --arg t "${branch:-session}" \
  '{hookSpecificOutput: {hookEventName: "SessionStart", additionalContext: $c, sessionTitle: $t}}'

Persisting environment variables. SessionStart, Setup, CwdChanged and FileChanged hooks get CLAUDE_ENV_FILE, a script sourced before each later Bash command. Append export lines to it (append, so you do not clobber other hooks):

#!/bin/bash
[ -n "$CLAUDE_ENV_FILE" ] || exit 0
echo 'export AWS_PROFILE=client-staging' >> "$CLAUDE_ENV_FILE"
echo 'export PATH="$PATH:$PWD/node_modules/.bin"' >> "$CLAUDE_ENV_FILE"

To capture everything a setup script changes, diff export -p | sort before and after running it and append the new lines.

Setup

Fires only for claude --init-only (init), claude -p --init (init) and claude -p --maintenance (maintenance), never on normal start-up. Use it for one-off preparation driven by CI or scripts; use SessionStart for per-session work. --init-only runs Setup and startup SessionStart hooks, then exits silently; to confirm they ran, use --debug-file <path> and check the log. With -p you still need a prompt unless a SessionStart hook supplies initialUserMessage or you are resuming a deferred tool call.

Input adds trigger (init or maintenance). It cannot block, and all JSON output is discarded; in -p with --output-format stream-json --verbose, its output appears only as hook_response events. It gets CLAUDE_ENV_FILE. Only command handlers run (mcp_tool is always skipped).

Because Setup does not run every launch, plugins should not rely on it for dependencies; check and install on first use into ${CLAUDE_PLUGIN_DATA}, or rely on the automatic Node dependency install described on How plugins load.

InstructionsLoaded

Fires when a CLAUDE.md or .claude/rules/*.md file loads, at start-up and lazily later (a nested CLAUDE.md in a folder Claude enters, a path-scoped rule matching). It is asynchronous and observational. It does not fire when AGENTS.md is read directly via the Project instructions setting, but does when a CLAUDE.md imports it (include) or is a symlink to it.

Input: file_path; memory_type (User, Project, Local, Managed); load_reason (session_start, nested_traversal, path_glob_match, include, compact); globs for path_glob_match; trigger_file_path for lazy loads; parent_file_path for includes. No decision control; JSON output is discarded. Handy for auditing which instructions were active.

UserPromptSubmit

Runs before Claude sees a prompt, including prompts from scheduled tasks and /loop, background subagents reporting back, and messages from other sessions. Default timeout is 30 seconds because it blocks every prompt; a timed-out command, http or mcp_tool hook is cancelled, its context discarded, and the prompt goes through with a notice. An SDK callback that times out here blocks the prompt instead (since v2.1.208).

Input: prompt, with collapsed pastes expanded (and, where pastes are marked, wrapped in <pasted_content id="…"> / </pasted_content id="…"> lines); session_title when set.

Output: plain stdout or additionalContext is added as a system reminder named after the hook (no visible transcript entry; check the debug log to confirm). To block:

FieldMeaning
decision"block" stops the prompt reaching Claude
reasonShown to you, not added to context
additionalContextAdded alongside the prompt
sessionTitleSets the title from the prompt
suppressOriginalPromptWith a block, leave the prompt text out of the block message

Exit 2 behaves like reason (stderr shown to you).

#!/bin/bash
# Stop prompts that paste what looks like a live Stripe secret key
if jq -r '.prompt' | grep -Eq 'sk_live_[0-9A-Za-z]{10,}'; then
  jq -n '{decision: "block", reason: "That prompt contains a live Stripe key. Remove it and use the STRIPE_SECRET_KEY env var.",
          hookSpecificOutput: {hookEventName: "UserPromptSubmit", suppressOriginalPrompt: true}}'
fi
exit 0

What a block leaves behind. By default the block message ends with Original prompt: and the text, and is written to the transcript on disk. suppressOriginalPrompt removes it from the message only; the text may still be in the transcript and prompt history, so a block is not a way to keep secrets off disk. See The .claude directory for clearing local data.

UserPromptExpansion

Fires when a typed /command expands into a prompt. It covers the direct path that PreToolUse on the Skill tool misses (that only fires when Claude invokes a skill). Matches command_name; an empty matcher catches every prompt-type command.

Input: expansion_type (slash_command for skills and custom commands, mcp_prompt for MCP prompts), command_name, command_args, command_source, prompt. Output: decision: "block" with reason (shown to you), and additionalContext. Exit 2 routes like reason. Example: block /release unless a sign-off file exists, or attach a review checklist whenever /code-review runs.

MessageDisplay

Runs while assistant text streams to the screen, once per batch of newly completed lines, letting you change only what is displayed. Use it to strip Markdown, redact hostnames or keys from the display, or reformat text in an SDK app. It is display-only: the transcript, verbose mode and Claude all keep the original. Tool results and your own input are unaffected. No matcher; messages with no text do not trigger it. Default timeout 10 seconds; a failure or timeout shows the original text. Keep it fast, because each batch waits for you.

In non-interactive runs (-p, SDK) it runs once per message, after completion, with index 0, final true and the whole message in delta.

Input: turn_id; message_id (stable across batches, not the API msg_ id); index; final (exactly once per message, and use it rather than an empty delta to detect the end); delta (whole new lines, except possibly the final batch). Output: hookSpecificOutput.displayContent replaces the delta; omit it to show the original. systemMessage and continue are discarded.

#!/bin/bash
# Hide internal hostnames from what is shown on screen (Claude still sees them)
jq '{hookSpecificOutput: {hookEventName: "MessageDisplay",
     displayContent: (.delta | gsub("[a-z0-9-]+\\.corp\\.example\\.net"; "[internal-host]"))}}'

PreToolUse

Runs after Claude has built a tool call and before it runs. Matches any tool except EndConversation: built-ins such as Bash, PowerShell, Edit, Write, Read, Glob, Grep, Agent, Workflow, WebFetch, WebSearch, AskUserQuestion, ExitPlanMode, and MCP tools.

Warning: @ file references in your prompt are inserted without any tool call, so no PreToolUse (not even one on Read) sees them. Block sensitive paths with a Read deny rule. For reacting to file changes whatever caused them, use FileChanged.

Input: tool_name, tool_input, tool_use_id; for MCP tools also mcp_server with name and source (plugin, sdk, or a scope like user or project; v2.1.274). Base trust decisions on source, not the name.

For Write, Edit and Read, tool_input.file_path is always absolute, with ~ and relative paths expanded first so they cannot be used to dodge a path check. On Windows it uses backslashes even under Git Bash, so normalise (p="${p//\\//}") and match a segment like /migrations/ rather than anchoring with ^.

tool_input by tool:

ToolFields
Bashcommand, optional description, timeout (ms; over-maximum values are reduced, not rejected), run_in_background
PowerShellSame as Bash
Writefile_path, content
Editfile_path, old_string, new_string, replace_all
Readfile_path, optional offset, limit
Globpattern, optional path
Greppattern, optional path, glob, output_mode (default files_with_matches), -i, multiline
WebFetchurl, prompt
WebSearchquery, optional allowed_domains, blocked_domains
Agentprompt, description, subagent_type, optional model
AskUserQuestionquestions (each with question, header, options, optional multiSelect); optional answers mapping question text to label (comma-joined for multi-select), which you supply via updatedInput
ExitPlanModeplan and planFilePath, both injected from the plan file on disk; allowedPrompts is deprecated and ignored

Match Bash|PowerShell in shell-inspecting hooks. On Windows, wherever PowerShell is enabled it is the primary shell, and without Git Bash there is no Bash tool at all, so a Bash-only hook never fires there.

Bash edit diffs. When a Bash command changes files in a git repository, Claude Code can record what changed and pass it to PostToolUse as tool_response.bashEditDiff (v2.1.269; public beta, best effort). It records in every mode if bashEditDiffEnabled is on; otherwise only in auto and bypassPermissions modes when Claude was directed to edit via Bash. Ignored files, submodules, background and read-only commands are excluded.

FieldMeaning
changedFilesAbsolute paths changed (up to 200)
filesDisplay diffs for up to 5 files, with created/deleted flags
moreFilesChanged files without a diff
unavailableDiff incomplete or impossible
skippedA tree-moving git command (checkout, stash) so no diff was taken
sharedAnother Bash call ran in the same repo concurrently, so some changes may be theirs

Use it to decide what to review, not to enforce policy.

Agent results. For a foreground Agent call, PostToolUse receives tool_response with status (completed, or async_launched for background launches, which is the default), agentId, content, resolvedModel, modelsUsed (only when the model changed mid-run; v2.1.212), totalTokens and usage (final request only, not the whole run), totalDurationMs and totalToolUseCount. Background launches return immediately with status, agentId, description, prompt, outputFile and resolvedModel, and no usage. When a subagent hands back via SubagentHandback (auto mode, v2.1.271), content holds a short note; read the actual report from tool_input.message in a hook on SubagentHandback. For token and cost totals across subagents, use the OpenTelemetry counters filtered to query_source "subagent" (see Monitoring usage).

ExitPlanMode results. In PostToolUse, tool_response.plan and tool_response.filePath hold the approved plan; read those rather than the file.

Decision control:

FieldMeaning
permissionDecisionallow skips the prompt (except for actions no mode auto-approves, and for AskUserQuestion/ExitPlanMode, which need updatedInput too). deny blocks. ask prompts. defer pauses for an external caller. Deny and ask rules are evaluated regardless
permissionDecisionReasonask: shown in the prompt (or read by Claude when a -p run auto-denies). deny: shown to Claude. allow/defer: debug log only
updatedInputReplaces the whole input object (include unchanged fields). Permission rules and auto-background eligibility are evaluated against your version. Ignored for defer
additionalContextAdded next to the tool result; ignored for defer

Across several hooks, precedence is deny > defer > ask > allow. Exit 2 behaves like deny. An ask prompt is labelled with its origin: [settings] (settings files or agent frontmatter), [plugin:<name>] or [skill]. In auto mode a hook's ask forces a real prompt; the classifier may still deny but cannot silently approve (since v2.1.211). The old top-level decision/reason (approve/block) still map to allow/deny but are deprecated for this event.

#!/bin/bash
# Force every npm install to use --save-exact, then let it run
input=$(cat)
cmd=$(jq -r '.tool_input.command' <<<"$input")
if [[ "$cmd" =~ ^npm\ (i|install)\  && "$cmd" != *"--save-exact"* ]]; then
  jq --arg c "$cmd --save-exact" \
     '{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "allow",
       updatedInput: (.tool_input + {command: $c})}}' <<<"$input"
fi
exit 0

Tools that need a human. AskUserQuestion and ExitPlanMode are only offered in -p when there is a permission host (such as an SDK canUseTool callback). A PreToolUse hook can stand in for one by reading the input, collecting the answer through your own UI, and returning allow with updatedInput containing it: for AskUserQuestion, the original questions plus an answers map like {"Which region?": "lon1"}. allow alone is not enough. MCP tools flagged with _meta["anthropic/requiresUserInteraction"] cannot be approved by a hook at all.

Deferring a call. defer exists for programs that drive claude -p and want to pause at a tool call, ask their own user, and resume. It is honoured only in -p (interactive sessions log a warning and ignore it), and only when Claude made a single tool call that turn. The cycle:

  1. The hook returns defer; the tool does not run and the process exits with stop_reason: "tool_deferred" and a deferred_tool_use object (id, name, input) in the result.
  2. Your program shows the question and collects an answer.
  3. It runs claude -p --resume <session-id> with the same permission host; PreToolUse fires again for the same call.
  4. The hook returns allow with the answer in updatedInput (or defer again to keep waiting, or deny).

There is no timeout; the session waits on disk subject to cleanupPeriodDays (30 days by default). If the tool has gone (an MCP server not connected on resume) the run exits with stop_reason: "tool_deferred_unavailable" and is_error: true. On resume with -p, stored permission modes are not restored, so pass --permission-mode again; to resume into plan mode, pass --permission-prompt-tool with --resume (v2.1.246). See Sessions.

PermissionRequest

Runs when Claude Code is about to ask you to approve a tool. In sessions that cannot prompt (such as background subagents in -p), it still runs, and the call is denied if no hook decides. Alongside a --permission-prompt-tool or SDK canUseTool, whichever decides first wins. It does not run for sandboxed network requests (use the permission_prompt notification). For an instant "Claude needs me" signal use this event; Notification's permission_prompt waits about six seconds. Neither it nor PreToolUse fires for EndConversation.

Input: tool_name, tool_input (no tool_use_id), mcp_server for MCP tools, and optionally permission_suggestions, the permission updates Claude Code proposes (e.g. an allow rule). These are not a faithful list of the dialog's buttons: some dialogs ignore them, some hide options (under allowManagedPermissionRulesOnly) and some add options with no suggestion (such as switching to auto mode).

Output (hookSpecificOutput.decision):

FieldMeaning
behaviorallow or deny. Deny and ask rules still apply, so allow cannot override a deny rule
updatedInputallow only; replaces the input, then re-checked against deny and ask rules
updatedPermissionsallow only; permission update entries to apply
messagedeny only; tells Claude why
interruptdeny only; true stops Claude

Exit 2 without a decision changes nothing (stderr discarded).

{
  "hookSpecificOutput": {
    "hookEventName": "PermissionRequest",
    "decision": {
      "behavior": "allow",
      "updatedPermissions": [
        { "type": "addRules", "rules": [{ "toolName": "Bash", "ruleContent": "pnpm lint *" }], "behavior": "allow", "destination": "localSettings" }
      ]
    }
  }
}

Permission update entries (used by updatedPermissions and permission_suggestions):

typeFieldsEffect
addRulesrules, behavior, destinationAdd rules; rules are {toolName, ruleContent?} (omit ruleContent for the whole tool); behavior is allow, deny or ask
replaceRulessameReplace all rules of that behaviour at the destination
removeRulessameRemove matching rules
setModemode, destinationChange mode: default, auto, acceptEdits, dontAsk, bypassPermissions, plan, or manual (alias of default)
addDirectoriesdirectories, destinationAdd working directories
removeDirectoriesdirectories, destinationRemove them

destination is session (memory only), localSettings, projectSettings or userSettings. A hook may echo back one of the suggestions it received. setMode to bypassPermissions only works if bypass was available at launch (via --dangerously-skip-permissions, --permission-mode bypassPermissions, --allow-dangerously-skip-permissions, or permissions.defaultMode in user, --settings or managed settings), and never when permissions.disableBypassPermissionsMode is set or in restricted mode. It is never persisted as defaultMode.

PostToolUse

Runs after a tool succeeds. Matches tool names; omit the matcher to catch everything and work out what changed yourself (git status --porcelain also shows untracked files). It does not run when something other than Claude's file tools (a Bash command, an external process) rewrites a file; use FileChanged for that, and pair with PostToolUseFailure for failures.

Input: tool_name, tool_input (paths as for PreToolUse), tool_response (shape depends on the tool), tool_use_id, optional duration_ms (excluding permission prompts and PreToolUse hooks), and mcp_server for MCP tools.

Output:

FieldMeaning
decision"block" puts reason next to the result; Claude still sees the original output
reasonThe feedback for Claude
additionalContextContext next to the result
classifierContextA short note for the auto mode classifier, not Claude (v2.1.236)
updatedToolOutputReplaces what Claude sees; must match the tool's output shape
updatedMCPToolOutputMCP-only predecessor; prefer the above

updatedToolOutput changes only what Claude sees: the tool has already run, and telemetry captured the original. Built-in tools return structured objects (Bash returns stdout, stderr, interrupted, isImage); a value that does not match is ignored. MCP output is not validated. Removing error details Claude needs can mislead it.

{
  "hookSpecificOutput": {
    "hookEventName": "PostToolUse",
    "updatedToolOutput": { "stdout": "[output withheld: contained customer PII]", "stderr": "", "interrupted": false, "isImage": false }
  }
}

Notes for the auto mode classifier. The classifier never sees tool results, so classifierContext is the supported way to tell it something about one, such as "This query ran against the anonymised staging replica." Notes from settings, plugins, skills and agent frontmatter are treated as unverified application context and can never establish user intent; claims that you approved something are checked against your actual messages. In-process TypeScript SDK callbacks during a live session may have relayed user statements weighed as intent (never lifting blocks your own message could not), but restored notes after a resume are unverified. Limits: 2,000 characters shared across all hooks per call; ignored from async hooks; discarded for read-only lookups the classifier does not record; and if you are also replacing output, return both fields in the same response, because the note is dropped if that rewrite is rejected or overridden. Never copy untrusted output into it.

PostToolUseFailure

Runs when a tool that started executing fails (it threw, or an MCP tool returned an error). Not for calls rejected beforehand: validation failures fire neither PreToolUse nor this, and permission denials fire PreToolUse but not this (see PermissionDenied).

Input: tool_name, tool_input, tool_use_id, error, optional is_interrupt (an abort rather than a tool-reported error; cancelling a running tool does not fire this hook), optional duration_ms, and mcp_server for MCP tools. error is usually the text Claude gets; for Bash and PowerShell it begins Exit code N followed by interleaved output, but it may be a bare message, can be middle-truncated around ... [N characters truncated] ..., and may include lines like Command timed out after 2m 0s. Key on tool_name, is_interrupt and the first line, not the rest.

Output: additionalContext alongside the error, e.g. "Module not found errors in this repo usually mean you need pnpm install --filter api."

PostToolBatch

Runs once after every call in a parallel batch has resolved, before the next model request. Where PostToolUse fires per tool (concurrently for parallel calls), this fires exactly once, so it suits context that depends on the whole set. No matcher.

Input: tool_calls, an array of {tool_name, tool_input, tool_use_id, tool_response}. Here tool_response is the serialised content the model receives (for Read, line-numbered text), unlike PostToolUse's structured objects; it can be large.

Output: additionalContext, delivered once before the next call. decision: "block" or continue: false (or exit 2) stops the loop; the message from reason, stopReason or stderr appears as a warning and stays in the conversation.

PermissionDenied

Runs only in auto mode, when the classifier denies a call, or when it is denied without a verdict (the classifier's response failed to parse, or a separate safety check refused the classifier's request). Not for your own denials, hook blocks or deny rules.

Input: tool_name, tool_input, tool_use_id, reason, and mcp_server for MCP tools. reason usually names the rule in brackets, e.g. [Irreversible Local Destruction]; no-verdict denials start Auto mode could not evaluate this action and is blocking it for safety; an unavailable classifier gives Classifier unavailable. See Auto mode configuration.

Output: hookSpecificOutput.retry: true adds a message telling the model it may try again. The denial itself is not reversed, and retry is ignored for no-verdict denials (the rejection already says whether to retry).

Notification

Runs when Claude Code sends a notification, even if desktop notifications are off (preferredNotifChannel only changes how you are alerted).

TypeWhen
permission_promptApproval needed (including sandboxed network requests, v2.1.246) and the prompt has waited about six seconds
idle_promptAbout 60 seconds after Claude finished, with no typing since and no background agents running
auth_successAuthentication completed
elicitation_dialogAn MCP form is open and you have not typed for about six seconds
elicitation_url_dialogAn MCP server wants you to open a URL, same timing
elicitation_completeA URL-mode elicitation reports completion
elicitation_responseAn elicitation response is sent
agent_needs_inputA background session waits on you while agent view is open; also teammate setup questions (v2.1.248) and the auto mode classifier billing notice, after six idle seconds
agent_completedA background session finishes or fails, while agent view is open
quota_auto_resume_firedA task resumed after a usage limit (v2.1.234)
quota_auto_resume_staleA limit reset during a long sleep; waiting for Enter
quota_auto_resume_disabledA usage-limit wait ended without continuing (not when you cancel it yourself)

In the terminal, the six-second gates start when the prompt or dialog appears and each keystroke resets them; requests arriving behind another open dialog are timed from their arrival. idle_prompt is not sent while waiting out a usage limit. In desktop and VS Code sessions (which route permissions through the SDK canUseTool callback), permission_prompt fires about six seconds after the request without deferring on typing, not at all if answered sooner, and can be disabled with CLAUDE_CODE_DISABLE_PERMISSION_PROMPT_NOTIFY_HOOKS=1 (v2.1.233).

Input: message, optional title, notification_type. It cannot block; systemMessage and continue are discarded, but terminalSequence works. Typical use: forward to Slack or ntfy, or raise a desktop notification.

SubagentStart

Runs when a subagent is spawned or resumed, and each time an in-process teammate handles a new message. Matches the agent type: built-in names, the frontmatter name of custom agents (not the filename), or plugin-scoped names such as crm-tools:auditor (anchor these as ^crm-tools:auditor$, since the colon makes it a regex).

Input: agent_id, agent_type. Output: additionalContext added at the start of the subagent's conversation. If the hook runs again for the same subagent, the context is only re-injected once compaction has removed the earlier copy, which keeps the subagent's prompt cache intact.

SubagentStop

Runs when a subagent finishes; same matcher values as SubagentStart. It also fires for Claude Code's own internal agents (prompt suggestions, /btw), where agent_type is the session's own agent name or an empty string; named matchers do not match empty strings, but omitted, "", "*" and regexes matching empty do.

Input: stop_hook_active, agent_id, agent_type, agent_transcript_path (in a subagents/ folder), last_assistant_message, and the parent session's background_tasks and session_crons. With SubagentHandback (v2.1.271), last_assistant_message is just the closing text; the report is in that tool's tool_input.message.

Output: same as Stop: decision: "block" with reason (or exit 2) keeps the subagent working with that as its next instruction; additionalContext gives non-error feedback. To affect the parent after a subagent returns, use PostToolUse on Agent.

TaskCreated

Runs as TaskCreate creates a task (not in sessions without task tools). No matcher.

Input: task_id, task_subject, optional task_description, teammate_name, and deprecated team_name. Output: exit 2 (stderr) or decision: "block" (reason) deletes the task and returns the message to Claude as the tool error; continue: false is ignored. A natural use is enforcing a naming convention such as requiring a ticket prefix.

TaskCompleted

Runs when a task is being marked complete, either via TaskUpdate or when a teammate ends its turn with tasks in progress. No matcher. Same input fields as TaskCreated.

Output: exit 2 keeps the task open and feeds stderr back as feedback. continue: false with stopReason stops a teammate entirely, but is ignored when TaskUpdate triggered the event.

#!/bin/bash
# Refuse to close a task while the type checker is unhappy
subject=$(jq -r '.task_subject')
if ! pnpm -s tsc --noEmit >/tmp/tsc.log 2>&1; then
  echo "Cannot complete \"$subject\": tsc reports errors. See /tmp/tsc.log." >&2
  exit 2
fi
exit 0

Stop

Runs when the main agent finishes responding, but not after a user interrupt (and API errors fire StopFailure). The built-in /goal command is essentially a session-scoped prompt Stop hook.

Input: stop_hook_active (already continuing because of a stop hook), last_assistant_message (use this rather than the transcript), background_tasks and session_crons.

background_tasks entries: id; type (shell, subagent, monitor, workflow, teammate, cloud session, MCP task, or a raw value); status; description (capped at 1,000 characters with a … [+N chars] marker); command for shells; agent_type for subagents; server and tool for monitors and MCP tasks; name for workflows. session_crons entries (from CronCreate, ScheduleWakeup and /loop): id, schedule, recurring, prompt (capped likewise). Together they let you tell "finished" from "paused waiting for background work".

Output:

FieldMeaning
decision"block" keeps Claude working
reasonRequired with block; Claude's next instruction
hookSpecificOutput.additionalContextNon-error feedback that also continues the turn, labelled Stop hook feedback with no error notice

After eight consecutive continuations Claude Code overrides the next block and ends the turn (the count resets whenever Claude calls a tool; change it with CLAUDE_CODE_STOP_HOOK_BLOCK_CAP). Always check stop_hook_active to avoid loops on conditions that can never be met.

#!/bin/bash
# Make Claude run the tests once before finishing, but do not loop forever
input=$(cat)
[ "$(jq -r '.stop_hook_active' <<<"$input")" = "true" ] && exit 0
git diff --quiet && exit 0   # nothing changed, nothing to test
jq -n '{hookSpecificOutput: {hookEventName: "Stop",
  additionalContext: "You changed code this turn. Run pnpm test and fix any failures before finishing."}}'

StopFailure

Runs instead of Stop when the turn ends on an API error. Output and exit code are ignored apart from terminalSequence; use it to log or alert.

Input: error (the matcher values listed earlier), optional error_details (such as 429 Too Many Requests), and optional last_assistant_message, which here is the rendered API error text, not Claude's words.

TeammateIdle

Runs when an agent team teammate is about to go idle. No matcher. Input: teammate_name, deprecated team_name. Output: exit 2 sends stderr as feedback and keeps the teammate working; continue: false with stopReason stops it entirely. Good for quality gates such as "the build output must exist".

ConfigChange

Runs when settings or skill files change mid-session: user, project and local settings, managed-settings.json or files in managed-settings.d/, and skill files in .claude/skills/. Server-managed settings, macOS managed preferences, Windows registry policy, and WSL's inherited Windows managed file are applied without running it.

Input: source (user_settings, project_settings, local_settings, policy_settings, skills) and optional file_path. Output: exit 2 or decision: "block" stops the new settings applying to the running session. reason is accepted but never shown, and nothing is shown to you or Claude either way; only a debug line is written. policy_settings changes cannot be blocked (the hook still fires, so you can log them). systemMessage and continue are discarded.

CwdChanged

Runs when a shell command in the main conversation changes directory. No matcher. Pairs well with FileChanged for direnv-style setups.

Input: old_cwd, new_cwd. Output: watchPaths (absolute paths) replaces the dynamic FileChanged watch list, with matcher-configured files always watched; return [] to clear it on entering a new directory. systemMessage appears as a brief terminal notification in interactive sessions (not in the SDK stream). It has CLAUDE_ENV_FILE; variables written there last until the next CwdChanged, which clears them. It cannot block.

DirectoryAdded

Runs after /add-dir (slash_command) or the SDK register_repo_root request (register_repo_root) adds a directory, once sandbox and permission state include it (the hook itself runs unsandboxed). Not for --add-dir at launch (SessionStart covers those), directories added in the /permissions Workspace tab, or directories already inside a working directory. It runs in the background with the 600-second default; the add has already completed.

Input: directory, source. Output: for slash_command, systemMessage is passed to Claude as context on the next turn (not shown to you) and a count of failed hooks appears in the transcript; for register_repo_root, output goes to the debug log. A good place to run pnpm install in a newly added repo.

FileChanged

Runs when a watched file changes on disk, whatever changed it: Claude's tools, a Bash script, or an external program. The matcher does double duty: split on |, each segment is registered as a literal filename in the working directory to watch (so regexes are pointless here), and the same value filters which groups run, matched against the changed file's basename.

To watch files you cannot name in advance, return watchPaths from a FileChanged, SessionStart or CwdChanged hook. The watcher only starts once something names a file, so seed it. Give the group handling dynamic paths an omitted matcher, which matches every watched file without adding to the list (a "*" matcher would register a file literally named *).

Input: file_path, event (change, add, unlink). Output: watchPaths replaces the dynamic list; systemMessage shows as a brief terminal notification. It has CLAUDE_ENV_FILE (cleared at the next CwdChanged). It cannot block.

Beware loops: if your hook rewrites the file it is watching, it will fire again. Guard on the condition you are fixing:

#!/bin/bash
# Keep translations sorted; only rewrite if they are not already sorted
f=$(jq -r .file_path)
if ! jq -e '. == (to_entries | sort_by(.key) | from_entries)' "$f" >/dev/null; then
  tmp=$(mktemp) && jq -S . "$f" > "$tmp" && mv "$tmp" "$f"
fi

with the matcher en-GB.json|fr-FR.json.

WorktreeCreate

Runs when a worktree is needed (claude --worktree, a subagent with isolation: "worktree", or a background session isolated in its own worktree) and replaces the default git worktree behaviour, so you can use SVN, Perforce, Mercurial or a custom git layout. .worktreeinclude is not processed, so copy files like .env yourself.

Input: name, a slug such as bold-oak-a3f2. Output: command hooks print the absolute path as the last non-empty line of stdout (ANSI codes stripped; send everything else to stderr); HTTP hooks return {"hookSpecificOutput": {"hookEventName": "WorktreeCreate", "worktreePath": "/abs/path"}}. Failure or no path fails creation. Relative paths are resolved against the hook's directory; a non-enterable result exits the session with code 1. Absolute paths containing . or .., and any path passing through a symlink below the repository root, are refused (since v2.1.216). systemMessage and continue are discarded.

{
  "hooks": {
    "WorktreeCreate": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "bash -c 'n=$(jq -r .name); d=\"$HOME/worktrees/$n\"; hg share -q \"$CLAUDE_PROJECT_DIR\" \"$d\" >&2 && echo \"$d\"'"
          }
        ]
      }
    ]
  }
}

WorktreeRemove

Runs when Claude Code removes a worktree your WorktreeCreate hook made: when you exit a worktree session and choose removal, when you exit an unnamed one with no changes (detected via git, so non-git worktrees always look clean; check for real work before deleting), or when you delete a background session using it.

Behaviour:

  • No WorktreeRemove hook: on session exit Claude Code falls back to git worktree remove --force on the returned path, which only removes worktrees git recognises.
  • Hook exits 0: treated as removed; make sure it actually deleted the directory.
  • Hook exits non-zero: removal fails if the directory still exists (no git fallback); the command and stderr go to the debug log, and a background session delete reports how the hook ended and whether deleting again would remove the directory anyway.

Claude Code never deletes branches belonging to hook-created worktrees; do that in this hook. JSON output is discarded. For background deletes, the stored path is checked and refused if it is or passes through a symlink below the repo root, and a worktree still containing files is only removed after confirmation in agent view (claude rm keeps it).

Input: worktree_path.

PreCompact and PostCompact

PreCompact runs before compaction (manual for /compact, auto when the auto-compact window is reached). Exit 2 or decision: "block" stops it; for manual compaction stderr is shown to you. Blocking a proactive auto-compaction just continues uncompacted; blocking one triggered to recover from an API context-limit error lets that error surface and the request fail. Input adds trigger and custom_instructions (your /compact text, or null).

PostCompact runs afterwards with trigger and compact_summary, for logging or syncing external state. No decision control. Both discard systemMessage and continue.

PreModelSwitch

Runs before a model switch you or a client asked for (v2.1.251): /model <name>, the /model and Option+P/Alt+P pickers, the Model setting in /config, turning on fast mode when it changes the model, and set_model or model changes in apply_flag_settings from an SDK host or Remote Control. Switches Claude Code makes itself (automatic fallback, restoring on resume) go only to PostModelSwitch.

The matcher is compared with the target's canonical name, ignoring a [1m] suffix, so claude-opus-5 covers the opus alias, dated ids and provider-specific ids alike. If no canonical name can be found (a gateway-only id, say), every PreModelSwitch hook runs regardless of matcher, so a blocking hook should check to_model itself.

Input:

FieldMeaning
from_model, to_modelModel ids
requested_modelWhat was asked for (alias, id, or null for the default)
sourcecommand, picker or sdk
context_tokensTokens the next request re-sends (0 before the first response)
prompt_cache_warmWhether the switch forfeits a warm cache
cache_ttl5m or 1h
estimated_cache_write_usdEstimated cost of re-caching on to_model, excluding the response
pricingconfigured (your organisation's rates), catalog (list price) or default (unknown model)

Output: exit 2 or decision: "block" cancels. Or hookSpecificOutput with permissionDecision of allow (also skips the warm-cache confirmation), deny or ask, plus permissionDecisionReason (the reason for a deny, shown to you or returned as the set_model error; the prompt text for ask). No defer, updatedInput or additionalContext. ask only works for /model in an interactive session; everywhere else it counts as a refusal. Precedence is deny > ask > allow. Any systemMessage is shown whatever the decision, which suits a cost-reporting hook. Default timeout 30 seconds, and a timeout blocks the switch. Only command, http and mcp_tool handlers run. A non-0/2 exit with no JSON decision lets the switch go ahead.

#!/bin/bash
# Ask before switching model mid-conversation when it would cost more than 50p to re-cache
input=$(cat)
cost=$(jq -r '.estimated_cache_write_usd' <<<"$input")
if awk "BEGIN{exit !($cost > 0.65)}"; then
  jq -n --arg c "$cost" '{hookSpecificOutput: {hookEventName: "PreModelSwitch", permissionDecision: "ask",
    permissionDecisionReason: ("Switching re-caches this conversation (about $" + $c + "). Continue?")}}'
fi
exit 0

PostModelSwitch

Runs after the session's model changes (v2.1.251): requested switches, automatic fallbacks, opusplan-style settings entering or leaving plan mode, and restoring the model on resume. Not when a fallback chain model serves a single turn. Same matcher rules as PreModelSwitch; the same input fields, with source additionally auto (requested_model is then null) or resume (the restored setting). It cannot block.

Plain stdout on exit 0, or additionalContext, goes to Claude with the next request. If the hook is still running five seconds after you send that prompt, its output rides on the following request instead; if the model changes several times first, only the last target's output is delivered. A neat use is model-specific guidance without editing every CLAUDE.md, e.g. telling Claude to delegate implementation to subagents whenever it is running on an Opus model.

SessionEnd

Runs when a session ends. reason (and the matcher) is clear, resume (interactive /resume switch), logout, prompt_input_exit, or other; bypass_permissions_disabled was removed in v2.1.234. No decision control, JSON discarded.

The default budget is 1.5 seconds, applied on exit, /clear and interactive /resume. A per-hook timeout raises the overall budget to the largest such value in your settings files, up to 60 seconds (plugin hook timeouts do not count), though hooks without their own timeout keep 1.5 seconds. CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS sets the budget explicitly and also becomes the default per-hook timeout (since v2.1.268).

Elicitation and ElicitationResult

Elicitation runs when an MCP server asks for user input during a tool call; a hook can answer instead of showing the dialog. Matcher: server name. Input: mcp_server_name, message, optional mode (form or url), url, elicitation_id, requested_schema. Output: hookSpecificOutput with action (accept, decline, cancel) and, for accept, content with the form values. Exit 2 denies, silently.

ElicitationResult runs after you respond, before the answer goes back to the server. Input: mcp_server_name, action, optional mode, elicitation_id, content. Output: action and content override your response. Exit 2 turns it into decline, silently. Both discard systemMessage and continue, and ignore hookSpecificOutput on exit 2.

Prompt-based hooks

A prompt handler sends the hook input and your prompt to a Claude model (by default the background-work model) and acts on its JSON verdict, no script required.

Supported events. All five handler types work on PermissionDenied, PostToolBatch, PostToolUse, PostToolUseFailure, PreToolUse, Stop, SubagentStop, TaskCompleted, TaskCreated, TeammateIdle, UserPromptExpansion and UserPromptSubmit. PermissionRequest accepts command, http, mcp_tool and prompt but not agent (agent hooks are skipped). ConfigChange, CwdChanged, DirectoryAdded, Elicitation, ElicitationResult, FileChanged, InstructionsLoaded, MessageDisplay, Notification, PostCompact, PostModelSwitch, PreCompact, PreModelSwitch, SessionEnd, StopFailure, SubagentStart, WorktreeCreate and WorktreeRemove accept command, http and mcp_tool only. SessionStart and Setup accept command and mcp_tool only.

FieldRequiredMeaning
typeyes"prompt"
promptyesText with $ARGUMENTS for the input JSON (appended if missing)
modelnoEvaluation model
timeoutnoSeconds; default 30
continueOnBlocknoDefault false; see below

The model must reply {"ok": true} or {"ok": false, "reason": "...", "impossible": false}. reason is required with false. impossible: true (Stop and SubagentStop only) means the condition can never be met, so the turn is allowed to end.

What ok: false does:

EventEffect
Stop, SubagentStopReason becomes Claude's next instruction and the turn continues (unless impossible)
PreToolUseCall denied; by default the turn ends with a warning line. continueOnBlock: true returns the reason to Claude as the tool error instead (since v2.1.210)
PostToolUseTurn ends with a warning by default; continueOnBlock: true feeds it back and continues
PostToolBatch, UserPromptSubmit, UserPromptExpansionTurn ends with a warning
PostToolUseFailure, TaskCreatedReason returned as a tool error; turn continues
TaskCompletedDuring a turn, returned as a tool error; on a teammate stopping, behaves like TeammateIdle
TeammateIdleTeammate stops with a warning by default; continueOnBlock: true keeps it working
PermissionRequestNo effect; use a command hook's decision object
PermissionDeniedNo effect; output discarded (only command hooks can return retry)

A prompt Stop hook that checks for loose ends:

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "prompt",
            "timeout": 30,
            "prompt": "Decide whether Claude may stop. Input: $ARGUMENTS\nReply {\"ok\": false, \"reason\": \"...\"} if the last message mentions TODOs left behind, failing tests, or migrations that were written but not run. Otherwise reply {\"ok\": true}."
          }
        ]
      }
    ]
  }
}

Agent-based hooks

Warning: Agent hooks are experimental. Prefer command hooks for anything important.

An agent handler spawns a verifier subagent that can use tools such as Read, Grep and Glob for up to 50 turns before answering {"ok": true} or {"ok": false, "reason": "..."}. Configuration matches prompt hooks except the default timeout is 60 seconds and there is no continueOnBlock or impossible; ok: false is handled like a prompt hook with continueOnBlock: true on the same event. They support the same events as prompt hooks except PermissionRequest. Use one when the check needs to look at real files or test output.

{
  "type": "agent",
  "timeout": 180,
  "prompt": "Check that every new API route under app/api has a matching test in tests/api. $ARGUMENTS"
}

Background (async) hooks

Hooks normally block until they finish. Add "async": true to a command hook to run it in the background while Claude carries on. It then cannot control anything (decision, permissionDecision, continue do nothing), and its timeout is not enforced (it is for asyncRewake).

When it exits, its additionalContext and systemMessage are delivered to Claude on the next turn (neither is shown to you). The response is validated like a normal hook, and fields with the wrong type are dropped with a debug warning. Completion notices are hidden unless verbose (Ctrl+O or --verbose).

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/test-related.sh", "args": [], "async": true }
        ]
      }
    ]
  }
}
#!/bin/bash
# test-related.sh: run the tests nearest the edited file and report back
f=$(jq -r '.tool_input.file_path // empty')
[[ "$f" == *.ts || "$f" == *.tsx ]] || exit 0
if out=$(pnpm -s vitest related "$f" --run 2>&1); then msg="Related tests pass for $f."
else msg="Related tests FAIL for $f: $(tail -40 <<<"$out")"; fi
jq -nc --arg m "$msg" '{hookSpecificOutput: {hookEventName: "PostToolUse", additionalContext: $m}}'

Limits: results wait for the next turn, so an idle session sees nothing until you interact (except asyncRewake hooks exiting 2, which wake Claude immediately); every firing is a separate process with no deduplication; and in -p, async hooks still running at teardown are killed with outcome cancelled, so start a fully detached process if work must outlive the run.

Security

Warning: Command hooks run with your full user permissions and can read, change or delete anything you can. Review every hook before adding it, especially ones that arrive in someone else's repository or plugin.

Workspace trust. In interactive sessions, hooks from every settings file (your own user settings included) are held back until you accept the trust dialog for the folder or a parent whose trust covers it. In -p and SDK sessions there is no dialog and the folder is treated as trusted, so a repository's committed .claude/settings.json hooks will run. Before scripting claude -p over a repo you did not write, read its .claude/ folder, use --bare, or pass --settings '{"disableAllHooks": true}'. See Permissions.

Good habits:

  • Treat hook input as untrusted and validate it.
  • Quote shell variables ("$file", not $file).
  • Reject paths containing ...
  • Use absolute script paths: ${CLAUDE_PROJECT_DIR} in exec form needs no quoting; in shell form wrap it in double quotes.
  • Leave .env, .git/, keys and similar alone.

PowerShell hooks on Windows

Set "shell": "powershell" on a command hook to run it in PowerShell; pwsh.exe (7+) is preferred, falling back to powershell.exe (5.1). This does not require the PowerShell tool to be enabled.

In PowerShell shell-form commands, ${CLAUDE_PROJECT_DIR}, ${CLAUDE_PLUGIN_ROOT} and ${CLAUDE_PLUGIN_DATA} are rewritten to ${env:NAME}, which works inside double quotes but not single quotes. You can also write $env:CLAUDE_PROJECT_DIR. Do not write bare $CLAUDE_PROJECT_DIR: PowerShell treats it as an undefined variable ($null), Claude Code does not rewrite it, and only a debug warning tells you.

{
  "type": "command",
  "shell": "powershell",
  "command": "& \"$env:CLAUDE_PROJECT_DIR\\.claude\\hooks\\format.ps1\""
}

For exec form on Windows, call powershell.exe with -NoProfile -ExecutionPolicy Bypass -File <script> in args.

Debugging hooks

Hook activity goes to the debug log: run claude --debug-file ./hooks-debug.log, or claude --debug and read ~/.claude/debug/<session-id>.txt (neither prints to the terminal). A PostToolUse hook on Write that prints formatted produces lines like:

2026-10-08T09:14:02.511Z [DEBUG] Hook output does not start with {, treating as plain text
2026-10-08T09:14:02.511Z [DEBUG] "Hook PostToolUse:Write (PostToolUse) success:\nformatted"

CLAUDE_CODE_DEBUG_LOG_LEVEL=verbose adds matcher counts and query matching detail. For hooks that never fire, Stop hooks stuck blocking, or JSON that seems ignored, see the troubleshooting section of the hooks guide and Debug your configuration.