Hooks and permissions

How to Debug Claude Code Hooks: Find Out Why One Won't Fire

How to debug Claude Code hooks: check /hooks, fix the matcher, read the --debug-file log, replay real input by hand, and get exit codes and JSON output right.

On this page
  1. Find the failing layer first
  2. Step 1: Is the hook loaded?
  3. Step 2: Does the event and matcher match?
  4. Step 3: Read the debug log
  5. Step 4: Run the hook by hand with real input
  6. Step 5: Check exit codes and JSON output
  7. Step 6: Timeouts, async hooks and Stop loops
  8. Isolate the problem
  9. Windows-specific traps
  10. When a tool installed the hook
  11. FAQ

To debug Claude Code hooks, work through four checks in order: run /hooks to confirm the hook loaded, make sure its event and matcher actually match, run the command by hand with sample JSON on stdin, and start Claude Code with claude --debug-file /tmp/claude.log to see which hooks matched, their exit codes and their output. Almost every broken hook fails at one of those four layers, and each check rules one out.

The flags, log format and error messages below come from the official hooks reference, the hooks guide's troubleshooting section and Debug your configuration, checked on October 3, 2026. Every JSON block is a valid hook config and every shell snippet passes bash -n.

Find the failing layer first

The symptom tells you where to start. Don't rewrite a script that never ran.

What you seeFailing layerStart with
/hooks doesn't list your hookLoadingThe settings file: JSON, location, shape
Listed, but nothing happens and no error appearsMatchingEvent choice, matcher, if field
A <hook name> hook error notice in the transcriptThe commandRun it by hand; read the debug log
It runs, but its decision or JSON is ignoredOutputExit code, stdout contents, field placement
Claude keeps going and won't stopA Stop hook loopThe stop_hook_active check

Step 1: Is the hook loaded?

Type /hooks in Claude Code. It's a read-only browser of every hook registered for the session, grouped by event and labeled with where it came from: user settings, project settings, local settings, a plugin or the session. Select a hook to see the exact command and its source file. If your hook isn't there, Claude Code didn't load it, and nothing further down this page matters yet.

The common causes, from the debug your configuration guide and the hooks guide:

  • Invalid JSON. Settings files are strict JSON. A trailing comma or a // comment stops the file from loading. Check with jq . ~/.claude/settings.json; it prints the error line if the file is broken.
  • The wrong file. Hooks go under the "hooks" key in a settings file such as ~/.claude/settings.json. ~/.claude.json (no slash) holds app state and is ignored for hooks, and there's no standalone hooks file for user or project config. Only plugins load a separate hooks/hooks.json.
  • A matcher written as an array. "matcher": ["Edit", "Write"] is invalid. Claude Code lists it as an invalid setting at startup and in claude doctor, and if the array is under PreToolUse or PermissionRequest, none of the file's other hooks load either. Use "Edit|Write".
  • Your organization restricts hooks. If /hooks shows Only hooks from managed settings run here, an admin set allowManagedHooksOnly, and your own hooks don't run.
  • Hooks are switched off. Look for "disableAllHooks": true in any settings file. A project's false overrides your user true, since Claude Code applies normal settings precedence.

Edits are normally picked up by a file watcher after a short delay, so you don't need to restart. If /hooks still shows the old version a few seconds after you save, run /hooks again; if it still doesn't update, restart the session. /doctor is also worth running: it checks for invalid settings files among other things.

One more loading rule catches people out. In an interactive session, Claude Code holds back hooks from every settings file, including your own user file, until you accept the workspace trust dialog for that folder, as the workspace trust section explains.

Step 2: Does the event and matcher match?

If /hooks lists the hook but it never runs, the cause is almost always the matcher or the choice of event. Matchers fail silently: a matcher that matches nothing is valid, it just never fires.

