Claude Code hooks not working? Seven checks, in order
On this page
  1. 1. Is the hook loaded?
  2. 2. Is the settings file valid JSON?
  3. 3. Does the matcher match?
  4. 4. Is it on the event you think?
  5. 5. Does the script work when you run it?
  6. 6. Is the exit code the one you meant?
  7. 7. Does anything print before the JSON?
  8. Test a guard before you trust it
  9. Where Joxo fits
  10. Frequently asked questions
  11. Why is my Claude Code hook not running?
  12. Why doesn't my PreToolUse hook block the command?
  13. How do I see hook errors in Claude Code?
  14. Try it with your team

Blog / claude code

Claude Code hooks not working? Seven checks, in order

Check /hooks, the JSON, the matcher, the event, the script with sample input, the exit code and stray output. Each check takes under a minute.

When a Claude Code hook does nothing, the cause is almost always one of seven things: the hook isn't loaded, the settings file isn't valid JSON, the matcher doesn't match, it is on the wrong event, the script fails when run, it exits with the wrong code, or something prints before its JSON. Check them in that order; each takes under a minute, and the first four need no debugging at all.

Hooks fail quietly by design: most hook errors don't stop Claude Code, so a broken one looks exactly like a hook that was never there. A guard you thought was blocking force-pushes may have been doing nothing for a week.

1. Is the hook loaded?#

Type /hooks in Claude Code. Your hook should appear under its event, such as PreToolUse or Notification.

If it is missing:

  • Check the file is in a place Claude Code reads: .claude/settings.json in the project, .claude/settings.local.json in the project, or ~/.claude/settings.json for every project.
  • Edits are usually picked up within seconds. If not, restart the session.
  • If the menu says Only hooks from managed settings run here, your organization has turned off personal and project hooks. Nothing in your own files will run.

2. Is the settings file valid JSON?#

A trailing comma or a comment is enough to stop the whole file from loading. Check it:

bash
jq . .claude/settings.json

A valid file prints back formatted. A broken one names the line:

jq: parse error: Expected another key-value pair at line 4, column 3

That error came from a file with a comma after the last item in a list. Also check that every event sits inside one hooks object. Adding a second "hooks": {...} block lower in the file replaces the first one instead of adding to it.

3. Does the matcher match?#

For tool events, the matcher is compared with the tool's name, and it is case-sensitive. bash matches nothing; Bash matches shell commands. Edit|Write matches either tool. An empty matcher, or leaving it out, matches everything.

Other events match other things. For Notification the matcher is the kind of notification, such as permission_prompt or idle_prompt. For SessionStart it is how the session started: startup, resume, clear or compact. A Notification hook with the matcher Bash never fires.

4. Is it on the event you think?#

Many "it never runs" reports are a hook on the right idea but the wrong moment:

You wantUseNot
Block a command before it runsPreToolUsePostToolUse, which runs after the command already ran
Know the moment Claude asks permissionPermissionRequestNotification, which waits about six seconds and only fires if you seem away
Know when Claude has finished and you are awayNotification with idle_promptStop, which fires after every single reply
Put context back after compactionSessionStart with compactPreCompact, whose message is discarded

The hooks reference lists every event and what it can and can't do.

5. Does the script work when you run it?#

Run the hook yourself with the kind of input Claude Code sends. Hooks get a JSON object on standard input. For a PreToolUse hook on Bash, this is enough:

bash
echo '{"tool_name":"Bash","tool_input":{"command":"git push --force origin main"}}' | ./.claude/hooks/block-push.sh
echo "exit $?"

The first time we ran ours, it printed:

permission denied: ./.claude/hooks/block-push.sh
exit 126

The script wasn't executable. One command fixed it:

bash
chmod +x .claude/hooks/block-push.sh

Other common failures at this step:

  • jq: command not found: install jq, or parse the input with Python or Node instead.
  • command not found for your script: Claude Code may run the hook from a different folder. Use "$CLAUDE_PROJECT_DIR"/.claude/hooks/block-push.sh in the command instead of a relative path.
  • On Windows, shell commands in hooks need Git Bash; if Claude Code can't find it, the hook fails.

6. Is the exit code the one you meant?#

This one catches people who know shell scripting well. In a Claude Code hook:

Exit codeEffect
0Success. JSON on standard output is read for a decision
2Blocks the action (for events that can block). Your standard error is the reason
Anything elseA non-blocking error. The action goes ahead

So a guard script that ends with exit 1 when it spots a bad command doesn't block anything. It reports an error and the command runs. To block, exit 2 with a reason on standard error, or exit 0 and print a deny decision as JSON. Ours does the second:

sh
#!/bin/sh
cmd=$(jq -r '.tool_input.command')
case "$cmd" in
  *"git push"*"--force"*)
    jq -n '{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: "No force-push from the agent"}}'
    ;;
esac
exit 0

Build the JSON with jq -n rather than gluing strings together: a quote inside a value breaks hand-made JSON, and broken JSON is ignored.

Fields have to be at the right level, too. permissionDecision belongs inside hookSpecificOutput; at the top level it is silently ignored.

7. Does anything print before the JSON?#

Claude Code reads hook output as JSON only if it starts with {. If your shell profile prints a greeting, that text arrives first and your JSON is treated as plain text. The hook exits 0, so there is no error either.

To see it, start Claude Code with claude --debug, trigger the hook, and look in the debug log under ~/.claude/debug/ for Hook output does not start with {. The fix is to print profile messages only in interactive shells:

bash
# In ~/.zshrc or ~/.bashrc
if [[ $- == *i* ]]; then
  echo "Shell ready"
fi

Test a guard before you trust it#

A blocking hook is only worth having if you have seen it block. After any change:

  1. Run it by hand with a bad input and check you get the deny (step 5).
  2. Run it with a harmless input and check it prints nothing and exits 0.
  3. In a test session, ask Claude Code to do the blocked thing and watch it refuse.

Keep in mind that a hook catches the commands it was written for. A script that looks for git push --force won't see git push -f. Pair hooks with deny rules in settings, and keep anything truly dangerous out of the agent's reach.

Where Joxo fits#

If Joxo itself stops responding in your agent, joxo doctor checks the setup on that computer and names each problem with its fix, and joxo repair fixes what needs no decision from you and says what it fixed. Setup is on the setup page.

Frequently asked questions#

Why is my Claude Code hook not running?#

Run /hooks first. If the hook isn't listed, the settings file is in the wrong place or isn't valid JSON (check with jq .). If it is listed, check that the matcher matches the tool name exactly, with the right case, and that the hook is on the right event.

Why doesn't my PreToolUse hook block the command?#

Most often it exits with code 1, which Claude Code treats as a non-blocking error. Exit 2 with a reason on standard error, or exit 0 and print a JSON deny decision inside hookSpecificOutput.

How do I see hook errors in Claude Code?#

Start it with claude --debug and read the debug log in ~/.claude/debug/. It shows each hook that ran, its output, and lines such as Hook output does not start with { when the output wasn't read as JSON.

See also: Keep Claude Code's rules after compaction, which uses a SessionStart hook.