DEV Community

Agent Island Pro
Agent Island Pro

Posted on Originally published at agentislandapp.github.io

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.

Flags checked against the CLI directly; behaviour against the hooks documentation, September 2026. Where a number below comes from my own config rather than a default, it says so — an earlier version of this post did not, and got the timeout wrong as a result.

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
Enter fullscreen mode Exit fullscreen mode

--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:

  • claude --debug-file /tmp/cc.log writes the debug log to a file instead of into your session, which is much easier to read after the fact than a scrolled-away terminal. It enables debug mode on its own, so you do not need both flags.
  • claude --bare starts a session that skips hooks entirely (along with LSP and plugins). This is the isolation test: if the misbehaviour you are chasing also happens under --bare, your hook was never the cause.

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
          }
        ]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

The five things that silently stop a hook

1. A path with a space in it, unquoted

The most common one on macOS, and 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"
Enter fullscreen mode Exit fullscreen mode

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.

Fail open, never closed. If your hook cannot reach whatever it consults, exit 0 and let the normal flow happen. A hook that fails closed converts one bug in a shell script into a session that cannot run anything, and the failure looks like the agent being broken, not like your hook being broken.

4. The timeout you did not set

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. 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.

Mine waits on a person clicking a button: "timeout": 55 in the config, curl -m 52 in the script, three seconds of headroom. Those are my numbers, not defaults.

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 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

Check What it rules out
claude --debug hooks Config problem or script problem — do this first, always
ls -l the script Not executable. It needs the executable bit and a shebang
Run it by hand, echoing its output Invalid JSON, or output on stderr instead of stdout
Quote the path in command The space in Application Support
Compare the matcher to the real tool name Case, and matching the command instead of the tool
claude --bare Whether your hook was ever involved in the symptom at all

One thing a hook is not

Hooks and permission rules are separate gates, and a hook does not override the rules: Claude Code evaluates deny and ask regardless of what the hook returned. A matching deny still blocks and a matching ask still prompts, even if the hook said allow.

The ordering of those gates, and why an allow rule stops applying the moment one part of a chained command is not covered, is a separate piece.


Originally published at agentislandapp.github.io/hooks.html. I write these while building AgentIsland, a macOS app that installs exactly the hooks described above and puts Claude Code's permission prompts in the notch.

Top comments (0)