Claude Code Hooks Not Working? A Troubleshooting Guide
8 min read · Reviewed 2026-06-12
Your hook is not firing, its output is being ignored, or it is silently breaking your session — and Claude Code is not telling you why. Hooks fail quietly by design, which makes them frustrating to debug the first time.
This guide walks through the most common reasons a Claude Code hook does not work, with a concrete diagnostic and fix for each. Work top to bottom; the causes are roughly ordered from most to least common.
First: how do I see what my hook is doing?
Before guessing, get visibility. Two commands solve most cases. Run Claude Code with the debug flag to see hooks being matched, executed, and what they return:
claude --debugAnd test the script in isolation by piping it a fake payload — exactly the JSON Claude Code would send. If it works here but not in a session, the problem is the registration, not the script:
echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf /"}}' \
| node .claude/hooks/block-rm-rf.mjs
# Expect: {"decision":"block","reason":"…"} → script is fine
# No output / a stack trace → the bug is in the scriptWhy is my hook never firing?
If claude --debug shows the hook is never even invoked, the registration is wrong. Check, in order:
- Wrong event name. The keys under
hooksare case-sensitive and exact:PreToolUse,PostToolUse,UserPromptSubmit,Stop. A typo likepreToolUseorPostToolsilently registers nothing. - Matcher does not match the tool.
"matcher": "Bash"only fires on Bash. If you expected it on file writes, you need"Write|Edit". Use"*"or omit the matcher to fire on everything while debugging. - settings.json in the wrong place. Project hooks must be in
.claude/settings.jsonat the project root — not in~/.claude/(that is global) and not in a subdirectory. - Invalid JSON in settings.json. A trailing comma or missing brace makes Claude Code skip the entire hooks block. Validate it.
Validate the file with a one-liner — if it prints nothing, the JSON is malformed:
node -e "JSON.parse(require('fs').readFileSync('.claude/settings.json','utf8')); console.log('settings.json is valid')"Why is my hook running but its output ignored?
If the script runs (you see it in --debug) but nothing happens, the problem is what it writes and where. Claude Code only reads a block decision from valid JSON on stdout. Common causes:
- Invalid JSON on stdout. If you print anything that is not parseable JSON, the decision is ignored. A stray
console.log("debug")corrupts the output — debug to stderr instead. - Wrong channel. A block decision must go to stdout. Diagnostic messages, lint output, and logs go to stderr. Mixing them up means Claude Code parses your log line as the decision and discards it.
- Returning the wrong shape. PreToolUse blocks with
{ "decision": "block", "reason": "…" }. Returning{ "blocked": true }or a plain string does nothing. - Silent success looks the same as failure. A hook that returns nothing means “allow”. If you expected it to block, log to stderr to confirm your branch is even reached.
The safe pattern: decisions to stdout, everything else to stderr.
// correct
process.stdout.write(JSON.stringify({ decision: 'block', reason: '…' })) // the decision
process.stderr.write('debug: matched secret pattern\n') // logs
// wrong — this console.log lands on stdout and corrupts the decision
console.log('checking command…')How do exit codes affect my hook?
Claude Code interprets the hook’s exit code, and getting it wrong changes the behavior entirely:
- Exit 0 — success. Claude Code parses JSON from stdout (a block decision, injected context, etc.). This is what the HookStack convention uses.
- Exit 2 — blocking error. stdout is ignored; stderr is fed back to Claude and the action is blocked. This is the alternative way to block, driven by stderr instead of JSON.
- Any other exit code — non-blocking error. The first line of stderr shows in the transcript, the action continues. An uncaught exception throws here, so your hook “does nothing” because it crashed before printing.
If your hook throws (a bad JSON.parse, a missing file), Node exits non-zero and the action proceeds as if the hook never ran. Wrap risky work in try/catch, or let it crash on purpose only when you intend exit-2 blocking behavior.
Why does my hook hang or break the session?
A hook that never returns will stall Claude Code, and a PreToolUse hook that crashes can make every tool call fail. Two fixes:
- Set a timeout on every external command. An
execSyncwithout atimeoutcan hang forever if the tool prompts for input or waits on a network call. Always pass{ timeout: 15_000 }. - Filter before doing expensive work. Check the file extension or tool name first and return
nullearly. Running ESLint on every file (including images and JSON) is slow and error-prone.
export function run(input) {
const filePath = input.tool_input?.file_path ?? ''
if (!/\.[cm]?[jt]sx?$/.test(filePath)) return null // skip non-JS/TS fast
try {
execSync(`npx --no-install eslint "${filePath}"`, { stdio: 'pipe', timeout: 15_000 })
return null
} catch {
return null // tool missing or lint failed — never break the session
}
}Why does my hook work standalone but not in Claude Code?
If echo '…' | node .claude/hooks/x.mjs works but the hook does nothing in a session, the problem is almost always the command path. Claude Code may launch from a different working directory than your project root, so a relative path like node .claude/hooks/x.mjs can resolve to the wrong place — or nowhere.
Always reference the script with $CLAUDE_PROJECT_DIR, which Claude Code sets to the project root:
// wrong — relative path, breaks when cwd differs from project root
{ "type": "command", "command": "node .claude/hooks/x.mjs" }
// correct — absolute via the project-dir variable
{ "type": "command", "command": "node $CLAUDE_PROJECT_DIR/.claude/hooks/x.mjs" }Still stuck? A quick checklist
- Run
claude --debugand confirm the hook is matched and executed. - Pipe a fake payload to the script directly — does it print the JSON you expect?
- Validate
.claude/settings.jsonparses as JSON. - Confirm the event name and matcher are correct and case-exact.
- Confirm decisions go to stdout and logs go to stderr.
- Confirm the command uses
$CLAUDE_PROJECT_DIR. - Confirm every external command has a timeout.
Frequently asked questions
- How do I debug a Claude Code hook?
- Run Claude Code with claude --debug to see hooks being matched and executed, and test the script directly by piping it a fake payload: echo '{"tool_name":"Bash","tool_input":{"command":"…"}}' | node .claude/hooks/your-hook.mjs.
- Why is my hook firing but not blocking?
- Claude Code only reads a block decision from valid JSON on stdout. A stray console.log corrupts stdout, the wrong shape (not { decision: "block", reason }) is ignored, and logs must go to stderr, not stdout.
- What exit code should a Claude Code hook return?
- Exit 0 and print a JSON decision to stdout (the HookStack convention). Exit 2 blocks the action and sends stderr to Claude. Any other code is a non-blocking error and the action proceeds — which is what happens when your hook crashes.
- Why does my hook work in the terminal but not in Claude Code?
- Almost always a path issue. Reference the script with $CLAUDE_PROJECT_DIR (node $CLAUDE_PROJECT_DIR/.claude/hooks/x.mjs) instead of a relative path, because Claude Code may run from a different working directory.
Related hooks
Sources
Read next
- Write Your First Claude Code Hook in 5 MinutesA step-by-step tutorial: create the hooks folder, write a Bash command logger, register it in settings.json, test it, and watch it run. No deps beyond Node.
- What Are Claude Code Hooks? A Practical GuideClaude Code hooks are commands that run automatically at lifecycle events. See the lifecycle, a complete working hook, and the settings.json that wires it up.
- PreToolUse vs PostToolUse: Which Claude Code Hook to UsePreToolUse runs before a tool and can block it; PostToolUse runs after and reacts. See two complete hooks, the differing stdin payloads, and a one-line rule.