DEV Community

Rulestack
Rulestack

Posted on

Claude Code hooks not firing: how to verify what actually ran

A hook that silently does nothing and a hook that isn't configured look identical from inside a session. Claude Code deliberately keeps successful hooks quiet — the transcript shows nothing when a hook exits 0 — so "I didn't see anything happen" tells you almost nothing about whether your guard actually ran.

This is a checklist for closing that gap: how to confirm a hook is loaded, how to see what it did, and the specific failure modes that produce a hook which appears to work but doesn't.

Everything below is from the current hooks reference and hooks guide, read 2026-07-29. Where behavior changed by version I've said which version.

Step 1: is it even loaded?

Type /hooks in Claude Code. It opens a read-only browser of your configured hooks: every event with a count, the matchers under it, and the full details of each handler — command, prompt, or URL.

The detail worth going in for is which settings file each hook came from. Hooks merge across user (~/.claude/settings.json), project (.claude/settings.json), local, and plugin (hooks/hooks.json) sources. A hook you "definitely wrote" might be the project copy while the one actually running came from a plugin, or vice versa.

If /hooks shows nothing after you edited a settings file:

  • File edits are normally picked up automatically. If nothing appears after a few seconds, the file watcher may have missed the change — restart the session to force a reload.
  • Check the JSON is valid. Trailing commas and comments are not allowed, and a settings file that fails to parse takes your hooks down with it.
  • Confirm the path. .claude/settings.json for project hooks, ~/.claude/settings.json for global.

Step 2: it's loaded but never runs

The hook appears in /hooks and still nothing happens. Three things account for most of these.

Matchers are case-sensitive. bash does not match the Bash tool. This one is easy to stare past because the rest of the config looks right.

The event isn't the one you think. PreToolUse fires before the tool executes, PostToolUse after. If you wired a guard meant to block something to PostToolUse, it runs — and can't undo anything, because the tool already ran.

PermissionRequest hooks don't fire in non-interactive mode. Run Claude Code headless with -p and those hooks are skipped entirely. For automated permission decisions, use PreToolUse instead. This is a common way a policy that works on your laptop is absent in CI.

Step 3: the exit code trap

This is the failure mode I'd check first, because it produces a hook that runs, reports failure, and is ignored — which reads exactly like success.

Exit code What Claude Code does
0 Success. stdout is parsed for JSON output fields. For most events stdout goes to the debug log, not the transcript
2 Blocking error. stdout and any JSON in it are ignored; stderr is fed back to Claude as an error message
anything else Non-blocking error. The action proceeds. The transcript shows a hook error notice with the first line of stderr; full stderr goes to the debug log

The trap is that exit 1 does not block. It's the conventional Unix failure code, and for most hook events Claude Code treats it as a non-blocking error and proceeds with the action anyway. If your hook is meant to enforce a policy, it has to exit 2.

#!/bin/bash
# Wrong: the tool call proceeds anyway
if bad_thing; then
  echo "not allowed" >&2
  exit 1
fi
Enter fullscreen mode Exit fullscreen mode
#!/bin/bash
# Right: exit 2 blocks, and stderr becomes the reason Claude sees
if bad_thing; then
  echo "not allowed: use the wrapper script instead" >&2
  exit 2
fi
Enter fullscreen mode Exit fullscreen mode

The one exception is WorktreeCreate, where any non-zero exit code aborts worktree creation.

Exit 2 does not mean the same thing everywhere, either. It blocks on events that represent an action that hasn't happened yet, and merely surfaces stderr on events describing something already done. PostToolUse shows stderr to Claude but the tool already ran. PermissionDenied ignores the exit code entirely because the denial already occurred. SessionStart, Setup, SubagentStart, Notification, SessionEnd and friends show stderr to the user only — Claude never sees it.

Two version boundaries here, both worth knowing if you are debugging against an older install:

  • As of v2.1.214, a hook that exits 2 while printing JSON that fails schema validation still blocks — Claude Code uses stderr as the reason and records the validation failure in the debug log. Before that, this combination was treated as a non-blocking error and the action proceeded.
  • As of v2.1.199, SessionStart, Setup and SubagentStart show exit-code-2 stderr in the transcript. Earlier versions wrote it to the debug log only, so a broken session hook was invisible unless you went looking.

Step 4: read what actually happened

The transcript view (Ctrl+O) gives you a one-line summary per hook that fired. Success is silent; blocking errors show stderr; non-blocking errors show a hook error notice with the first line of stderr. That first line is often enough — you don't always need --debug.

When it isn't enough, get the debug log, which has the full picture: which hooks matched, their exit codes, stdout, and stderr.

claude --debug-file /tmp/claude.log
# then, in another terminal
tail -f /tmp/claude.log
Enter fullscreen mode Exit fullscreen mode

Already mid-session without the flag? Run /debug to enable logging and find the log path.

A useful habit while developing: have the hook write a marker to stderr. stderr goes to the debug log without polluting stdout, which matters because stdout is where JSON output is parsed from.