MistakeWhy it failsFix
"matcher": "bash"Matchers are case-sensitive; the tool is BashUse the exact tool name: Bash, Edit, Write, Read
"matcher": "Edit,Write" on an old versionBefore v2.1.191 a comma was a literal characterUse Edit|Write, or update Claude Code
"matcher": "Edit.*"Regex matchers are unanchored, so this also matches NotebookEditUse ^Edit$ or the plain name Edit
A matcher on Stop or UserPromptSubmitThose events have no matcher support, so it's ignoredRemove it and filter inside the script
"if": "Bash(git *)" on a non-tool eventif only works on tool events; elsewhere the hook never runsMove the check into the script
"matcher": "mcp__memory"Only exact-match characters, so it's compared as a whole name and matches no toolUse mcp__memory__.* for every tool from that server

The full rules are in the reference's matcher patterns section. Matchers made only of letters, digits, _, -, spaces, | and , are exact names; anything else is a JavaScript regular expression.

Then check you picked the event that fires when you think it does:

  • PreToolUse runs before a tool call and PostToolUse after a successful one. A failed tool call fires PostToolUseFailure instead.
  • Stop fires when Claude finishes responding, but not when you interrupt it, and an API error fires StopFailure instead.
  • The Notification event's permission_prompt type waits about six seconds, and each keystroke restarts the wait. idle_prompt comes about 60 seconds after Claude finishes. If you're typing while you test, they won't fire. Notification hook not working? covers those timing traps in detail.
  • PermissionRequest only fires when Claude Code is about to show you a permission prompt, so it never fires for a tool you already allowed.

The hooks pillar has the table of all events with what each matcher filters on.

Step 3: Read the debug log

The debug log is where hook problems stop being guesswork. Start Claude Code with a log file you choose and follow it from a second terminal:

Shell
claude --debug-file /tmp/claude.log
The Claude Code hooks reference section Debug hooks, with claude --debug-file and sample debug log lines
The Debug hooks section of the reference, with sample log lines.
Shell
tail -f /tmp/claude.log | grep -i hook

claude --debug without a path writes the same log to ~/.claude/debug/<session-id>.txt; it doesn't print to your terminal. If you're already in a session, type /debug to turn logging on and see where the log is. For more detail on matching, such as how many hooks each matcher selected, set CLAUDE_CODE_DEBUG_LOG_LEVEL=verbose before starting Claude Code.

A PostToolUse hook on Write whose command prints hook-ran produces lines like these, according to the reference:

Text
2026-07-19T02:03:24.382Z [DEBUG] Hook output does not start with {, treating as plain text
2026-07-19T02:03:24.382Z [DEBUG] "Hook PostToolUse:Write (PostToolUse) success:\nhook-ran"

If the event fires but your hook never shows up in the log, go back to Step 2: the matcher didn't select it. If it shows up with a failure, the log has the exit code, the full stderr and the stdout, which the transcript doesn't.

Inside the session, press Ctrl+O to open the transcript view. A successful hook shows nothing there. A blocking hook shows its feedback. A failing hook shows a notice such as PreToolUse hook error with the first line of stderr after Failed with non-blocking status code:.

Step 4: Run the hook by hand with real input

Claude Code sends each hook a JSON object on stdin. The fastest way to debug a script is to give it that JSON yourself and look at the exit code:

Shell
echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf build"}}' | ./.claude/hooks/check-bash.sh
echo "exit code: $?"

Hand-written JSON is easy to get wrong, though: a field you guessed, like tool_input.path instead of tool_input.file_path, makes your jq return null and the script quietly does nothing. Capture the real input instead. Add a temporary hook that appends each event to a file:

.claude/settings.local.json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "jq -c . >> /tmp/claude-hook-input.jsonl" }
        ]
      }
    ]
  }
}

Trigger the tool once, remove the temporary hook, and replay the captured event into your script:

Shell
tail -n 1 /tmp/claude-hook-input.jsonl | ./.claude/hooks/check-bash.sh
echo "exit code: $?"

