Claude Code Hooks Not Working? A Checklist
A hook that couldn't find jq exited 0 and let rm -rf / through. A checklist for hooks that don't fire or block, with the commands we used to reproduce them.
- A hook that never fires looks the same as one that fired and allowed the call; start with claude doctor, which caught a trailing comma and a misspelled event name in our tests.
- Check that settings live in a documented location (/status shows loaded sources) and that the matcher matches the exact, case-sensitive tool name.
- claude doctor doesn't flag a lowercase matcher like "bash"; use /hooks to see which hooks are loaded and where they're defined.
A hook that never fires looks exactly like a hook that fired and allowed the call. Below is the checklist we use when a Claude Code hook seems to do nothing. Each item gives the symptom, a command to check it, and the fix. Where we reproduced the cause, we show the command and its output. Where we did not, the item says “(not reproduced)”. We tested on macOS with Claude Code 2.1.303 and jq 1.7.1, on 2026-10-19, using a dummy project and dummy JSON. We did not run a live Claude Code session. For working examples to compare against, see Claude Code PreToolUse Hook Examples.
Start with claude doctor
claude doctor prints install and settings diagnostics without starting a session (source: https://code.claude.com/docs/en/setup, checked 2026-10-19). Run it from the project directory first. It caught two of the causes below.
Is the hook loaded?
The settings file is in the wrong place
- Symptom: the hook works in one project but not another, or not at all
- Check: run
/statusin a session. TheSetting sourcesline lists the files Claude Code loaded (source: https://code.claude.com/docs/en/settings, checked 2026-10-19) - Fix: use one of the documented locations
| File | Scope |
|---|---|
~/.claude/settings.json | All your projects |
.claude/settings.json | This project, can be committed |
.claude/settings.local.json | This project, not shared |
Hooks merge across these levels instead of replacing each other (source: https://code.claude.com/docs/en/hooks, checked 2026-10-19). (not reproduced)
The JSON doesn’t parse
- Symptom: none of the hooks in a file run
- Check:
jq .on the file, thenclaude doctor - Fix: remove trailing commas and comments; neither is allowed
We left a trailing comma in a dummy .claude/settings.json:
$ jq . .claude/settings.json
jq: parse error: Expected another key-value pair at line 4, column 3
exit=5
claude doctor reported the same file:
Invalid settings
- <project>/.claude/settings.json: Invalid or malformed JSON
The event name is misspelled
- Symptom: one event’s hooks never run
- Check:
claude doctor - Fix: copy the event name from the docs
We typed PreToolUSE. Claude Code dropped that event and listed the valid names:
- <project>/.claude/settings.json › hooks.PreToolUSE: Unknown hook event "PreToolUSE" was ignored. Valid events: PreToolUse, PostToolUse, PostToolUseFailure, ...
The matcher doesn’t match the tool name
- Symptom: the hook runs for some tools but not the one you meant
- Check: compare the matcher with the exact tool name:
Bash,Edit,Write,Read, ormcp__<server>__<tool> - Fix: matchers are case-sensitive.
Edit|Writeis an exact list. Any other character makes the matcher an unanchored JavaScript regex, soEdit.*also matchesNotebookEdit; use^Edit$instead (source: https://code.claude.com/docs/en/hooks, checked 2026-10-19)
claude doctor does not catch a wrong-case matcher. With "matcher": "bash" and a valid event name, it reported no invalid settings. We checked the regex behavior with Node:
$ node -e 'console.log(/Edit.*/.test("NotebookEdit"), /^Edit$/.test("NotebookEdit"))'
true false
Whether a bash matcher fails to fire in a live session: (not reproduced)
The change hasn’t loaded, or the folder isn’t trusted
- Symptom: you edited the hook and nothing changed
- Check: run
/hooks. It’s a read-only list of configured hooks and where each comes from - Fix: edits are normally picked up by a file watcher. If the hook still doesn’t appear after a few seconds, restart the session. In an interactive session, hooks from settings files don’t run until you accept the workspace trust dialog (sources: https://code.claude.com/docs/en/hooks and https://code.claude.com/docs/en/hooks-guide, checked 2026-10-19). (not reproduced)
Does the script start?
The script isn’t executable
- Symptom: a
hook errornotice, and the tool runs anyway - Check:
ls -l .claude/hooks/ - Fix:
chmod +x .claude/hooks/*.sh
$ echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf /"}}' | sh -c .claude/hooks/block-rm-root.sh
sh: .claude/hooks/block-rm-root.sh: Permission denied
exit=126
The path is relative
- Symptom: the hook works until Claude runs a command from a subdirectory
- Check: run the hook’s
commandfrom another directory - Fix: use
"$CLAUDE_PROJECT_DIR"/.claude/hooks/...in double quotes, or exec form with"args": [](source: https://code.claude.com/docs/en/hooks-guide, checked 2026-10-19)
Run from src/:
sh: .claude/hooks/block-rm-root.sh: No such file or directory
exit=127
Both 126 and 127 are non-blocking errors, so the tool call goes ahead. To block when a hook can’t start, set "onFailure": "block" (v2.1.295 or later) (source: https://code.claude.com/docs/en/hooks, checked 2026-10-19).
jq is missing
- Symptom: the hook allows everything
- Check:
which jqin the environment the hook runs in - Fix: install jq, or parse the JSON with Python or Node
This was the worst case we found. We ran a working rm guard with jq removed from PATH:
.claude/hooks/block-rm-root.sh: line 3: jq: command not found
exit=0
cmd=$(jq ...) came back empty, nothing matched, and the script reached exit 0. It allowed rm -rf / with no error notice. Make a guard script fail closed: check command -v jq first and exit 2 if it’s missing.
Does it block?
exit 1 instead of exit 2
- Symptom: the hook prints “Blocked” but the command still runs
- Check:
echo $?after piping sample input - Fix: use
exit 2to block on PreToolUse
Blocked: rm
exit=1
The docs say any exit code other than 0 or 2 is a non-blocking error, including 1 (same source). That the tool then runs in a live session: (not reproduced)
The reason goes to stdout
- Symptom: the call is blocked but Claude sees no useful reason
- Check: run the hook with stdout thrown away and see what is left
- Fix: on exit 2, write the reason to stderr (
>&2). Claude Code uses stderr as the block reason
$ ... | .claude/hooks/stdout.sh 1>/dev/null
exit=2
Nothing was printed, so stderr was empty.
The hook times out
- Symptom: slow hooks seem to be skipped
- Fix: the default timeout for a command hook is 600 seconds, and 30 on
UserPromptSubmit. A timed-out PreToolUse hook doesn’t block unlessonFailureis"block"(source: https://code.claude.com/docs/en/hooks, checked 2026-10-19). (not reproduced)
The JSON decision is ignored
- Symptom: valid JSON, no effect, no error
- Fix: put
permissionDecisioninsidehookSpecificOutput, not at the top level. Also check that nothing prints before the JSON, such as anechoin your shell profile. Output that doesn’t start with{is read as plain text (source: https://code.claude.com/docs/en/hooks-guide, checked 2026-10-19). (not reproduced)
Read the debug log
claude --debug-file <path> writes hook execution details to a file. claude --debug writes to ~/.claude/debug/<session-id>.txt. On v2.1.296 or later, a command hook on a tool event leaves a Hooks: line with its exit status and duration when it finishes (source: https://code.claude.com/docs/en/hooks, checked 2026-10-19). We confirmed both flags appear in claude --help for 2.1.303. We did not read a log from a live session. (not reproduced)
Environment variables that don’t reach hooks are a separate problem: Claude Code Env Vars Not Working. For why exit codes matter, see Claude Code Hooks in Practice.
Summary
- Run
claude doctorfirst. It catches broken JSON and misspelled event names, but not a wrong-case matcher. - Matchers are case-sensitive, and any character other than letters, digits,
_,-, spaces,,and|turns them into a regex. - Non-executable scripts (126) and wrong paths (127) let the call through. Use
"$CLAUDE_PROJECT_DIR"andchmod +x. - Block with
exit 2and a reason on stderr.exit 1does not block. - A guard that can’t find jq can exit 0 and allow everything. Check for jq first and fail closed.
- Use
/hooksandclaude --debug-fileto see what loaded and what ran.