#!/bin/bash
INPUT=$(cat)
echo "[my-hook] fired for $(echo "$INPUT" | jq -r '.tool_name')" >&2
Enter fullscreen mode Exit fullscreen mode

Step 5: test the script outside Claude Code

Hooks receive their JSON on stdin, so you can run one by hand. This separates "my script is broken" from "Claude Code isn't calling my script":

echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | ./my-hook.sh
echo $?   # is this the exit code you expected?
Enter fullscreen mode Exit fullscreen mode

If that works and the hook still doesn't fire correctly, the usual suspects are environment rather than logic:

  • command not found — hooks don't inherit your interactive shell's PATH assumptions. Use absolute paths or ${CLAUDE_PROJECT_DIR}. To sidestep shell quoting entirely, add "args": [] to switch to exec form, which spawns the script directly without a shell.
  • jq: command not found — install jq, or parse the JSON with Python or Node.
  • Nothing runs at allchmod +x ./my-hook.sh.

The one that looks impossible: "JSON validation failed"

Your hook prints valid JSON. You've checked it. Claude Code says it can't parse it.

The cause is usually not your script. A shell-form command hook (one without args) is spawned via sh -c on macOS and Linux, or Git Bash on Windows. That shell is non-interactive, but Git Bash — and any setup where BASH_ENV points at ~/.bashrc — still sources your profile. If the profile prints anything unconditionally, it gets prepended to your JSON:

Shell ready on arm64
{"decision": "block", "reason": "Not allowed"}
Enter fullscreen mode Exit fullscreen mode

The fix is in your shell profile, not the hook. Guard the output so it only runs in interactive shells:

# ~/.zshrc or ~/.bashrc
if [[ $- == *i* ]]; then
  echo "Shell ready"
fi
Enter fullscreen mode Exit fullscreen mode

$- holds the shell's flags, and i means interactive. Hooks run non-interactively, so the echo is skipped.

Three more that produce confusing symptoms

Your Stop hook stopped working after eight tries. Claude Code overrides a Stop hook that blocks eight consecutive times without progress, and ends the turn with a warning. The hook is meant to check whether it already triggered a continuation — read stop_hook_active from the JSON input and exit early when it's true. If you genuinely need more iterations, raise the cap with CLAUDE_CODE_STOP_HOOK_BLOCK_CAP.

#!/bin/bash
INPUT=$(cat)
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
  exit 0   # let Claude stop
fi
# ... your logic
Enter fullscreen mode Exit fullscreen mode

Also worth knowing: Stop fires whenever Claude finishes responding, not only at task completion, and it does not fire on user interrupts.

Your hook read the transcript and got stale data. The transcript_path in the hook input is written asynchronously and can lag the in-memory conversation, so it may not include the current turn's most recent messages. If you need the final assistant text of the current turn, use last_assistant_message on Stop or SubagentStop instead of reading the file.

Two hooks rewrite the same tool input and the result is random. When multiple PreToolUse hooks return updatedInput, the last one to finish wins — and because hooks run in parallel, the order is non-deterministic. Don't have two hooks modify the same tool's input.

Timeouts are a quieter version of the same problem. Command, HTTP and MCP-tool hooks get 10 minutes by default, but UserPromptSubmit lowers that to 30 seconds and MessageDisplay to 10. Prompt hooks get 30 seconds, agent hooks 60. A hook that's fine on PostToolUse can time out on UserPromptSubmit without you changing anything about the script.

What a hook can and can't enforce

Worth having straight before you debug the wrong layer.

PreToolUse hooks fire before any permission-mode check, in every permission mode including dontAsk. A hook returning permissionDecision: "deny" blocks the tool even under bypassPermissions or --dangerously-skip-permissions. That's what makes hooks useful for policy a user shouldn't be able to switch off.

The reverse does not hold. A hook returning "allow" doesn't bypass deny rules from settings, and it can't suppress the prompt for connector tools your organization set to ask, or MCP tools marked requiresUserInteraction. Hooks tighten restrictions; they don't loosen them. If you're trying to make something auto-approve and a hook isn't doing it, the hook isn't the layer with the authority.

The short version

  1. /hooks — is it loaded, and from which settings file?
  2. Matcher case, correct event, and not PermissionRequest under -p.
  3. exit 2, not exit 1, if it's supposed to block.
  4. Ctrl+O for the one-liner, claude --debug-file for everything.
  5. Pipe sample JSON to the script directly to isolate script bugs from wiring bugs.

The generalizable lesson, and the reason exit codes are first on that list: a check that fails open is worse than no check, because its output is indistinguishable from a check that passed. exit 1 produces exactly that shape — the script ran, decided no, and the tool call went ahead anyway.

If you're writing hooks alongside skills, the trigger mechanics are a separate problem with the same character — I wrote up how the listing budget decides which skill descriptions Claude actually sees and the config structure and matchers for hooks themselves.


I'm Rulestack — I build and maintain drop-in rule, skill, and hook packs for Claude Code, Cursor, and Codex, at rulestack.gumroad.com. I post working AI-coding tips on Bluesky at @ai-shop.bsky.social — follow along if this was useful.

Top comments (0)