Now you're debugging with exactly what Claude Code sends, including session_id, cwd, permission_mode and the event's own fields.

If the script works by hand but fails as a hook, look at what's different about how Claude Code runs it:

  • The shell. Shell-form hooks run with sh -c on macOS and Linux, Git Bash on Windows, or PowerShell when Git Bash isn't installed. Bash-only syntax in the command string itself can fail under sh; put it in a script with a #!/bin/bash line.
  • The path. Hooks run in Claude Code's working directory, which changes when Claude moves into another folder. Use "$CLAUDE_PROJECT_DIR"/.claude/hooks/check-bash.sh instead of a relative path, or exec form with "args": [].
  • Permissions. A script that isn't executable fails with a notice like Failed with non-blocking status code: /bin/sh: /path/to/hook.sh: No such file or directory or Permission denied. Run chmod +x on it.
  • Missing tools. jq: command not found means jq isn't installed or isn't on the PATH Claude Code started with.
  • No terminal. Hooks run without a controlling terminal, so anything that reads from or writes to /dev/tty fails. Use the terminalSequence output field for bells and terminal notifications.

Step 5: Check exit codes and JSON output

A hook that runs but "doesn't work" usually returns the wrong signal. The exit code rules are strict:

Exit codeMeaningWhat happens
0Successstdout is parsed as JSON if it starts with { and ends with }; stderr goes only to the debug log
2Blocking errorOn events that can block, the action stops and the stderr text goes to Claude as the reason
Anything else, including 1Non-blocking errorThe action goes ahead and the transcript shows a hook error notice

The classic bug is a policy hook that exits 1. That's the normal Unix failure code, but Claude Code treats it as non-blocking, so the tool call runs anyway. Use exit 2 for a block.

