Why your Claude Code hook isn't running

A hook that fails does not announce it. It just never seems to fire, and you are left guessing whether the problem is your script, your matcher, or your JSON. There is a flag that answers that in one line, and then five failure modes that account for nearly all of the rest.

First: find out whether it ran at all

Almost every hook debugging session starts in the wrong place — reading the script — when the actual question is whether Claude Code ever invoked it. Ask directly:

claude --debug hooks

--debug takes an optional category filter, so this starts a normal session with hook activity logged and the rest of the noise left out. You will see which hooks are considered for each tool call and which are actually executed. That single line splits the problem in half: if your hook never appears, the fault is in your configuration and nothing in your script can fix it. If it appears and runs, the fault is in what it printed or how long it took.

Two companions worth knowing:

What the configuration actually looks like

Hooks live in settings.json under a hooks key, and the shape is three levels deep, which is where a lot of hand-written config goes wrong. Event name, then a list of matcher groups, then a list of hooks in each group:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash|Write|Edit|NotebookEdit|WebFetch",
        "hooks": [
          {
            "type": "command",
            "command": "\"/Users/you/Library/Application Support/YourApp/hook.sh\"",
            "timeout": 10
          }
        ]
      }
    ]
  }
}

That is a real installed hook, reformatted only for width. The events you can hang a hook on are PreToolUse, PostToolUse, Notification, UserPromptSubmit, Stop, SubagentStop, PreCompact, SessionStart and SessionEnd.

The matcher is optional, and omitting it is usually what you want for the non-tool events. A group with no matcher key fires for every occurrence of that event. Our own installer writes a matcher only on PreToolUse, because that is the only event where narrowing to specific tools is meaningful — Notification and PreCompact are not about a tool, so a matcher there can only ever reduce what you see.

The five things that silently stop a hook

1. A path with a space in it, unquoted

This is the most common one on macOS and it is entirely self-inflicted, because the natural place to put a helper script is ~/Library/Application Support/… — a path with a space in the middle of it. The command string is handed to a shell, so an unquoted path splits into two arguments and the shell reports a file that does not exist, to nobody.

❌  /Users/you/Library/Application Support/YourApp/hook.sh
✅  "/Users/you/Library/Application Support/YourApp/hook.sh"

Inside JSON those quotes have to be escaped, which is how you end up with the \" soup in the example above. It looks wrong and it is correct.

2. A matcher that does not match

The matcher is tested against the tool name, so it has to be the name Claude Code uses — Bash, Write, Edit, NotebookEdit, WebFetch — not the command you are running and not a lowercase version. A matcher of bash will not match Bash. A matcher of npm will never match anything, because no tool is called that; the command is input to the Bash tool, not a tool of its own. If --debug hooks shows your hook being considered but never run, this is almost always why.

3. Misreading what the exit code does

A hook has two channels, and conflating them causes trouble in both directions. What it prints on stdout can carry a decision — for a PreToolUse hook, JSON becomes the decision, and printing {} means no opinion, so the normal permission flow happens exactly as if the hook were not installed. But the exit code is not merely advisory:

Exit 2 blocks, and it wins. On events that can block, exit 2 stops the tool call whether or not you printed JSON — it overrides even a JSON permissionDecision of allow, and it stops the call before permission rules are evaluated, so it beats an allow rule too. That makes it a deliberate tool, and also a hazard: a script that dies with status 2 for an unrelated reason blocks real work.

So the rule is not "never exit non-zero" — it is that an ordinary failure must exit 0. Reserve a non-zero status for a block you actually mean.

So the rule for anything unexpected is: print {} and exit 0. Here is the entire fallback logic from our own hook, which is the part worth copying:

RESP=$(curl -s -m 52 ... 2>/dev/null) || RESP=""
if [ -n "$RESP" ]; then
  printf '%s\n' "$RESP"
else
  printf '{}\n'
fi
exit 0
Fail open, never closed. A hook that fails closed converts one bug in a shell script into a session that cannot run anything, and the user has no idea why — the failure looks like the agent being broken, not like your hook being broken. Every path through the script above ends in valid JSON and exit 0, including the path where the app it talks to is not running at all.

4. The timeout, which is shorter than you think

The default for a command hook is 600 seconds on most events — lower on a few, such as 30 for UserPromptSubmit and 10 for MessageDisplay. Ten minutes is far longer than you want a blocking hook to hold a tool call, so set timeout explicitly in the config rather than inheriting it, and then give the work inside the script a slightly shorter deadline than the timeout you set, so it always answers rather than being killed mid-thought. Ours waits on a person clicking a button: "timeout": 55 in the config, curl -m 52 in the script, three seconds of headroom. Those are our numbers, not defaults — a figure you read anywhere, including here, is worth checking against your own config.

You can also set timeout per hook in the config, as in the example above. Lowering it is the right move for fire-and-forget hooks: our notification hooks are set to 10 seconds because nothing is waiting on their answer, and a slow one should get out of the way rather than hold up the session.

5. A PATH that is not your shell's

Your hook is not launched from your interactive shell, so it does not inherit what your shell profile sets up. A hook that calls jq, node, uv or anything else installed by a version manager can work perfectly when you run it by hand in a terminal and find nothing when Claude Code runs it. Use absolute paths for interpreters and helpers, or resolve them explicitly at the top of the script. This is the failure mode that most reliably survives an afternoon of debugging, because every manual test of the script passes.

A checklist, in the order that finds it fastest

CheckWhat it rules out
claude --debug hooksWhether it is a config problem or a script problem — do this first, always
ls -l the scriptNot executable. It needs the executable bit and a shebang; ours ships 0755 with #!/bin/sh
Run it by hand, echoing its outputInvalid JSON, or output on stderr instead of stdout
Quote the path in commandThe space in Application Support
Compare the matcher to the real tool nameCase, and matching the command instead of the tool
claude --bareWhether your hook was ever involved in the symptom at all

One thing a hook is not

Hooks and permission rules are separate gates, and it is worth being precise about this because it causes a specific and confusing bug report: bypass mode does not bypass hooks. A PreToolUse hook that asks something will still stop you in bypass or auto mode, and the prompt it produces can look exactly like an ordinary permission prompt. "It suddenly started asking again even in auto mode" is, more often than not, a hook someone installed weeks ago. The ordering of those gates, and why allow rules stop applying the moment the agent prepends a cd, is a separate piece. If what you are actually trying to build is a notifier, the states those hooks report — and the one that goes stale without telling you — are covered in how to know when Claude Code is done.