Anyone who runs Claude Code hooks in production long enough eventually hits the same wall. A script that worked fine locally goes completely silent on the CI runner or a teammate's machine. No echo, no log file, no visible effect on Claude's behavior. You are staring at a problem with no obvious handhold.
This article is the debugging playbook I have built up from running hooks on real projects. It skips the surface-level "check your settings.json" advice and goes one level down: why you cannot see the logs in the first place, and how to reproduce something that refuses to reproduce.
The first question is not "did it fire" — it is "where did the output go"
Most people start debugging a silent hook by asking whether it was called at all. In my experience that is the wrong first question. Nine times out of ten the hook is firing; you just cannot see the output because stdout and stderr are being swallowed somewhere along the way.
Claude Code forks the hook as a child process and feeds it JSON over stdin. Stdout is reserved for the protocol contract with Claude itself, and stderr often ends up in a place the user never looks at. The print statements you saw in your terminal during development are invisible once Claude Code is launched from a different parent process in production.
So the first thing to fix in a production debugging session is not the script content — it is the output destination.
#!/usr/bin/env bash
# hook-entry.sh — always write to a known location
LOG_DIR="${HOME}/.claude/hooks-debug"
mkdir -p "$LOG_DIR"
LOG_FILE="$LOG_DIR/$(date +%Y%m%d).log"
{
echo "---"
echo "time: $(date -Iseconds)"
echo "pid: $$"
echo "hook: $CLAUDE_HOOK_EVENT"
echo "tool: $CLAUDE_TOOL_NAME"
echo "cwd: $(pwd)"
echo "stdin: $(cat)"
} >> "$LOG_FILE" 2>&1Wrap your real script with this tiny shim. The goal is to guarantee that between "hook fires" and "user logic runs" there is at least one line written to a known location. That single line decides whether you are debugging "the hook never got called" or "the hook ran but my logic broke."
I once spent two hours tearing apart the internals of a hook only to discover the matcher was wrong and nothing was being called at all. Prove the binary question — did it fire — separately from script quality. It saves hours.
Build a three-layer observability stack
If you are running hooks in production, set up observability in three layers. I call this the "hook visibility stack."
Layer 1 — proof of fire. The shim above. It records only that the hook was invoked. Nothing about the internals.
Layer 2 — I/O capture. The JSON received on stdin, the stdout the script tried to return, and the exit code. With this you have the full protocol trace of what Claude sent and what your hook replied.
Layer 3 — internal tracing. Script-specific logging for the actual logic. This varies by project, but it sits on top of layers 1 and 2.
A quick one-file implementation that ties the first two together:
#!/usr/bin/env bash
set -euo pipefail
LOG_DIR="${HOME}/.claude/hooks-debug"
mkdir -p "$LOG_DIR"
TRACE="$LOG_DIR/trace-$(date +%Y%m%d-%H%M%S)-$$.log"
# Layer 1: fire
echo "[fire] $(date -Iseconds) event=$CLAUDE_HOOK_EVENT tool=${CLAUDE_TOOL_NAME:-}" \
>> "$LOG_DIR/fire.log"
# Layer 2: I/O capture
STDIN_JSON=$(cat)
echo "$STDIN_JSON" > "$TRACE.in.json"
RESULT=$(echo "$STDIN_JSON" | your-actual-hook 2>> "$TRACE.err")
EXIT=$?
echo "$RESULT" > "$TRACE.out.json"
echo "exit=$EXIT" > "$TRACE.exit"
echo "$RESULT"
exit $EXITIt looks like overkill until the first production outage. Hooks are a system where nothing happens when things are working. When they break, you are starting from zero information unless you invested in visibility ahead of time. The time saved when things break is enormous compared to the cost of writing the shim.
Build a 60-second minimal reproduction
The hardest part of "works locally, breaks in production" problems is reproducing them. Production configurations are complex and hard to replicate. I optimize for speed instead: I make sure I can stand up a minimal environment with only the failing hook in under sixty seconds.
The trick is to never copy config files from the production project. Keep a tiny scratch project that tests one hook at a time.
mkdir -p /tmp/hooks-repro && cd /tmp/hooks-repro
cat > .claude/settings.json <<'JSON'
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "/tmp/hooks-repro/hook.sh" }
]
}
]
}
}
JSON
cat > hook.sh <<'SH'
#!/usr/bin/env bash
echo "[$(date -Iseconds)] hook fired" >> /tmp/hooks-repro/fire.log
cat > /tmp/hooks-repro/stdin-$$.json
exit 0
SH
chmod +x hook.shLaunch Claude Code against this and run a tool call. If the hook fires here, you have a known-good baseline: the hook mechanism itself works on this machine. If it fails, the problem is infrastructure — config file location, permissions, Claude Code version — not your logic.
I strongly prefer building up from minimum toward production rather than subtracting from production toward minimum. Additive debugging exposes the exact addition that breaks things.
Understand the hook protocol contract precisely
The second classic failure mode is "the hook fires but Claude's behavior does not change." Almost always this means exit codes and stdout are being misused.
The Claude Code hook protocol has a few rules that matter:
- Stdin carries event JSON from Claude
- Stdout carrying JSON is interpreted by Claude as a control signal
- Exit 0 means "let the flow continue"; non-zero means "the hook made a decision"
- Specific exit codes defined by Anthropic (for example "block") stop tool execution
The failure I see constantly is printing human-readable messages to stdout. A casual echo "starting analysis" becomes unknown JSON from Claude's perspective and poisons whatever control signal you meant to return.
My rules are:
- Human logs go to stderr or a dedicated log file, never stdout
- Stdout is exclusively protocol JSON
- Terminate JSON with a newline — some implementations expect line-delimited output
- Exit codes are binary by design: 0 means pass, a chosen non-zero means block
Freeze these conventions in your hook template. It survives staff turnover better than documentation alone.
Multi-user environments: permissions, paths, race conditions
Teams running Claude Code together hit three issues that solo developers rarely see:
- Missing execute permission. The hook script lacks
+x. Git only preserves a subset of permissions, so this breaks silently across machines. - User-dependent absolute paths.
/home/alice/bin/hook.shin settings.json means something different for Bob. - Concurrent write collisions. Multiple editor sessions write to the same log file and corrupt the tail.
Triage in this order: permissions first (ls -l, enforce with .gitattributes or a pre-commit hook), then paths (use $HOME or, better, a project-relative .claude/hooks/xxx.sh), then concurrency (include $$ — PID — in log filenames).
If you run hooks in CI, watch out for root-like users where $HOME resolves to /root. Avoid $HOME in production hooks entirely. Self-contained project-relative paths are safer over the long haul.
Routine practices that kill "cannot reproduce" bugs before they happen
Everything above is reactive. The practices below are preventive, and they matter more.
Practice 1 — always install a sentinel hook. Before any "real" hook runs, have a tiny one that only records the fact that it was invoked. Then "did it fire" is a question you can answer in one grep.
Practice 2 — log version information. Claude Code version, script git SHA, OS, shell version. Eight out of ten "only breaks in production" bugs trace to a version mismatch somewhere. Knowing the versions at a glance closes the case fast.
Practice 3 — run schema tests on captured stdin. Claude Code updates occasionally reshape the JSON shape delivered to hooks. Schedule a test that runs against the layer-2 archive and fails if expected keys disappear. You catch protocol drift before it burns you.
Teams that install these three practices almost never have the "suddenly broke" class of incident. Teams that skip them eventually hit a big one.
A real debugging walkthrough from production
A concrete example from my own work. The symptom: a secrets scanner running as a PreToolUse hook worked for everyone except one teammate.
I spent the first hour staring at settings.json, looking for a matcher mismatch. No progress. Installing the layer-1 sentinel hook changed everything. In the problem teammate's environment the fire log stayed empty. That single data point narrowed the investigation to the config layer — the hook script itself was off the suspect list.
Claude Code merges user, project, and local settings in a defined order. I opened .claude/settings.local.json in the teammate's checkout and found an old experimental hook configuration that was overriding the PreToolUse array wholesale. That was the bug.
The interesting part is not the root cause. The interesting part is that without layer 1 I would have spent a full day. The moment "did not fire" became a known fact, my search space collapsed to files under .claude/.
Debugging Claude Code hooks in production is less about clever tricks and more about patient, cheap observability. Logging costs little; not logging costs hours when something breaks. That asymmetry is what separates teams who ship hooks safely from teams who quietly abandon them after the first incident.
If you only adopt one piece of this article, install the layer-1 sentinel hook today. The next time something goes wrong you will have handed your future self several hours of sanity.