Your Claude Code hook isn't firing (or isn't blocking anything) — here's why

You wired up a PreToolUse or PostToolUse hook and it either never runs or runs but doesn't stop anything. Here's the exact-match-vs-regex matcher rule, the exit-code-2-not-1 gotcha, and the other silent failure points — plus how to check each one.

You wired up a PreToolUse hook to block edits to a protected file, or a PostToolUse hook to auto-format after every write. You tested it once, it worked, or maybe it never fired at all — and now Claude Code is doing the exact thing the hook was supposed to stop, with no error, no warning, nothing in the output that looks like a failure.

If you searched “claude code hooks not firing,” “pretooluse hook not working,” or “why doesn’t my claude code hook block anything” — this is almost never a single bug. It’s one of a handful of specific, checkable failure points, and the two most common ones don’t even look like failures: the hook did run, it just didn’t do what you expected.

The mechanism: hooks fail (and half-fail) silently by design

A hook is just a shell command Claude Code runs at a specific event and reads a result from. That means there are two entirely separate ways for a hook to not do what you wanted: it never runs at all, or it runs but its output doesn’t do what you assumed.

The matcher decides if it runs, and it’s evaluated two different ways depending on what you typed. If a matcher value contains only letters, digits, underscores, hyphens, spaces, commas, or |, Claude Code treats it as an exact tool-name match (or a list of them) — Bash, Edit|Write, Edit, Write. The moment the value has anything else in it — a ^, a ., a * — it’s evaluated as an unanchored JavaScript regex instead. Write Bash and it matches the tool named Bash exactly, case included. Write bash and it matches nothing, silently, because Bash the tool and bash the string are different in an exact match. For MCP tools this bites harder: mcp__memory looks like it should match every tool from that server, but it’s still just letters/underscores, so it’s compared as an exact string and matches nothing — you need mcp__memory__.* for the regex form to actually cover the server.

Exit code 2 blocks the action. Exit code 1 does not — Claude Code proceeds anyway. This is the one that produces the scariest-looking non-bug: your hook script hits an error, set -e kills it, it exits 1, and Claude Code logs a non-blocking error and does the thing your hook was supposed to prevent. A hook meant to enforce a policy has to explicitly exit 2 on the reject path — a generic script failure is not treated as a rejection.

Hook output has to be nothing but JSON on stdout. If your shell’s startup profile prints a banner, a nvm/conda activation line, or anything else before your hook’s actual JSON, Claude Code can’t parse the result — and depending on the event, that failure mode itself may not surface as anything obviously wrong.

Adding or moving a hook needs a new session; editing one in place doesn’t. Direct edits to an already-configured hook’s command are picked up by Claude Code’s file watcher without a restart. But adding a brand-new hook entry, or changing which settings file it lives in, is only read at session start — if you added the hook mid-session and it’s never fired once, you’re very possibly still running against the config from before you added it.

How to tell which failure mode you’ve got

  1. Open /hooks first, before touching the script. It’s a read-only browser of every hook Claude Code actually has loaded right now — event, matcher, source file, full command. If your hook isn’t listed at all, it’s a config-loading problem (wrong file, JSON syntax error, or you added it mid-session and haven’t restarted). If it’s listed with the matcher you expect, the hook is registered and the problem is downstream.
  2. Set the matcher to * temporarily. This matches every tool unconditionally. If the hook fires now, your original matcher pattern was the problem — go check exact-match vs regex against what you actually wrote.
  3. Check the script’s own exit code, not just whether it “ran.” Add echo "exit code: $?" right after invoking it manually with a sample input, or add a debug log line as the very first thing inside the script. A hook that logs its debug line but doesn’t block anything is very likely exiting 1 somewhere instead of 2.
  4. Validate the JSON your hook actually emits. Run the script by hand and pipe its stdout through python3 -m json.tool (or jq .). If that fails, something — often a shell profile — is printing non-JSON text ahead of your intended output.
  5. Confirm which settings file you edited. ~/.claude/settings.json (all your projects), .claude/settings.json (this project, shareable), and .claude/settings.local.json (this project, gitignored) are all valid, separate locations — editing the wrong one means your hook exists somewhere Claude Code for this session isn’t reading from.

What actually fixes it

  1. Use exact tool names for exact-match matchers, case includedBash, Edit|Write, not lowercase guesses — and reach for a real regex (^Notebook, mcp__memory__.*) the moment you need a prefix or wildcard, since a partial name with no regex characters is compared as a literal string and will not partially match anything.
  2. Make policy-enforcing hooks exit 2 explicitly on the block path, and treat any other non-zero exit as “something broke in my script,” not “the action was blocked” — they are not the same thing to Claude Code.
  3. Keep hook scripts’ stdout clean. If the command shells out through a profile that prints anything, either strip that output or write the hook as a plain script invocation that doesn’t source your interactive shell config.
  4. Start a fresh session after adding a new hook or changing its file location — don’t debug a “silently not firing” hook for ten minutes before confirming this obvious cause first.
  5. Use ${CLAUDE_PROJECT_DIR} for any path your hook command references, rather than a relative path — Claude Code’s working directory isn’t guaranteed to be the same across every project you run it in, and a relative path that happened to work once is not reliable.

What doesn’t help

  • Rewriting the hook script from scratch. If the matcher never fires or the exit code is wrong, a better script changes nothing — the failure is in the config or the exit path, not the logic.
  • Adding more logging inside the hook if /hooks shows it isn’t loaded at all. Logging only helps once you’ve confirmed the hook is actually registered and running.
  • Assuming a silently-ignored matcher field means the event itself is broken. A handful of events — UserPromptSubmit, Stop, CwdChanged, and others — don’t support matchers at all and always fire; adding one there is just a no-op, not a sign of a bigger problem.
  • Restarting Claude Code repeatedly without changing anything. If the hook edit was a content change to an existing entry, the file watcher already picked it up — restarting isn’t the fix if the matcher or exit code is still wrong underneath.

How to verify you’ve actually fixed it

Open /hooks and confirm your hook shows up under the right event with the matcher you intended. Trigger the exact tool call the matcher targets and confirm the hook’s own debug output appears. Then specifically test the block path — do the thing the hook is supposed to prevent — and confirm Claude Code actually stops it, not just that the script ran. A hook that logs correctly but exits 1 on the reject path will pass every check except this last one, which is why it has to be tested explicitly rather than inferred from the logs looking clean.

Comments