Skip to content

Claude Code Hooks: Why Yours Isn't Firing, and How to Write One That Does

A hook that does not fire produces no error, no log line, no red text — it just quietly doesn't guard. Every silent hook we have hit in production comes down to one of seven checks. Here they are, fastest-catch first.

August 20, 20268 min readShift The Culture

A Claude Code hook that does not fire fails silently. There is no error, no log line, no red text — the format-on-save just does not happen, the guard you wrote does not guard, and you only find out when the thing it was supposed to prevent happens anyway. Every cause of a silent hook we have hit in months of running these in production comes down to one of seven checks. Here they are, in the order that catches the most cases first.

The fast diagnosis

  1. Run /hooks inside the session. If your hook is not listed there, Claude Code is not seeing it at all — the problem is the file or the JSON, not the command. Go to check 1.
  2. If it is listed but nothing happens, the matcher is not matching or the command is failing invisibly. Go to checks 4 and 5.
  3. If it fires in one project but not another, you put it in a project-scoped file and are expecting user-scoped behaviour. Check 2.

Check 1 — the hook was edited mid-session and never loaded

This is the single most common cause, and it is by design. Claude Code captures a snapshot of your hook configuration at session startup. Editing settings.jsonwhile a session is running does not change the running session — a security measure, so nothing can quietly rewrite your hooks underneath you mid-flight. The session you are testing in is running yesterday's hooks.

  • Fix: restart the session, or open /hooks and review the changes there — the menu is also where direct file edits get picked up.
  • Corollary: when you iterate on a hook script, put the logic in a separate script file and have settings.json just call it. Edits to the script apply immediately; only edits to the hook configuration need the reload.

Check 2 — it is in the wrong settings file

Hooks load from specific files, and each has a scope:

where hooks can live
~/.claude/settings.json           user — every project on this machine
.claude/settings.json             project — checked into git, whole team
.claude/settings.local.json       project — yours only, gitignored

A hook in .claude/settings.json of project A does nothing in project B. A hook you meant to share but wrote into settings.local.json exists only on your machine. And a file named anything else — hooks.json, settings.jsonc, a typo'd path — is not read at all. Also verify the JSON parses: a trailing comma silently invalidates the file, and with it every hook in it.

Check 3 — the event name is wrong

Hook events are exact, case-sensitive keys. The ones that exist: PreToolUse, PostToolUse, UserPromptSubmit, Notification, Stop, SubagentStop, PreCompact, SessionStart and SessionEnd. preToolUse, PreToolCall, OnEdit— all silently ignored. If you want “after every file edit”, that is PostToolUse with a matcher, not an event of its own.

Check 4 — the matcher does not match the real tool name

For PreToolUse and PostToolUse, the matcheris compared against the tool's exact name, and it supports regex. The names that actually occur are the internal tool names: Edit, Write, Read, Bash, Glob, Grep, Task, WebFetch — capitalised, no spaces.

  • "edit" does not match Edit. Matchers are case-sensitive.
  • To match several tools, use regex alternation: "Edit|Write".
  • To match everything, use "*" or leave the matcher empty.
  • MCP tools have three-part names: mcp__<server>__<tool>, e.g. mcp__github__create_issue. A matcher of "github" misses it; "mcp__github__.*" catches the whole server.
a PostToolUse hook that actually fires on edits
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/format.sh"
          }
        ]
      }
    ]
  }
}

Check 5 — the command runs and fails, invisibly

A hook whose command exits non-zero mostly looks identical to a hook that never ran. The environment your hook runs in is not your interactive shell: no .zshrc aliases, sometimes a leaner PATH. The classic version is a hook that pipes into jq on a machine where jq lives somewhere the hook's PATH does not reach — works when you test it in your terminal, dies inside the hook.

  • Test the command the way the hook runs it: feed it JSON on stdin, because that is how hooks receive their input — a JSON payload with session_id, tool_name, tool_input and friends on stdin, not as arguments.
  • Use absolute paths for binaries, or $CLAUDE_PROJECT_DIR for project scripts — the working directory is not guaranteed to be where you think.
  • Log to a file while debugging: echo "fired $(date)" >> /tmp/hook.logas the first line answers “did it even run” definitively.
  • Run claude --debug to watch hook execution live — it prints which hooks matched, what ran, and what came back.

Check 6 — it fires, but the exit code says the wrong thing

Hooks talk back through exit codes, and getting this wrong makes a hook that runs perfectly look broken:

  • Exit 0 — success. stdout is mostly invisible to the model (shown in transcript verbose mode). Exception: on UserPromptSubmit and SessionStart, stdout is injected into context — which is exactly how you feed the agent live information.
  • Exit 2 — blocking error. stderr is fed back to Claude, which reacts to it. On PreToolUse this blocks the tool call. This is the only exit code that stops anything.
  • Any other non-zero — non-blocking. stderr is shown to you, the run continues. A guard hook that exits 1 guards nothing.
A validation hook that exits 1 instead of 2 runs every single time and blocks nothing. It is the most expensive one-character bug in the hooks system.

For decisions richer than allow/block — approve this, deny that, ask the human — print structured JSON on stdout with a permissionDecision instead. And mind the default 60-second timeout: a hook that calls a slow linter gets killed quietly unless you raise timeout on that command.

Check 7 — you expected a hook where a hook cannot exist

Hooks fire on tool use and session lifecycle. They do not fire on “the model said something”, and a PostToolUse hook cannot un-run the tool — the edit already happened; your hook is a reaction, not a veto. If you need a veto, it has to be PreToolUsewith exit 2. If you need “check everything before the agent declares victory”, that is a Stop hook — which can exit 2 to refuse the stop and send the agent back to work. That last pattern is the backbone of making an agent actually finish the job.

The whole diagnosis on one screen

silent hook checklist, in order
1. /hooks             → not listed? file/JSON problem, not command problem
2. restart session    → config is snapshotted at startup; mid-session edits don't load
3. right file?        → ~/.claude/settings.json vs .claude/settings.json vs .local
4. JSON valid?        → one trailing comma kills every hook in the file
5. event name exact?  → PreToolUse / PostToolUse / Stop… case-sensitive
6. matcher exact?     → "Edit|Write", "*", mcp__server__tool for MCP
7. command works on stdin-JSON with hook's PATH?  → test it that way, log to a file
8. exit codes: 0 ok · 2 block (stderr → Claude) · else non-blocking
9. claude --debug     → watch it fire

Reference for everything above: Anthropic's hooks reference and hooks guide. If the thing that is not firing is a permission rule rather than a hook, that is a different file with different traps — covered in permission rules that do nothing.

SharePost on X
Free · 13 pages · no upsell inside

Get the Operator Field Kit — free

Six production prompts, the five-step operator setup, and nine rules from our own failure log.

  • 6 complete prompts — printed in full, not previews
  • The five-step setup, each step with a pass/fail test
  • 9 rules from the failure log that produced them

The kit, then the occasional operator note. One click unsubscribes and we never sell the address.

Keep reading