Claude Code hooks that don't fire: a debugging map
A hook is a command Claude Code runs at a fixed point in its lifecycle: when a session starts, before a tool call, when a turn ends. It can also be an HTTP request, an MCP tool call (MCP is how Claude Code reaches external tool servers), a prompt to a model, or a subagent (a separate Claude instance with its own context). The point is the same: something happens without the model deciding to do it.
When a hook "doesn't fire", four different failures get the same name:
- Claude Code never loaded it.
- The event never happened.
- The event happened, but the matcher did not match.
- The handler ran, but its output never reached Claude.
The transcript — the saved record of the conversation — shows the fourth one as silence. The first three are invisible there. That is why the debugging order matters: check loading, then the event, then the matcher, then the handler, then delivery.
Checked against Claude Code's documentation on 26 October 2026.
Status, 26 October 2026: three open reports matter here. A SessionStart hook can run without its output reaching Claude on a brand-new session (#10373); the thread reports it again on v2.1.259 on 3 September 2026 and in cloud sessions on 24 August 2026. Plugin-delivered hooks can run without their output reaching Claude (#12151), reported as a regression in v2.1.97 on 8 April 2026. The third is only a label: a successful PreToolUse hook can still show a hook error notice (#17088, last updated 10 July 2026). The two delivery reports have workarounds below. If a fix has shipped in your version, the triage still applies.
1. Is it loaded?#
Run /hooks. It is a read-only browser of every hook registered for the session, grouped by event, with the source: user settings, project settings, local settings, a plugin, or the current session. Select one to see the full command and where it is defined.
If your hook is not there, Claude Code did not load it. The usual causes:
- The hook is in a standalone file, or the JSON is invalid. Hooks go under the
"hooks"key in a settings file; trailing commas and comments are not allowed. matcheris an array instead of a string. Claude Code reports it as an invalid setting, and if it is underPreToolUseorPermissionRequest, none of that file's other hooks load either.disableAllHooksistrue, orallowManagedHooksOnlyis set in managed settings. The first is read after settings precedence, so a project file can override your user setting, and managed hooks still run; the second makes/hooksshowOnly hooks from managed settings run here.- The hook is in a skill's frontmatter and you have not invoked the skill, or in a subagent's frontmatter and that subagent is not running. It is registered only for that lifetime.
Direct edits to a settings file are normally picked up by the file watcher after a short delay. If /hooks still shows the old definition, run /hooks again to refresh; if it still does not appear, restart the session. Before v2.1.257, Claude Code did not detect edits in a .claude/ folder created after the session started.
claude doctor prints invalid settings files. claude --safe-mode starts a session with your hooks, plugins, MCP servers, CLAUDE.md, skills, and custom commands disabled. If the problem disappears in safe mode, the cause is in one of those.
2. Is it the right event?#
The event names are literal. PreToolUse runs before a tool call; PostToolUse runs after it succeeds; PostToolUseFailure runs after it fails. A hook on PostToolUse will not see a failed command. A hook on Stop will not run when you press Esc; an API error fires StopFailure instead.
The events people most often confuse:
| Event | When it fires | What it can do |
|---|---|---|
SessionStart | A session begins or resumes; matcher startup, resume, clear, compact, or fork | Add context, set the title, watch files |
UserPromptSubmit | A prompt is submitted, before Claude processes it | Add context or block the prompt |
PreToolUse | Before a tool call | Allow, deny, ask, or rewrite the input |
PostToolUse | After a tool call succeeds | Add context or show feedback; cannot undo |
Stop | Claude finishes responding | Block the stop and continue the turn |
StopFailure | The turn ends on an API error | Log or alert; output is ignored |
SessionStart is the one with the most scope surprises. It runs on every new session, resume, /clear, compaction, and fork. At launch it runs in the background, so you can type right away; Claude's first response waits. A /resume switch waits too. If you /clear while background hooks are still running, their output does not apply to the new session.
SessionStart supports only type: "command" and type: "mcp_tool". At launch, including --continue and --resume, it fires before the session's MCP servers are available, so an mcp_tool hook is skipped. The debug log records mcp_tool hooks are not available for the 'SessionStart' hook event (no MCP client context). Use a command hook for anything the session needs at launch.
3. Does the matcher match?#
The matcher filters the event. Its syntax depends on the characters:
"*","", or omitted matches everything.- Only letters, digits,
_,-, spaces,,, and|are treated as an exact string or a list.Bashmatches the Bash tool;Edit|WriteandEdit, Writematch either. - Any other character makes it a JavaScript regular expression, unanchored.
^Notebookmatches any tool whose name starts withNotebook;mcp__memory__.*matches every tool from the memory server.
Matchers are case-sensitive. A misspelled tool name matches nothing and produces no error.
For MCP tools, the .* is required: mcp__memory__.* matches all tools from the memory server, while mcp__memory matches no tool. Plugin-bundled servers use a scoped name such as mcp__plugin_my-plugin_db__query, so a matcher against the bare server key never fires.
The if field is a second filter, using permission-rule syntax. It runs only on tool events (PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest, PermissionDenied); on any other event, a hook with if set never runs. It is best-effort on Bash, so do not use it as a security boundary.
4. Does the handler run?#
If /hooks lists the hook and the event is right, the handler itself may be failing before it can produce a decision. The handler is the command, HTTP endpoint, MCP tool, prompt, or subagent that runs when the event and matcher line up. Check:
- The script is executable (
chmod +x.claude/hooks/check.sh) and the path is absolute or uses${CLAUDE_PROJECT_DIR}. A relative path resolves against the directory Claude Code is running in, which may not be the project root after acdor a worktree. jqis installed, if the script uses it.- The command is in the form you think. With
argspresent, Claude Code uses exec form and spawns the executable directly; without it, the command goes through a shell. A hook that relies on pipes or&&needs shell form; an exec-form hook that points at a.cmdor.batshim on Windows will not start. Without Git Bash on Windows, a shell-form hook may run in PowerShell instead. - The hook has not timed out. Command, HTTP, and MCP-tool hooks default to 600 seconds, lowered to 30 on
UserPromptSubmit,PreModelSwitch, andPostModelSwitch, and 10 onMessageDisplay.SessionEndhooks share a 1.5-second budget.
Test the script outside Claude Code with a sample payload:
echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | .claude/hooks/check.sh
echo $?If the script exits 1, that is a non-blocking error, not a block. On most events the action goes ahead. Exit 2 is the blocking code on events that can block. If you want a policy hook to block when it cannot run, set "onFailure": "block" (v2.1.295 or later); otherwise a missing script or a timeout lets the action through.
5. Did the output reach Claude?#
This is the most common "it fired but nothing happened". Claude Code does not add every hook's stdout to the conversation. It adds plain stdout only on SessionStart, UserPromptSubmit, UserPromptExpansion, and PostModelSwitch. On those events, Claude sees the text. On PreToolUse, PostToolUse, Stop, and most others, stdout goes to the debug log and nowhere else.
To add context on any event that supports it, return JSON with additionalContext inside hookSpecificOutput:
{
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": "This file is generated. Edit src/schema.ts and run `bun generate` instead."
}
}hookEventName is required. additionalContext at the top level is silently ignored. So is permissionDecision at the top level. The debug log line to search for is Hook JSON output had unrecognized keys.
A hook's additionalContext, systemMessage, and initialUserMessage, and its plain stdout, are capped at 10,000 characters each. Over the cap, Claude Code writes the text to a file and gives Claude the path plus a 2,000-character preview; it does not ask Claude to read the file. Keep anything that must always arrive within the cap.
For a synchronous hook, the JSON must be the only thing on stdout. A shell profile that prints a banner before your JSON makes Claude Code read the whole output as plain text. The debug log says Hook output does not start with {. If the output starts with { and ends with } but is not valid JSON, it is a parse failure. Build it with a JSON encoder such as jq, not string concatenation.
For the user, use systemMessage: it shows a warning in the transcript, not context for Claude. SessionStart is special: plain stdout is added to context, so a hook that only prints text does not need JSON. If you print a JSON object, make it valid and put the text in hookSpecificOutput.additionalContext; one that fails validation is a non-blocking error and the session proceeds.
When it runs but the decision is ignored#
A hook can execute and still not do what you meant:
PreToolUsereturning"allow"does not override a deny or ask rule. Deny and ask are evaluated regardless of the hook. A hook's"deny"holds in every permission mode, includingbypassPermissions.- In plan mode, a
PreToolUsehook's"allow"has no effect on a file edit or another non-read-only call. You still get the normal prompt, block, or approval. PermissionRequestdoes not honor exit code 2. ReturnhookSpecificOutput.decision.behavioras"allow"or"deny".PostToolUsecannot undo the tool call. It has already run. Exit 2 shows stderr to Claude as feedback.- A
Stophook that blocks too many times hits an 8-consecutive-continuation cap. Checkstop_hook_activeand exit 0 when it is true, or Claude keeps looping. - A hook that times out on
PreToolUsedoes not block the call unlessonFailureis"block".
6. Scope: where the hook runs, and where it does not#
Hooks from settings files, managed settings, and plugins also run inside subagents. The input carries agent_id and agent_type, so a tool hook can tell which agent it is in. A subagent's own frontmatter hooks run only while that subagent is running; a Stop hook there is converted to SubagentStop. A skill's frontmatter hooks are registered when the skill is invoked and stay for the rest of the session.
Two open reports are about delivery rather than matching. For a SessionStart hook whose output does not reach Claude on a brand-new session (#10373), move the injection to UserPromptSubmit; for a plugin hook whose output is lost (#12151), move the hook out of the plugin.
Read the debug log, not the transcript#
The transcript shows block messages and a <hook name> hook error notice. It does not show a successful hook's stdout. The debug log does.
Start with a known path:
claude --debug-file /tmp/claude.logThen tail -f /tmp/claude.log in another terminal. Without the flag, claude --debug writes to ~/.claude/debug/<session-id>.txt; /debug enables logging mid-session and tells you the path.
Lines worth searching for:
Hook output does not start with {— something printed before your JSON.Hook JSON output had unrecognized keys— a field at the wrong level.Hooks: PostToolUse:Write [...] finished with status 0 (31ms)— the hook ran; this is the proof.mcp_tool hooks are not available for the 'SessionStart' hook event— the launch-time MCP limitation.
Set CLAUDE_CODE_DEBUG_LOG_LEVEL=verbose for matcher counts and query matching.
One cosmetic trap: a successful PreToolUse hook can still show a PreToolUse:Bash hook error label. That is an open report (#17088, last updated 10 July 2026), not necessarily a failure. Check the debug log for the exit status before you rewrite the script.
To prove delivery quickly, use a nonce: have the hook write hook-ran-7f3a to a file and print hook-nonce-7f3a. If the file appears, the handler ran; if Claude can repeat the nonce, the output reached the model. If the file appears and Claude cannot repeat it, the problem is delivery, not execution.
The short version#
/hooksfirst: if it is not listed, it was never loaded.- Then the event and matcher: exact names are case-sensitive; MCP needs
.*;ifonly works on tool events. - Then the handler: executable, absolute path,
jq, exit 2 to block,onFailureif a failure must block. - Then delivery: only four events add plain stdout; elsewhere use
additionalContextinsidehookSpecificOutput. The debug log is the only place a successful hook is visible.
This guide is part of Claude Code guides. For the other half of the permission story, see why Claude Code keeps asking for permission. For a SessionStart hook that carries work into the next session, see hand off work between Claude Code and Codex.