The short version
A silent hook is the worst failure mode in agent operations: no error, no log, no signal that anything is wrong - except the side effect you depended on never happened. Three failure modes (registered-but-skipped, registered-and-erroring, never-registered) need three different fixes. Run claude --debug to see the registration list and per-invocation log. Always write a heartbeat line to a known sink so absence of the line is a positive failure signal. The exam trap under D3 is "no output means no problem" - it does not; it means you cannot distinguish a crashed hook from a skipped hook.
When the deterministic gate stops being deterministic
Hooks exist because prompt-only enforcement is probabilistic. Per the conversational ai patterns scenario: "Prompt-only enforcement is probabilistic (roughly 92% in this scenario). Hooks are deterministic. They read structured state, exit 2, and route Claude back. For business-bearing guarantees (do not re-ask answered questions, do not process unverified accounts), the 8% leak from prompt-only is unacceptable." A hook that silently fails to register is even worse than prompt-only: you believe you have a deterministic gate, you do not, and the failure is invisible until an auditor catches the violation weeks later.
The most painful version of this: a PreToolUse hook designed to block refunds above $500 that never registered because the JSON config had the hooks key one level too deep. The agent processed three refunds for $750, $1,200, and $4,300 before anyone noticed. The hook script was correct. The wiring was not. There was no error message; there was just an absence of the protection the team thought it had.
How hooks load, why they go silent, and how to diagnose each case
The hooks guide explains that the lifecycle has three steps: "Step 1: Tool Interception. Claude emits a tool_use block; the SDK extracts the tool name and input JSON. Step 2: Hook Execution. Your hook command runs as a subprocess; stdin contains a JSON object with hook_event_name, tool_name, tool_input, and optionally tool_response (PostToolUse only). Step 3: Decision Point. PreToolUse: exit code 0 (allow), 2 (deny), 1 (error)." Each of those three steps can break silently, and the diagnostic flow depends on which one.
Never registered. Symptom: the hook does not appear in the registration list when you run claude --debug. Cause: JSON parse failure, wrong key path, malformed event name. Fix: validate the config with jq, confirm the hooks key is at the top level (not nested in a comment block or wrong section), and confirm event names match the case-sensitive SDK expectation (PreToolUse, not pre_tool_use).
Registered but skipped. Symptom: claude --debug shows registration on startup, but no invocation log appears on the tool you expected. Cause: the matcher string does not match the tool name. Per hooks: "Matcher syntax is critical. A matcher is a pipe-separated list of tool names: 'Read|Edit|Grep' fires on any of those three. The wildcard '*' matches all tools. Matchers are exact string matches; 'Read' does not match 'ReadFile'." Fix: dump the actual tool name from a debug session, copy it exactly into the matcher.
Registered and erroring silently. Symptom: invocation appears in debug log; exit code is non-zero; nothing else happens. Cause: stderr is being discarded. The hook crashed but you cannot see why. Fix: explicit "2>> ~/.claude/hook.log" redirection in the hook command string, or wrap the hook in a shell wrapper that always logs.
A minimal heartbeat-instrumented hook in shell:
#!/usr/bin/env bash # /opt/hooks/refund-guard.sh LOG=~/.claude/hook.log exec 2>> "$LOG" INPUT=$(cat) EVENT=$(echo "$INPUT" | jq -r '.hook_event_name') TOOL=$(echo "$INPUT" | jq -r '.tool_name') AMOUNT=$(echo "$INPUT" | jq -r '.tool_input.amount // 0') echo "$(date -u +%FT%TZ) event=$EVENT tool=$TOOL amount=$AMOUNT" >> "$LOG" if [[ "$TOOL" == "process_refund" && "$AMOUNT" -gt 500 ]]; then echo "Refund amount $AMOUNT exceeds policy 500" >&2 exit 2 fi exit 0
Three things matter about that snippet. Every invocation writes a one-line heartbeat to a known file before any decision logic - so absence of the line proves the hook never ran. The shell redirects stderr to the same log so a crash leaves a trace. The exit codes are explicit (2 for deny with a clear message, 0 for allow). With that pattern in place, the failure modes above become falsifiable: grep the log, see whether the heartbeat is there, classify the problem, fix the right thing.
For richer iteration, use the --hooks-dry-run pattern. Record one real session with --debug capturing the full stdin payloads. Replay the recorded payloads through your hook script directly, as many times as you want, without re-incurring the tool-call cost or risking state mutation. This is the cheapest iteration loop for hook authoring; treat it as the test harness.
Seven checks when a hook fails silently
- Run claude --debug. Confirm the hook is in the registration list. If not, the config did not parse - jump to step 2.
- Validate the config with jq. Confirm the hooks key is at the top level and event names match the SDK's case-sensitive expectations.
- Check the matcher against the real tool name. Grep the debug log for the actual tool_name on the call you expected to intercept. Exact match only.
- Check file permissions. The hook script must be executable (chmod +x). Path must be absolute.
- Capture stderr to a log. 2>> ~/.claude/hook.log on the command string. Without this, a crash is invisible.
- Write a heartbeat line on every invocation. Even on success. Absence of the line is the positive failure signal.
- Iterate with --hooks-dry-run on a recorded session. Do not test against live tool calls; the iteration loop is too slow and too destructive.
Five recurring hook-debugging mistakes
- Silence as success. Assuming the hook ran because nothing crashed. Cause: no heartbeat logging. Fix: heartbeat on every invocation.
- Relative paths. The hook script path is relative; the working directory changes mid-session; the hook fails to launch. Fix: absolute paths always; use $${HOME} expansion if needed.
- Exit 0 with a printed warning. The hook detects a violation but exits 0 with a stderr warning. The warning is invisible to the model. Fix: exit 2 to deny; the stderr message routes back to Claude.
- Wrong matcher casing or substring. Matcher is 'Read' but the tool is 'ReadFile'. The hook never fires. Fix: copy the exact tool name from the debug log.
- Testing only in the parent session. The hook works in the parent but does not fire on subagent tool calls because configuration scope differs. Fix: test in the actual execution context, not just the parent.
How this shows up on the exam
Sources and further reading
- SDK Hooks (Pre/PostToolUse) — further reading on this site.
- Hooks — Claude Code 101 (Anthropic course; enrollment may be required).
- Conversational AI Patterns — further reading on this site.
- Agentic Tool Design — further reading on this site.
- Claude Code for CI/CD — further reading on this site.
