Solo Stack Kits Directory
Claude Code workflows & skills for solo consultants and freelancers

← Blog · 2026-10-18

Claude Code Hooks Not Working? 12 Causes and Fixes

Shortcut: our free hooks & settings.json generator writes a valid hooks block 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

  1. 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.
  2. The file is in the wrong place. Project hooks go in .claude/settings.json (or .claude/settings.local.json for just you) inside the project. Hooks for every project go in ~/.claude/settings.json. A settings.json at the project root, outside .claude/, isn't read. Cloud sessions also don't read your local ~/.claude/settings.json.
  3. The nesting is one level off. Hooks have three levels: the event name, then a list of matcher groups, then each group's own hooks list of handlers. The handler needs "type": "command" and a command. 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.
  4. Hooks are turned off or restricted. "disableAllHooks": true in 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 /hooks says Only hooks from managed settings run here, your organization set allowManagedHooksOnly, 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

  1. The matcher doesn't match the tool name exactly. Matchers are case-sensitive: bash never matches the Bash tool. A matcher made only of letters, digits, _, -, spaces, , and | is compared as an exact name (or a list of names like Edit|Write). Anything else is treated as an unanchored regular expression, so Edit.* also matches NotebookEdit; use ^Edit$ when you mean only Edit.
  2. The MCP matcher is missing .*. MCP tools are named mcp__<server>__<tool>. A matcher like mcp__memory contains only exact-match characters, so it's compared as an exact string and matches no tool. Use mcp__memory__.*. For a server bundled in a plugin, the name includes the plugin, as in mcp__plugin_my-plugin_db__.*.
  3. It's the wrong event, or the event has no matcher. PreToolUse fires before a tool runs and PostToolUse after, so a PostToolUse hook can't undo anything. Stop, UserPromptSubmit and a few other events don't support matchers at all and fire every time. And the optional if field only works on tool events; adding it to any other event stops the hook from running.
  4. The script can't start. On macOS and Linux the script must be executable: chmod +x .claude/hooks/my-hook.sh. If you see command 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 is jq: command not found, install jq or parse the JSON with Python or Node instead.

Runs but doesn't block, or its output is ignored: 4 reasons

  1. 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 error notice and lets the action go ahead. For a guardrail, write the reason to stderr and exit 2.
  2. The JSON has a field at the wrong level. For PreToolUse, permissionDecision and permissionDecisionReason belong inside hookSpecificOutput, next to "hookEventName": "PreToolUse". At the top level they still parse, and Claude Code silently ignores them. Other events use different shapes: PostToolUse and Stop use a top-level "decision": "block".
  3. Something prints before your JSON. Claude Code only reads stdout as JSON when it starts with {. If your shell profile runs an unconditional echo (this happens with Git Bash or when BASH_ENV points at ~/.bashrc), that text lands first and your decision is read as plain text, with no error shown. Wrap profile echoes in if [[ $- == *i* ]]; then ... fi so they only run in interactive shells. Also build JSON with jq or a real encoder; hand-built strings break on quotes and backslashes.
  4. A hook can't loosen a deny rule. Hooks can tighten permissions but not loosen them: a hook that returns "allow" doesn't override a deny rule in your settings. In plan mode, an "allow" on a file edit still gets the normal plan-mode prompt. The reverse does work: a PreToolUse deny blocks the tool in every permission mode, including bypassPermissions.

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

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.

Want it set up already? Claude Code Power Config Pack

Fixed it? Skip the next one with a tested Claude Code setup you can drop into any repo.

One install command that backs up your settings first. Checked against the official Claude Code docs. MIT licensed.

Get the Config Pack: $12

Prefer to build it yourself? Use the free Claude Code tools, no sign-up.