AccrueDev

Is My Claude Code Hook Actually Firing? How to Tell

By Andrey Boyko

Published Updated

Reviewed against Claude Code docs on

To tell whether a Claude Code hook fires, write a log-only hook first: a script that appends what it receives to a file, prints nothing and exits 0. Trigger the event on purpose, then read the file. This matters because a hook that succeeds shows nothing, so a hook that exits 0 with no output looks identical to a hook that never ran.

This guide covers failure modes the docs do not list. What Claude Code does comes from the docs as read on 2026-10-23, not from a live-session test. Claude Code 2.1.309 was installed on the machine where we ran the reproductions below. Our own incidents and reproductions are labelled as ours. For exit codes, fail-open behaviour and a worked blocking example, see why Claude Code ignores your CLAUDE.md.

How do I prove my hook ran?

Use a probe that does nothing except record. This is the script we used:

#!/usr/bin/env bash
# Log-only probe: append the raw stdin JSON, exit 0, print nothing.
echo "FIRED $(date -u +%Y-%m-%dT%H:%M:%SZ): $(cat)" >> "${PROBE_LOG:-/tmp/probe.log}"
exit 0

Make it executable with chmod +x, then register it in the nested shape the docs show: event, matcher group, list of handlers. The path is a placeholder.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "/path/to/script.sh" }
        ]
      }
    ]
  }
}

Now ask Claude to run any shell command and read the log. On 2026-10-23 we piped a sample payload into the probe by hand (not in a live session): it wrote one line of JSON, printed nothing and exited 0. Copy field names from a capture instead of guessing, because a wrong path in jq does not fail. In our run, jq -r '.agent_name' on a payload without that field printed the string null and exited 0. Adding // empty gives an empty string instead.

If the log stays empty, the docs list the first things to check:

  • Run /hooks and confirm the hook appears under the right event (hooks guide, checked 2026-10-23).
  • Matchers are case-sensitive, so bash will not match Bash (same page).
  • The settings file must be valid JSON: no trailing commas, no comments (same page).
  • In an interactive session, hooks from every settings file, including your own user file, are held back until you accept the workspace trust dialog (hooks reference, checked 2026-10-23).
  • Make sure the script is executable.

One more trap: syntax checking is not schema checking. On 2026-10-23, jq . accepted both a nested registration sample and a flat one (a matcher and command side by side, no hooks list). Only jq '[.hooks.PreToolUse[] | has("hooks")] | all' told them apart: true for nested, false for flat. We did not test how a live Claude Code treats the flat shape, so copy the nested form from the docs.

Ctrl+O opens the transcript view, and claude --debug-file <path> writes the debug log, with per-hook detail, to a path you choose (hooks guide, checked 2026-10-23).

Does Claude see what my hook prints?

Not for every event. The docs say that for most events, stdout goes to the debug log and is not shown in the transcript. The exceptions are UserPromptSubmit, UserPromptExpansion, SessionStart and PostModelSwitch, where plain-text stdout is added as context Claude can see (hooks reference, checked 2026-10-23). PreToolUse is not on that list.

Our own live test agrees. On 2026-08-13, in a live session, a counter file proved the hook fired, but the advisory text it printed with exit 0 never showed up in what the model received. That was our test on our setup (we did not record the Claude Code version for that test). The docs have no sentence saying “PreToolUse plain stdout is not injected”, so this is an inference from the list above that matches our test, not a quote.

If the model must see something from a PreToolUse hook, the docs describe exit 2 with the reason on stderr, or JSON output with permissionDecision and additionalContext inside a hookSpecificOutput object. At the top level they are ignored (hooks guide, checked 2026-10-23).

A related mistake of ours, on 2026-04-02: we had a SessionStart hook meant to make Claude call our memory server’s tools at the start of each session, and nothing happened. A variant from 2026-03-25 used a prompt-type hook and showed a startup hook error. We removed both and can no longer test them, so we cannot test them; our note from that day says the first returned a systemMessage, which the docs describe as a warning shown to the user, not context for Claude. What the docs do say: plain SessionStart stdout is added to Claude’s context, and “Command hooks communicate through stdout, stderr, and exit codes only. They can’t trigger / commands or tool calls” (hooks guide, checked 2026-10-23). So a command hook can hand Claude text but cannot make the tool call itself; that is our reading of the docs, not something we re-tested. We removed the hook, because the same instructions already lived in our global CLAUDE.md, which the main model reads.

Why does my guard do nothing on macOS?

One cause we reproduced is old bash. On the Mac we tested (macOS 26.5.1), /bin/bash is 3.2.57; other macOS versions and other machines were not checked. Under set -uo pipefail, arithmetic on a value that looks like the name of an unset variable stops the whole script, and the exit code is 0.