When you print JSON instead, three things go wrong:

  1. Extra text before the JSON. If anything else writes to stdout first, the output no longer starts with { and Claude Code treats it as plain text. On exit 0 nothing is reported; the debug log just says it treated the output as plain text. The usual culprit is an unconditional echo in ~/.bashrc or ~/.zshrc, which Git Bash and some setups load even for hooks. Wrap such lines in if [[ $- == *i* ]]; then ... fi so they only run in interactive shells.
  2. A field at the wrong level. permissionDecision belongs inside hookSpecificOutput, not at the top level. Misplaced fields are ignored without an error in the transcript; start with claude --debug and search the log for Hook JSON output had unrecognized keys.
  3. Broken JSON. Output that starts with { but doesn't parse is reported as a hook error with a parse message. Build output with jq -n instead of gluing strings together, so quotes and newlines inside values are escaped.

A minimal, correct example of a JSON decision from a script:

.claude/hooks/deny-force-push.sh
#!/bin/bash
cmd=$(jq -r '.tool_input.command // empty')
case "$cmd" in
  *"git push"*"--force"*)
    jq -n '{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: "Force pushes are blocked in this repo."}}'
    ;;
esac
exit 0
Terminal output: bash -n reports syntax ok, a force push input prints the deny JSON with exit code 0, and a normal push prints nothing with exit code 0
Run here on Linux: the script above, checked with bash -n and fed two sample inputs by hand. The deny comes back as JSON with exit code 0.

Also remember which events read plain stdout at all. Only SessionStart, UserPromptSubmit, UserPromptExpansion and PostModelSwitch add plain text to Claude's context. On every other event, a hook that "prints a message for Claude" on exit 0 is talking to the debug log.

Step 6: Timeouts, async hooks and Stop loops

  • Timeouts. Command hooks default to 600 seconds, but UserPromptSubmit drops that to 30, and SessionEnd hooks share a 1.5-second budget. A cleanup script that takes three seconds at session end gets cut off; set a higher timeout on it, which raises the budget up to 60 seconds.
  • Async hooks can't decide anything. With "async": true, the hook runs in the background, so decision, permissionDecision and continue have no effect, and its output arrives on the next turn. Use async only for side effects like notifications and logging.
  • Stop hooks that loop. A Stop hook that keeps blocking makes Claude keep working. Check the stop_hook_active field in the input and exit 0 when it's true. Claude Code also overrides a Stop hook after eight blocks in a row, and you can change that cap with CLAUDE_CODE_STOP_HOOK_BLOCK_CAP.

Isolate the problem

When you can't tell whether a hook is the cause of odd behavior at all, turn things off:

CommandWhat it does
claude --settings '{"disableAllHooks": true}'One session with every hook off, except managed ones
claude --safe-modeOne session without CLAUDE.md, skills, plugins, hooks, MCP servers, custom commands or agents
CLAUDE_CONFIG_DIR=/tmp/claude-clean claudeA clean configuration directory, started from a folder without a .claude folder

If the problem goes away with hooks off, bring them back one file at a time. There's no way to disable a single hook while keeping it in the file, so move its entry out temporarily. The debug your configuration guide describes the clean-directory approach in full; you'll be asked to log in again there.

Windows-specific traps

  • Git Bash vs PowerShell. Shell-form hooks run in Git Bash when Git for Windows is installed. A PowerShell command needs "shell": "powershell" on the hook.
  • $CLAUDE_PROJECT_DIR in PowerShell. The bare spelling resolves to $null in PowerShell. Write ${CLAUDE_PROJECT_DIR} or $env:CLAUDE_PROJECT_DIR; the reference says Claude Code logs a warning in the debug log for the bare form.
  • .cmd shims in exec form. With args, command must be a real executable. npm's .cmd and .bat shims can't be spawned without a shell, so run node with the script path instead.
  • Backslashes. Every Windows backslash inside a JSON string is written \\. One bad escape breaks the whole file, which sends you back to Step 1.

When a tool installed the hook

Status bars, sound packs and notch apps register hooks too. /hooks labels each one with its source, so it's the first place to look when a hook appears that you didn't write, or one you expected is missing. Eddie, our notch app for Mac and Windows, adds its hooks to ~/.claude/settings.json (%USERPROFILE%\.claude\settings.json on Windows) with one click after saving a backup, and asks you to restart sessions that were already open; if its status doesn't update for an older session, that restart is the fix.

For more on what each event can do, see Claude Code hooks explained, and for working configs to compare against, 12 useful Claude Code hooks.

FAQ

Why is my Claude Code hook not working?

Run /hooks first. If the hook isn't listed, Claude Code never loaded it: usually invalid JSON, the wrong file (such as ~/.claude.json), or a matcher written as an array. If it's listed but never runs, the matcher is usually wrong; matchers are case-sensitive, so bash doesn't match the Bash tool.

Where are Claude Code hook logs?

In the debug log. Start Claude Code with claude --debug-file /tmp/claude.log to write it to a path you choose, or with claude --debug to write it to ~/.claude/debug/<session-id>.txt. Mid-session, /debug turns logging on and tells you the path. The log records which hooks matched, their exit codes, stdout and stderr.

How do I see a hook's output in Claude Code?

Press Ctrl+O to open the transcript view. A successful hook shows nothing there; a blocking hook shows its feedback, and a failing hook shows a hook error notice. Stdout from a successful hook only goes to the debug log, except on the few events where it becomes context for Claude.

Why doesn't exit code 1 block the action?

Because only exit code 2 blocks. Claude Code treats exit 1, and every code other than 0 and 2, as a non-blocking error: the action goes ahead and the transcript shows a hook error notice. Use exit 2 with a reason on stderr, or exit 0 and print a JSON decision.

How do I test a Claude Code hook without running Claude?

Pipe sample JSON into the script and check the exit code: echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | ./my-hook.sh; echo $?. For realistic input, add a temporary hook that saves the real event JSON to a file, then replay that file into your script.