◉CLAUDE LABJP
●2.1.301 — Adds the vim % motion, LaTeX math rendering in the VS Code panel, and a retry-watchdog limit for usage-limit waits (10/15)●2.1.299 — Conversations over 200K tokens left idle about 54 minutes get compacted; MCP allow rules now match only the server they name●SONNET 4.5 — Retires on the Claude API on 11/30, 45 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST MODE — Opus 5.5 fast mode is out of research preview and no longer needs the beta header (10/15)●NEW — I Put a Ten-Line Promise File at the To…●2.1.301 — Adds the vim % motion, LaTeX math rendering in the VS Code panel, and a retry-watchdog limit for usage-limit waits (10/15)●2.1.299 — Conversations over 200K tokens left idle about 54 minutes get compacted; MCP allow rules now match only the server they name●SONNET 4.5 — Retires on the Claude API on 11/30, 45 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST MODE — Opus 5.5 fast mode is out of research preview and no longer needs the beta header (10/15)●NEW — I Put a Ten-Line Promise File at the To…
Articles/Claude Code
⟐ Claude Code/2026-04-24Advanced

Debugging Claude Code Hooks in Production — Where to Start When Logs Are Missing

Your Claude Code hooks work locally but go silent in production. Here is the three-layer observability pattern I use on real projects, plus a 60-second minimal reproduction recipe for isolating failures.

hooks22claude-code132debugging8production111observability21

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 "cwd: $(pwd)"
  echo "stdin: $(cat)"   # event and tool names are inside this JSON
} >> "$LOG_FILE" 2>&1

Wrap 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. My first draft used set -euo pipefail and read unset environment variables; when I actually ran it, it died before writing anything. A tool for keeping records must not fail before the record. This is the version I re-tested (replace your-actual-hook.sh with your real logic):

#!/usr/bin/env bash
# All three layers in one wrapper. No `set -e`: a mid-script failure would drop the record
set -uo pipefail
LOG_DIR="${HOME}/.claude/hooks-debug"
mkdir -p "$LOG_DIR"
TRACE="$LOG_DIR/trace-$(date +%Y%m%d-%H%M%S)-$$"
 
# Layer 2: save stdin first (it can only be read once)
STDIN_JSON=$(cat)
printf '%s\n' "$STDIN_JSON" > "$TRACE.in.json"
 
# Layer 1: record the fire. Event and tool names come from the stdin JSON, not env vars
read -r EVENT TOOL < <(printf '%s' "$STDIN_JSON" | python3 -c '
import json,sys
try: d=json.load(sys.stdin)
except Exception: d={}
print(d.get("hook_event_name","?"), d.get("tool_name","-"))')
echo "[fire] $(date -Iseconds) pid=$$ event=${EVENT:-?} tool=${TOOL:--}" >> "$LOG_DIR/fire.log"
 
# Your real logic (do not stop on failure; keep the exit code)
RESULT=$(printf '%s' "$STDIN_JSON" | ./your-actual-hook.sh 2>> "$TRACE.err")
EXIT=$?
 
# Layer 2: record the response
printf '%s\n' "$RESULT" > "$TRACE.out.json"
echo "exit=$EXIT" > "$TRACE.exit"
 
printf '%s\n' "$RESULT"
exit "$EXIT"

It 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/.claude && 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.sh

Launch 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 the event JSON.
  • With exit 0, stdout is interpreted by Claude (JSON as a control signal; for some events, plain text is added as context).
  • Exit 2 means "block", and what you write to stderr is fed back to Claude.
  • Any other non-zero exit is a non-blocking error, and execution continues.

Mixing these up gives you a hook that fails to stop what you wanted stopped. Details can change between versions, so please confirm against the Hooks section of the official docs.

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

  1. Missing execute permission. The hook script lacks +x. Git only preserves a subset of permissions, so this breaks silently across machines.
  2. User-dependent absolute paths. /home/alice/bin/hook.sh in settings.json means something different for Bob.
  3. 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 reads settings from several layers: user, project and local. I opened .claude/settings.local.json in the teammate's checkout and found an old experimental hook configuration that did not match the PreToolUse setup I expected. That was the bug. Please check your version's official docs for the exact rules on how layers combine before relying on this.

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

Share

Thank You for Reading

Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

⟐ Claude Code2026-04-29
Observability for Claude Code with OpenTelemetry — A Production-Grade Tracing Guide for Agentic Workflows
Trace Claude Code agent runs end to end with OpenTelemetry. Hook integration, per-tool spans, MCP propagation, cost attribution, and sampling patterns that survive thousands of runs per day.
⟐ Claude Code2026-05-27
11 Days in Crashlytics: A Claude Code Debug Loop Across Two Android Wallpaper Apps
After shipping Beautiful Wallpapers v2.0.0 and Ukiyo-e Wallpapers v1.7.0 in early May, Crashlytics and Play Console threw more than 30 new issues at me in 11 days. This is the operations log of how I drove the fix list down to v2.1.1 / v1.8.1 using Claude Code as a triage partner.
⟐ Claude Code2026-05-02
Designing Zero-Downtime Database Migrations with Claude Code: A Production Operations Guide
A production guide to designing zero-downtime database migrations with Claude Code. Covers Expand-Contract, NOT NULL additions, renames, backfills, and subagent reviews — practical patterns that survive real production traffic.