← Blog · 2026-10-18
Claude Code Hooks Not Working? 12 Causes and Fixes
Shortcut: our free hooks & settings.json generator writes a valid
hooksblock with the right nesting, exact-case matchers and blocking exit codes, so several of the problems below can't happen. It runs in your browser.
A Claude Code hook is a command that runs at a set moment, such as right before Claude uses a tool (PreToolUse) or when it finishes a reply (Stop). When a hook "doesn't work", it's one of three things: Claude Code never loaded it, it loaded but never matched, or it ran and Claude Code didn't act on what it returned. Work through the checks below in order.
First: does Claude Code see the hook?
Type /hooks at the Claude Code prompt. It lists every configured hook grouped by event. If your hook isn't there, start at cause 1. If it's there but never seems to run, go to cause 5. If it runs but doesn't block or its output is ignored, go to cause 9.
Not loaded: 4 reasons /hooks shows nothing
- The settings file has a JSON error. Trailing commas and comments aren't allowed in Claude Code settings files, and one stray comma means none of the hooks in that file load. Paste the file into any JSON validator before anything else.
- The file is in the wrong place. Project hooks go in
.claude/settings.json(or.claude/settings.local.jsonfor just you) inside the project. Hooks for every project go in~/.claude/settings.json. Asettings.jsonat the project root, outside.claude/, isn't read. Cloud sessions also don't read your local~/.claude/settings.json. - The nesting is one level off. Hooks have three levels: the event name, then a list of matcher groups, then each group's own
hookslist of handlers. The handler needs"type": "command"and acommand. A handler placed straight under the event, with no matcher group around it, is the most common copy-paste mistake. The working shape is at the end of this page. - Hooks are turned off or restricted.
"disableAllHooks": truein any settings file turns hooks off, and the value left after settings precedence is the one that counts, so a project file can override yours. If/hookssaysOnly hooks from managed settings run here, your organization setallowManagedHooksOnly, and hooks in your user, project and local files don't run. File edits are normally picked up on their own; if a new hook still hasn't appeared after a few seconds, restart the session.
Loaded but never fires: 4 reasons
- The matcher doesn't match the tool name exactly. Matchers are case-sensitive:
bashnever matches theBashtool. A matcher made only of letters, digits,_,-, spaces,,and|is compared as an exact name (or a list of names likeEdit|Write). Anything else is treated as an unanchored regular expression, soEdit.*also matchesNotebookEdit; use^Edit$when you mean only Edit. - The MCP matcher is missing
.*. MCP tools are namedmcp__<server>__<tool>. A matcher likemcp__memorycontains only exact-match characters, so it's compared as an exact string and matches no tool. Usemcp__memory__.*. For a server bundled in a plugin, the name includes the plugin, as inmcp__plugin_my-plugin_db__.*. - It's the wrong event, or the event has no matcher.
PreToolUsefires before a tool runs andPostToolUseafter, so aPostToolUsehook can't undo anything.Stop,UserPromptSubmitand a few other events don't support matchers at all and fire every time. And the optionaliffield only works on tool events; adding it to any other event stops the hook from running. - The script can't start. On macOS and Linux the script must be executable:
chmod +x .claude/hooks/my-hook.sh. If you seecommand not found, use an absolute path or${CLAUDE_PROJECT_DIR}, and quote it in shell form ("\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/my-hook.sh") or add"args": []so Claude Code runs it directly without a shell. If the message isjq: command not found, installjqor parse the JSON with Python or Node instead.
Runs but doesn't block, or its output is ignored: 4 reasons
- It exits 1 instead of 2. Only exit code 2 blocks. Exit 1, the usual Unix failure code, is a non-blocking error: Claude Code shows a
hook errornotice and lets the action go ahead. For a guardrail, write the reason to stderr andexit 2. - The JSON has a field at the wrong level. For
PreToolUse,permissionDecisionandpermissionDecisionReasonbelong insidehookSpecificOutput, next to"hookEventName": "PreToolUse". At the top level they still parse, and Claude Code silently ignores them. Other events use different shapes:PostToolUseandStopuse a top-level"decision": "block". - Something prints before your JSON. Claude Code only reads stdout as JSON when it starts with
{. If your shell profile runs an unconditionalecho(this happens with Git Bash or whenBASH_ENVpoints at~/.bashrc), that text lands first and your decision is read as plain text, with no error shown. Wrap profile echoes inif [[ $- == *i* ]]; then ... fiso they only run in interactive shells. Also build JSON withjqor a real encoder; hand-built strings break on quotes and backslashes. - A hook can't loosen a deny rule. Hooks can tighten permissions but not loosen them: a hook that returns
"allow"doesn't override adenyrule in your settings. In plan mode, an"allow"on a file edit still gets the normal plan-mode prompt. The reverse does work: aPreToolUsedeny blocks the tool in every permission mode, includingbypassPermissions.
Bonus, for Stop hooks: if Claude keeps going instead of stopping, your hook is blocking every time. Read stop_hook_active from the input and exit 0 when it's true. Claude Code overrides a Stop hook after it blocks eight times in a row.
How to see what a hook actually did
- Test the script by hand. Pipe sample input in and check the exit code:
echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | ./my-hook.sh; echo $? - Read the transcript. Press
Ctrl+O. A block shows its message; a non-blocking error shows ahook errornotice with the first line of stderr; success usually shows nothing. - Read the debug log. Start with
claude --debug-file /tmp/claude.logand runtail -f /tmp/claude.login another terminal, or run/debugmid-session. Search it forHook output does not start with {(cause 11) orHook JSON output had unrecognized keys(cause 10).
A minimal hook that works
This blocks rm -rf before Bash runs it. Save it in .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-rm.sh"
}
]
}
]
}
}
And the script, at .claude/hooks/block-rm.sh (run chmod +x on it):
#!/bin/bash
COMMAND=$(jq -r '.tool_input.command')
if echo "$COMMAND" | grep -q 'rm -rf'; then
echo "Blocked: rm -rf is not allowed. Delete specific files instead." >&2
exit 2
fi
exit 0
Run /hooks to confirm it's listed under PreToolUse, then ask Claude to delete a folder to see the block.
If you'd rather not write the scripts yourself, our free, MIT-licensed claude-safety-hooks package installs three tested PreToolUse hooks (dangerous shell commands, .env and key files, lockfile edits) with npx claude-safety-hooks. It backs up your existing settings.json first, doesn't add duplicate entries if you run it twice, and --uninstall removes only what it added.
Facts on this page come from Anthropic's hooks guide and hooks reference. This guide isn't affiliated with Anthropic.