The test, run on 2026-10-23 with /bin/bash, was a script with v='abc'; n=$(( v + 1 )). Bash treated abc as a variable name, printed abc: unbound variable on stderr, and stopped. The exit code was 0 and the next line never ran. Inside a loop over 1 abc 3, the script stopped at the second item and still exited 0. Not every bad value does this: 12x and ? both gave errors and exit 1. Only a non-numeric value that looks like a variable name ends the script silently with success.

For a guard, exit 0 means “no objection”, so it stops guarding and nothing shows it. It happened to us on 2026-09-21. A quality gate we run on drafts of text read a counter from a state file into $(( )). A corrupted value ended the script with exit 0, and the gate passed every pending file, not just the corrupted one. An auditor agent caught it; our 12 test probes had not, because none used a corrupted state file.

The fix is a guard on every numeric field before the arithmetic:

case "$v" in ''|*[!0-9]*) v=0 ;; esac
n=$(( v + 1 ))

With that line, our reproduction turned abc into 0 and finished the loop. The other half is a test: feed the script a corrupted value and assert the decision text it should produce and no “unbound variable” on stderr. “It did not crash” is not evidence, because the crash is what exits 0. We tested only that one bash, so we make no claim about other versions.

How do I know a guard has not silently stopped?

Give every guard a canary. A canary is a check that feeds the guard a payload which must be blocked and confirms the guard blocks it. If the answer is anything other than exit code 2, the guard is off, and the canary says so.

Here is the toy version from our reproduction. For each guard, it pipes in a docker compose down payload, expects exit 2, and prints RED otherwise:

DIR="$1"; shift  # guard directory, then guard file names
PAYLOAD='{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"docker compose down"}}'
for g in "$@"; do
  if [ ! -x "$DIR/$g" ]; then rc=missing-or-not-executable
  else printf '%s' "$PAYLOAD" | "$DIR/$g" >/dev/null 2>&1; rc=$?; fi
  [ "$rc" = "2" ] && echo "CANARY GREEN: $g" || echo "CANARY RED: $g returned $rc"
done
exit 0

On 2026-10-23 a toy guard that blocks the command returned 2 and showed GREEN. A fake guard that just exits 0 showed RED. A guard without the executable bit also showed RED. The canary exited 0 in all three cases, on purpose: a check that can block your session is a new way to break it. Test the canary with a fake guard, since a canary that cannot go red proves nothing.

Our real canary was built in autumn 2026, after the bash incident above, and runs when a session starts. It prints RED into the session context at startup, so Claude sees the failure at the start of the conversation and can tell us, instead of it sitting in a log nobody reads. Pointed at an empty guard directory, it printed four RED lines (we did not re-run it as a registered hook in a live session for this guide).

Should a failing hook block?

There are two designs.

The first is to let a broken hook block. The docs say a failed hook (cannot start, exits with a code other than 0 or 2, times out, or returns invalid output) normally lets the action go ahead, and that "onFailure": "block" on a command or HTTP hook blocks instead, from Claude Code v2.1.295 (hooks reference, checked 2026-10-23, paraphrased).

The second is to fail open and watch the guard from outside with a canary. This is what we chose, for two reasons from our records of 2026-10-11. First, our guards fail open on purpose: they exit 0 if jq itself fails, so a broken guard never breaks a session. Such failures never reach onFailure, and the bash crash above also ended in exit 0, which the docs do not list as a failure (our reading, not a quoted sentence). Second, fail-closed guards would freeze our unattended background sessions whenever one broke. Failing open is only safe if something checks that the guard is alive, which is the canary’s job.

How do I tell a sub-agent’s tool call from mine?

The docs say agent_id and agent_type appear in a hook’s input when it fires inside a sub-agent (hooks reference, checked 2026-10-23). In July 2026 our delegation counter was keyed on session_id, which a session and its sub-agents share, so it blocked sub-agents doing their assigned work. Ignoring calls with agent_type set fixed it.

Next step

The AccrueDev course covers quality gates. For why a rule belongs in a hook rather than in CLAUDE.md, read why Claude Code ignores your CLAUDE.md. AccrueDev is not affiliated with Anthropic.

Frequently asked questions

How do I know a Claude Code hook ran?

Register a log-only hook first: a script that appends its input to a file, prints nothing and exits 0. Trigger the event on purpose, then read the file. A hook that succeeds shows nothing in the transcript, so the log is your proof.

Does Claude see what my hook prints to stdout?

Only for some events. The docs list SessionStart, UserPromptSubmit, UserPromptExpansion and PostModelSwitch as events where plain stdout becomes context. PreToolUse is not on that list, and our own live test in August 2026 agreed.

Why does my bash guard do nothing on macOS?

One cause we reproduced: the system bash on our Mac is 3.2. With set -u, a non-numeric value that looks like a variable name inside arithmetic prints 'unbound variable' and the script exits 0. A guard that exits 0 allows the action.

How do I know a guard has silently stopped working?

Keep one payload per guard that must be blocked, feed it to the guard at session start, and expect exit code 2. Anything else, including a missing or non-executable script, is reported as RED. The check itself always exits 0.