Claude Code Hooks: Why Yours Isn't Firing, and How to Write One That Does
A hook that does not fire produces no error, no log line, no red text — it just quietly doesn't guard. Every silent hook we have hit in production comes down to one of seven checks. Here they are, fastest-catch first.
A Claude Code hook that does not fire fails silently. There is no error, no log line, no red text — the format-on-save just does not happen, the guard you wrote does not guard, and you only find out when the thing it was supposed to prevent happens anyway. Every cause of a silent hook we have hit in months of running these in production comes down to one of seven checks. Here they are, in the order that catches the most cases first.
The fast diagnosis
- Run
/hooksinside the session. If your hook is not listed there, Claude Code is not seeing it at all — the problem is the file or the JSON, not the command. Go to check 1. - If it is listed but nothing happens, the matcher is not matching or the command is failing invisibly. Go to checks 4 and 5.
- If it fires in one project but not another, you put it in a project-scoped file and are expecting user-scoped behaviour. Check 2.
Check 1 — the hook was edited mid-session and never loaded
This is the single most common cause, and it is by design. Claude Code captures a snapshot of your hook configuration at session startup. Editing settings.jsonwhile a session is running does not change the running session — a security measure, so nothing can quietly rewrite your hooks underneath you mid-flight. The session you are testing in is running yesterday's hooks.
- Fix: restart the session, or open
/hooksand review the changes there — the menu is also where direct file edits get picked up. - Corollary: when you iterate on a hook script, put the logic in a separate script file and have
settings.jsonjust call it. Edits to the script apply immediately; only edits to the hook configuration need the reload.
Check 2 — it is in the wrong settings file
Hooks load from specific files, and each has a scope:
~/.claude/settings.json user — every project on this machine .claude/settings.json project — checked into git, whole team .claude/settings.local.json project — yours only, gitignored
A hook in .claude/settings.json of project A does nothing in project B. A hook you meant to share but wrote into settings.local.json exists only on your machine. And a file named anything else — hooks.json, settings.jsonc, a typo'd path — is not read at all. Also verify the JSON parses: a trailing comma silently invalidates the file, and with it every hook in it.
Check 3 — the event name is wrong
Hook events are exact, case-sensitive keys. The ones that exist: PreToolUse, PostToolUse, UserPromptSubmit, Notification, Stop, SubagentStop, PreCompact, SessionStart and SessionEnd. preToolUse, PreToolCall, OnEdit— all silently ignored. If you want “after every file edit”, that is PostToolUse with a matcher, not an event of its own.
Check 4 — the matcher does not match the real tool name
For PreToolUse and PostToolUse, the matcheris compared against the tool's exact name, and it supports regex. The names that actually occur are the internal tool names: Edit, Write, Read, Bash, Glob, Grep, Task, WebFetch — capitalised, no spaces.
"edit"does not matchEdit. Matchers are case-sensitive.- To match several tools, use regex alternation:
"Edit|Write". - To match everything, use
"*"or leave the matcher empty. - MCP tools have three-part names:
mcp__<server>__<tool>, e.g.mcp__github__create_issue. A matcher of"github"misses it;"mcp__github__.*"catches the whole server.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/format.sh"
}
]
}
]
}
}Check 5 — the command runs and fails, invisibly
A hook whose command exits non-zero mostly looks identical to a hook that never ran. The environment your hook runs in is not your interactive shell: no .zshrc aliases, sometimes a leaner PATH. The classic version is a hook that pipes into jq on a machine where jq lives somewhere the hook's PATH does not reach — works when you test it in your terminal, dies inside the hook.
- Test the command the way the hook runs it: feed it JSON on stdin, because that is how hooks receive their input — a JSON payload with
session_id,tool_name,tool_inputand friends on stdin, not as arguments. - Use absolute paths for binaries, or
$CLAUDE_PROJECT_DIRfor project scripts — the working directory is not guaranteed to be where you think. - Log to a file while debugging:
echo "fired $(date)" >> /tmp/hook.logas the first line answers “did it even run” definitively. - Run
claude --debugto watch hook execution live — it prints which hooks matched, what ran, and what came back.
Check 6 — it fires, but the exit code says the wrong thing
Hooks talk back through exit codes, and getting this wrong makes a hook that runs perfectly look broken:
- Exit 0 — success. stdout is mostly invisible to the model (shown in transcript verbose mode). Exception: on
UserPromptSubmitandSessionStart, stdout is injected into context — which is exactly how you feed the agent live information. - Exit 2 — blocking error. stderr is fed back to Claude, which reacts to it. On
PreToolUsethis blocks the tool call. This is the only exit code that stops anything. - Any other non-zero — non-blocking. stderr is shown to you, the run continues. A guard hook that exits 1 guards nothing.
A validation hook that exits 1 instead of 2 runs every single time and blocks nothing. It is the most expensive one-character bug in the hooks system.
For decisions richer than allow/block — approve this, deny that, ask the human — print structured JSON on stdout with a permissionDecision instead. And mind the default 60-second timeout: a hook that calls a slow linter gets killed quietly unless you raise timeout on that command.
Check 7 — you expected a hook where a hook cannot exist
Hooks fire on tool use and session lifecycle. They do not fire on “the model said something”, and a PostToolUse hook cannot un-run the tool — the edit already happened; your hook is a reaction, not a veto. If you need a veto, it has to be PreToolUsewith exit 2. If you need “check everything before the agent declares victory”, that is a Stop hook — which can exit 2 to refuse the stop and send the agent back to work. That last pattern is the backbone of making an agent actually finish the job.
The whole diagnosis on one screen
1. /hooks → not listed? file/JSON problem, not command problem 2. restart session → config is snapshotted at startup; mid-session edits don't load 3. right file? → ~/.claude/settings.json vs .claude/settings.json vs .local 4. JSON valid? → one trailing comma kills every hook in the file 5. event name exact? → PreToolUse / PostToolUse / Stop… case-sensitive 6. matcher exact? → "Edit|Write", "*", mcp__server__tool for MCP 7. command works on stdin-JSON with hook's PATH? → test it that way, log to a file 8. exit codes: 0 ok · 2 block (stderr → Claude) · else non-blocking 9. claude --debug → watch it fire
Reference for everything above: Anthropic's hooks reference and hooks guide. If the thing that is not firing is a permission rule rather than a hook, that is a different file with different traps — covered in permission rules that do nothing.