The annoyance
I put project hooks in a standalone hooks.json at the repository root so they would travel with the code. The JSON was valid. /hooks was empty, and so nothing fired.
I had the usual suspects, a matcher I had typed wrong and a script that failed to start and a settings file I thought I had already edited. Then I moved the same object under the hooks key in .claude/settings.json and the handlers appeared first time.
The mix-up is easy. Cursor keeps .cursor/hooks.json. A Claude Code plugin keeps hooks/hooks.json. I treat those as one pattern and drop a root hooks.json, or .claude/hooks.json next to the scripts, and Claude Code never reads either of those for user or project config.
What Anthropic actually says
From the official Debug your configuration page, common-causes table:
Symptom: “Hook never fires”
Cause: “Hooks are defined in a standalone file instead of settings.json”
Fix: “There is no standalone hooks file for project or user config. Define hooks under the "hooks" key in settings.json. Only plugins load a separate hooks/hooks.json. See hook configuration.”
The Check hooks section is the same rule, and it is the first thing to prove:
“Run /hooks to list every hook registered for the current session, grouped by event. If a hook you defined doesn't appear, it isn't being read: hooks go under the "hooks" key in a settings file, not in a standalone file.”
The hooks reference is the same contract. Official text:
“Hooks are defined in JSON settings files.”
Hook locations for project and user config are settings files. Official table:
“Where you define a hook determines its scope”~/.claude/settings.json: All your projects.claude/settings.json: Single project.claude/settings.local.json: Single project
Pluginhooks/hooks.json: When plugin is enabled
The one standalone file in that list is the plugin file:
“Define plugin hooks in hooks/hooks.json with an optional top-level description field. When a plugin is enabled, its hooks merge with your user and project hooks.”
The fix
Here is a minimal settings.json snippet. The handlers sit under the hooks key, in .claude/settings.json for the project or ~/.claude/settings.json for every project on this machine:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "echo \"hook loaded from settings.json\" >&2"
}
]
}
]
}
}These two files look finished and never load for user or project config: a root hooks.json, and .claude/hooks.json. The script can still live under .claude/hooks/. The registration does not. Only a plugin loads a separate hooks/hooks.json.
Prove it
Prove Claude Code read the settings file. If the hook does not appear in /hooks, it is not being read.
# In-session: /hooks
# Confirm the handler is listed
# Source should be a settings file, not a loose hooks.json
# User Settings: ~/.claude/settings.json
# Project Settings: .claude/settings.json
# Plugin Hooks: a plugin's hooks/hooks.json
# If the hook is missing, it is not being readOfficial menu sources:
“Each hook is labeled with a [type] prefix and a source indicating where it was defined”
User Settings: from~/.claude/settings.json
Project Settings: from.claude/settings.json
Local Settings: from.claude/settings.local.json
Plugin Hooks: from a plugin’shooks/hooks.json
If /hooks lists the handler and it still does not fire, you are on a different miss, such as a lowercase matcher. This note is the empty-menu case.
Checklist
- Put hooks under the
hookskey in a settings file, not in a standalonehooks.json. - Use
.claude/settings.jsonfor the project, or~/.claude/settings.jsonfor every project on this machine. - Leave
hooks/hooks.jsonto plugins. - Do not treat Cursor’s
.cursor/hooks.jsonas the Claude Code project file. - Run
/hooksand confirm the handler is listed and the source is a settings file.
Why it fails
I already have a standalone hooks file in Cursor, and a plugin in Claude Code uses one too, and so a root hooks.json looks like the same job. For user and project config it is not. Same family as .mcp.json under .claude/ — the file looks correct, the name is the one I already edit, and the documented place is a different one. Exit 1 Does Not Block is the other silent miss in this series: the hook looks installed and the documented contract is a different one. Hook Paths Don't Follow the Worktree is the path version of the same class: the hook runs, and it still hits the wrong place.
If you want to run this with other operators rather than on your own, we are doing that in the public Skool community, and that is https://www.skool.com/navaigate.
Sources
From idea to operation
Make the next AI decision concrete.
NavAIgate helps leadership teams identify high-value AI opportunities, prove them safely and turn the winners into working systems.
