Claude Code Hooks Not Working: 8 Fixes and a Debug Script

2026-11-08来源 dev.toEnglish

Originally published at Unattended.

Claude Code hooks not working? Run /hooks first: if the hook isn't listed, Claude Code never loaded it, so check the file location and JSON. If it is listed, the official docs are blunt: "If the hook appears but doesn't fire, the matcher is the usual cause." If the matcher is right, the problem is usually the exit code, the script's environment, or the way you start claude -p.

Which option is right for you?

For most Claude Code users, the best start is /hooks plus the debug script below. On a tight budget, stick to command hooks and that script, because only prompt and agent hooks call a Claude model. If nothing makes sense, run claude --safe-mode, then try a clean config, to isolate your customizations.

Why are my Claude Code hooks not running or firing?

Claude Code hooks usually fail to fire because the hook never loaded, the matcher misses, or you are watching the wrong event.

Work through the eight fixes in this order. The earlier ones are quicker to rule out.

# Symptom Likely cause Fix
1 Not listed in /hooks Wrong file, bad JSON or untrusted folder Move it to a settings file and validate it
2 Listed, never fires Matcher typo or wrong case Use the exact tool name
3 Fires, doesn't block Exit 1 or misplaced JSON Use exit 2 or valid hookSpecificOutput
4 Stop hook keeps Claude going No stop_hook_active check Exit 0 when it is true
5 Notification stays silent Expecting an alert on every response Match a real notification type
6 hook error in the transcript Permissions, path or missing jq Run the script by hand
7 Works in the terminal, not in cron Working directory cd into the repo first
8 Works interactively, not in a script --bare Remove it for runs that need hooks

Also confirm the event itself. PreToolUse fires before a tool runs and PostToolUse after it, and a successful hook shows nothing in the transcript. A working hook and a dead one can therefore look the same, which is why the logging script later in this guide helps. If you're unsure which event you need, see our Claude Code hooks lifecycle guide.

An Edit|Write hook can also look broken when Claude changes a file through a shell command. The guide notes that Claude can modify files with shell commands, so audit hooks should also match Bash|PowerShell or scan the working tree from a Stop hook.

Where should hooks go in settings.json (and why is my JSON ignored)?

Put hooks under the "hooks" key in a Claude Code settings file; broken JSON makes Claude Code skip that file.

Location Applies to Common trap
~/.claude/settings.json All your projects ~/.claude.json is a different file, and hooks don't belong there
.claude/settings.json One project Loads from the current directory with no parent-directory fallback
.claude/settings.local.json One project, you only Loads from the git root even when you start in a subfolder
Plugin hooks/hooks.json While the plugin is enabled Only plugins get a standalone hooks file

The second and third rows produce a confusing result. If you start Claude Code in repo/src, your local file can still load while the committed one doesn't.

The nesting must be event, then matcher group, then handler:

{ "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/check.sh", "args": [] } ] } ] } } 

Settings files are strict JSON: a // comment or trailing comma is a syntax error. Interactive sessions show a Settings Error dialog for that. An individually invalid entry, such as an unknown event name, gets a Settings Warning instead, and the rest of the file keeps working. To check files without opening a session, run claude doctor.

Three other things can hide a valid hook:

  • Workspace trust. An interactive session holds back settings-file hooks until you accept the trust dialog for the folder, including hooks in your user file.
  • disableAllHooks. Precedence applies, so a project value can override your user value.
  • Managed policy. If /hooks shows Only hooks from managed settings run here, allowManagedHooksOnly is on and your own hooks won't run or appear.

Is the hook matcher case-sensitive? Why doesn't my matcher match?

Yes, Claude Code hook matchers are case-sensitive, so bash never matches the Bash tool.

Tool names are capitalized: Bash, Edit, Write, Read. The rest depends on how the matcher string is evaluated. *, an empty string or no matcher means match all. Strings containing only letters, digits, _, -, spaces, , and | are exact names or lists. Anything else is an unanchored JavaScript regular expression.

Matcher Result Use instead
"bash" Matches nothing "Bash"
["Edit","Write"] Invalid entry; under PreToolUse or PermissionRequest, the file's other hooks don't load either `"Edit\
{% raw %}"Edit,Write" Matches both on v2.1.191 or later, nothing on older versions `"Edit\
{% raw %}"Edit.*" Also matches NotebookEdit "^Edit$"
"mcp__memory" An exact string that matches no tool "mcp__memory__.*"

Not every event matches on a tool name. SessionStart matches how the session started, Notification matches its type, and Stop and UserPromptSubmit have no matcher support, so they fire every time.

The if field has its own trap. It only works on tool events, and adding it to any other event prevents the hook from running.

Exit code vs. stdout JSON: why is my hook output ignored or not blocking?

Claude Code treats exit 2 as a block and exit 0 as success; exit 1 is normally non-blocking, so the action still proceeds.

This table summarizes the hooks guide and hooks reference:

Exit code Stdout Outcome
0 One JSON object and nothing else Fields are applied
0 Anything not starting with { Treated as plain text, with no error shown
2 Anything Blocks on events that can block; stderr is the reason unless JSON supplies one
Other Valid JSON that passes validation The JSON alone decides; the exit code is ignored
Other Empty, plain text or invalid JSON Non-blocking error; the action goes ahead

The current docs say a hook that prints JSON and exits 2 still blocks, and Claude Code still reads the JSON fields. They also advise choosing one approach per hook, and that keeps scripts easier to reason about. Use exit 2 with a stderr message for a simple block, or exit 0 with JSON for structured control:

{ "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "Use rg instead of grep" } } 

The silent failures usually come from one of these:

For copy-paste patterns, see our PreToolUse block scripts.

Why is my Claude Code Stop hook not working (and what is stop_hook_active)?

A Claude Code Stop hook fires when Claude finishes responding, not at task completion, and should exit 0 when stop_hook_active is true.

stop_hook_active is true when Claude is already continuing because of a stop hook. Without that check, your hook can keep sending Claude back to work until the safety cap applies: "Claude Code overrides a Stop hook after it blocks eight times in a row with no tool call from Claude in between."

Add this check to the top of the script. It's the guard from the official guide:

#!/bin/bash INPUT=$(cat) if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then exit 0 # Allow Claude to stop fi # ... rest of your hook logic 

Then check these in order:

  1. Remove any matcher. Stop ignores it, so it can't narrow the hook.
  2. Block with exit 2 and a stderr message, or with decision: "block" plus a reason. The reason is required with that decision.
  3. Read last_assistant_message from the input rather than parsing the transcript, because the transcript is written asynchronously and may lag.
  4. Raise the cap with CLAUDE_CODE_STOP_HOOK_BLOCK_CAP only if your hook truly needs more than eight rounds.

Scheduled jobs have one more gap. Stop hooks don't fire on user interrupts, and API errors fire StopFailure instead. A cleanup script attached only to Stop will therefore miss a rate-limited run. Our Stop hook guide covers loop-safe patterns.

Why is my Claude Code Notification hook not working?

A Claude Code Notification hook fires only for specific notification types, such as permission_prompt and idle_prompt, not after every response.

Symptom Cause Fix
No alert when Claude finishes a reply Notification isn't a reply-finished event Use a Stop hook
Quick approvals never alert permission_prompt needs the prompt to wait about six seconds Leave the prompt open while testing
Exit 2 doesn't stop anything Notification can't block; its exit code and stderr are ignored Use it for side effects only
Matcher "permission" never hits Values are exact types Use permission_prompt, or omit the matcher
OS alert never appears The command itself fails Run it outside Claude Code first

Turning off desktop alerts isn't the cause. preferredNotifChannel changes how you're alerted, not whether your hook runs.

Test the notifier on its own before blaming the hook. On macOS, for example, run osascript -e 'display notification "test"'. On Linux, try notify-send test. If neither shows anything in your terminal, the hook won't fix that.

Unattended runs need a different plan. The docs don't list Notification behavior for each mode, but claude -p denies calls that need approval rather than prompting. Our inference is that you shouldn't rely on permission_prompt for cron alerts. Use Stop, StopFailure or a PermissionRequest hook instead.

How do I fix hook script permissions, paths and environment problems?

Claude Code can only run a hook script that is executable, findable and fast enough; check chmod +x, paths, jq and timeout.

  1. Make the script executable with chmod +x ./my-hook.sh, and start it with a shebang such as #!/bin/bash.
  2. Run it by hand with the official test, then read the exit code:
echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | ./my-hook.sh echo $? 
  1. Reference the script with an absolute path or ${CLAUDE_PROJECT_DIR}. Add "args": [] to use exec form, which starts it without a shell and needs no quoting. In shell form, wrap the path in double quotes:
{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/check.sh" } 
  1. If you see jq: command not found, install jq or parse with Python or Node.js. Under cron, use absolute tool paths inside the script, because cron's PATH is usually far smaller than your login shell's.
  2. Compare your runtime with the default timeouts (as of November 2026). Set a timeout in seconds to override them.
Hook type or event Default timeout
command, http, mcp_tool 10 minutes
UserPromptSubmit, PreModelSwitch, PostModelSwitch 30 seconds
MessageDisplay 10 seconds
prompt 30 seconds
agent 60 seconds
SessionEnd, any type Shared 1.5-second budget, raised up to 60 seconds by a longer per-hook timeout

The SessionEnd budget catches many cleanup scripts. On Windows, a shell-form hook with no shell set runs in PowerShell when Git Bash is absent, so a Bash script may fail there.

Do Claude Code hooks work in headless mode (claude -p)?

Yes, Claude Code hooks run in claude -p, unless you add --bare, which skips hook discovery and ignores hooks passed through --settings.

The official wording is direct: "Hooks still don't run in bare mode when you pass them with --settings." The same page calls --bare the recommended mode for scripted calls and says it "will become the default for -p in a future release." So a job that depends on hooks could break after an upgrade. Check the release notes before updating that machine, and keep the test run below handy.

Headless behavior What it breaks Fix
.claude/settings.json loads from the current directory Cron starts elsewhere and project hooks never load cd /path/to/repo && claude -p ...
No dialog for broken settings The file or value is skipped and the run continues Run claude doctor after an odd run
No trust dialog; the folder counts as trusted A cloned repo's hooks run on your machine Review .claude/, or pass --settings '{"disableAllHooks": true}'
PermissionRequest still runs outside dontAsk A call no hook decides is denied Return a decision or add allow rules
Async hooks are killed at teardown Background work ends with cancelled Start a fully detached process

To see what a headless run actually does, use this example. Replace the path with your repo:

cd /path/to/repo && claude -p "List this folder using Bash" \ --allowedTools "Bash(ls *)" \ --output-format stream-json --verbose --include-hook-events \ --debug-file /tmp/claude-hooks.log 

Don't treat a missing event as proof of failure. Notification, SessionEnd, PreCompact and PostCompact never produce a hook_started event, even with that flag. To turn this into a scheduled job, follow our cron setup guide. For the flag and auth details, see the headless mode guide.

How do I reload hooks: do I need /hooks or a restart?

Usually neither: Claude Code normally reloads hook edits automatically, and /hooks is a read-only view rather than a reload button.

The settings docs say Claude Code watches your settings files and applies hooks edits to the running session. Advice that hooks only load at startup doesn't match the current documentation.

  1. Save the file and wait a few seconds.
  2. Run /hooks again. Rerunning it refreshes the view.
  3. Select the hook to see its full command and where it's defined. Choose All events to see events with no hooks.
  4. If nothing changed, restart the session, because the watcher may have missed the edit.

Two exceptions are worth knowing. Skill frontmatter hooks don't appear until the skill is invoked. And before v2.1.257, edits in a .claude/ folder created mid-session weren't detected, so run claude -v if you're on an old install.

How do I debug Claude Code hooks with a payload-logging script?

Log the JSON each command hook receives on stdin to see exactly what fired and what your script gets.

1. Save this as .claude/hooks/debug-log.sh. It writes nothing to stdout and always exits 0, so it won't make a decision for Claude Code:

#!/bin/bash # Logs every hook payload. For temporary debugging only. umask 077 LOG="$HOME/.claude/hook-debug.log" payload="$(cat)" { printf -- '--- %s pwd=%s project=%s\n' \ "$(date -u +%FT%TZ)" "$PWD" "$CLAUDE_PROJECT_DIR" printf 'PATH=%s\n' "$PATH" printf '%s\n' "$payload" } >> "$LOG" exit 0 

2. Make it executable with chmod +x .claude/hooks/debug-log.sh.

3. Register it in .claude/settings.local.json. With no matcher, it fires on every occurrence. Merge this into any existing "hooks" key, then add the same entry under SessionStart, PostToolUse, Notification, Stop and the event you're debugging if it's missing:

{ "hooks": { "PreToolUse": [{ "hooks": [{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/debug-log.sh", "args": [] }] }] } } 

4. Trigger the behavior, then run tail -f ~/.claude/hook-debug.log in another terminal. Matching hooks run in parallel, so this logger sits alongside your real hook. Each payload includes common fields such as session_id, transcript_path, cwd, permission_mode and hook_event_name, and tool events add tool_name and tool_input.

What the log shows What it means Next check
No file, no entries Hooks aren't loading /hooks, the file location, --bare
SessionStart only Your event didn't occur Wrong event, or no tool call happened
tool_name differs from your matcher Matcher miss Copy the exact name
Event logged, your script has no effect Script failure or wrong output Run it by hand, then check the exit code
Different pwd or PATH under cron Environment mismatch cd first, use absolute paths

5. Read Claude Code's own log. Start with claude --debug-file /tmp/claude.log, or run /debug mid-session to enable logging and find its path. Plain claude --debug can look broken, since "The --debug flag doesn't print to the terminal." Its log goes to ~/.claude/debug/<session-id>.txt.

On v2.1.296 or later, search for Hooks: lines, such as Hooks: PostToolUse:Write [.claude/hooks/log-write.sh] finished with status 0 (31ms). Other endings include timed out after <N>ms and failed to start. For matcher counts, set CLAUDE_CODE_DEBUG_LOG_LEVEL=verbose.

In a live session, Ctrl+O opens the transcript view, where a non-blocking failure appears as a <hook name> hook error notice.

6. Clean up. Remove the entries and delete the log when you're done, since payloads can include prompts, commands and file contents. Command hooks run with your full user permissions, so read any hook you copy before installing it.

FAQ

Where is the Claude Code hooks documentation?

The hooks reference covers event schemas, output fields and debug logs. The hooks guide covers setup and troubleshooting. For settings problems, use Debug your configuration.

Do Claude Code hooks run in parallel and what is the default timeout?

Yes, matching hooks run in parallel, and an identical handler defined in several settings files runs once. Command hooks default to 10 minutes as of November 2026, with shorter defaults on some events. Override them per hook with timeout, in seconds.

Can a hook block a tool call before it runs?

Yes. Use PreToolUse with exit 2 or permissionDecision: "deny". A deny holds even in bypassPermissions mode, with one documented exception: on machines without managed settings or a Team or Enterprise /login, an installed mod can approve a call your hook blocked. An "allow" from a hook doesn't override deny rules.

Why does my hook run but Claude ignores its output?

Claude never sees stderr from a hook that exits 0, and most events don't add plain stdout to Claude's context. Return hookSpecificOutput.additionalContext, nested correctly, because a top-level additionalContext is silently ignored. Output over 10,000 characters is saved to a file, and Claude gets the path plus a 2,000-character preview.

Next steps

If you'd rather not dig through logs yourself: run /debug followed by your issue in a session. It turns on debug logging and prompts Claude to diagnose the problem, while /doctor proposes fixes and asks before applying them.

Claude Code is included in paid plans: Pro is $20 billed monthly or $17 a month billed annually, Max is $100 a month, and a Team standard seat is $25 billed monthly or $20 billed annually (as of November 2026; check the official page). Pro suits a solo builder with one or two automations, while Max makes more sense only if you already hit Pro's usage limits. Neither suits someone who wants a person to take responsibility for the fix, and Claude's diagnosis can be wrong, so test the result with the manual command above.

Related guides: Hooks lifecycle, Stop hook without loops, Cron job setup.

Sources

The linked claims above come from these official pages, read on November 8, 2026.

This article was created with the help of AI. Sources are linked inline; check the official documentation for the latest details.

原文:https://www.a8q9.cn/en/article/claude-code-hooks-not-working-8-fixes-and-a-debug-script-547fdd.html

相关文章