The hook is already set up, the /hooks command shows it, and then things get strange. You send a request, but no new line shows up in requests.log. Or it shows up twice. Sometimes it's worse: an automatic action fires after every edit and starts getting in the way of ordinary work.
In this situation there's no need to rebuild the whole setup from scratch. It's more useful to turn the observation into a small, checkable task: what should trigger the hook, what trace it should leave, and what case it must skip. Then Claude Code can check specific files and suggest a minimal fix.

When a hook should fire
This article looks at command hooks from settings files. A hook like this has three parts that matter: an event, a matching condition, and a handler. The event answers "when to look", the condition narrows down the matching cases, and the handler carries out the action.
For example, a request log subscribes to UserPromptSubmit. This event fires when a request arrives in the session, before the model has processed it. Most often that's your request, but Claude Code also fires it for turns it starts on its own: the next round of /loop, a report from a background subagent. So a new line in the log confirms very little - the hook received the request and wrote it down, nothing more. That line can't tell you whether the agent did the task correctly.
UserPromptSubmit has no matcher. If the log only needs some of the requests, the handler itself has to filter them. Tool events work differently. A matcher like Edit|Write selects tools, while the file path is checked separately, from the call's own data. A matcher with a tool name doesn't mean "only files under src".
The Stop event means Claude finished responding. That's a convenient moment for a notification, but not proof that the code is correct or the task actually succeeded.
Before fixing anything, write down two short scenarios:
- the wanted one: send a test request and see one new line in
requests.log; - the unwanted one: open the file in an ordinary editor, without sending a new request to Claude Code, and confirm the log stays unchanged. For a log of every request, a second submitted prompt should also produce an entry - it won't work as a negative control.
These are the checks you're testing against. There's no need to guess Claude Code's exact answer up front.
The entry never shows up
Start at the point where the observation and the expectation part ways. For the log, that's the missing new line after a test request. From there, work from the setup toward the action:
- Run
/hooksin an interactive Claude Code session and check whether the entry you need is loaded. You open this menu yourself; hand the result over to the agent. - Check the event. A request log should react to
UserPromptSubmit, while an action after a file edit is usually tied to a tool event. - Find the source settings file. It could be a user, project, or local setting; compare not just the names but the full commands too. The same handler showing up in several settings files at once doesn't by itself mean two runs.
- Check the handler's command: does the file exist, does the command match your OS, or has a path with spaces broken it?
- Check where the result lands. A relative path depends on the process's working directory, so the handler may have written the file somewhere other than where you're looking for it.
Here the main route is to hand the problem to the agent. Give it the observation, the result of checking /hooks, and the boundaries of the fix:
The UserPromptSubmit hook shows up in /hooks, but after a test request
there's no new line in requests.log.
Find the source setting and the handler. Check the event, the path to the
command, the working directory, and whether the log directory gets created.
Show me the cause and make the minimal fix. Keep the other hooks and
settings as they are. After the fix, describe a positive and a negative
control scenario.Ask the agent to name the file and the exact lines it's changing. That way you can see whether it's fixing the actual setting or some unrelated copy that never even loads.
Say the agent found this: the handler writes to logs/requests.log, but the logs/ folder doesn't exist and the script never creates it. The minimal fix is creating that folder before writing. After that, check logs/requests.log specifically: one test request should add exactly one entry. This is an example of a possible cause, not a diagnosis for every empty log.
If the setting is in the file but missing from /hooks, the cause could be JSON syntax, the setting's scope, or hooks not running at all right now: the project folder isn't marked as trusted, disableAllHooks is on, or an administrator allowed only hooks from the managed policy. Hooks from different settings files add up rather than replace each other. If the entry loads but leaves no trace, attention shifts to the command, its input, and the output path.
The point of the branch is simple: until Claude Code has loaded the entry, it's too early to dig into the script. Once the entry is visible, the config has already reached the app, and it's more useful to check how the handler runs, the input JSON, and the place where the result should show up.
The handler failed
A command hook receives JSON over standard input. It carries the common event fields plus data specific to that event. For UserPromptSubmit, the request text arrives in the prompt field. That text has to be treated as data. Do not concatenate it into a shell command and run it: a test request may contain quotes, line breaks, pretty much any ordinary text.
Ask Claude Code to run the handler on made-up input. Spell out the important cases right in the request:
Test the hook handler separately from the session, on made-up JSON input.
Cover these cases: a plain and a multiline prompt, a project path with
spaces, a missing log directory, and malformed input.
Do not execute the prompt text as code. Show me the run command, the exit
code, stderr, and the actual change to the test file. Don't touch the
working log.The exit code helps you see what happened to the handler itself, but its meaning depends on the event. exit 0 says the hook command finished without an error. That doesn't confirm the whole task succeeded. exit 2 can block the action on "before" events, but after a tool has already run, it won't undo the work. So a single recipe built around exit 2 can't be carried over to other events blindly.
If the standalone run succeeds but the hook still stays silent in the session, start Claude Code from the project folder with claude --debug-file hook-debug.log and repeat one test request. Open the resulting hook-debug.log and look for the event, the hooks that matched, and the exit code. This is Claude Code's own log, separate from your requests.log. Once you're done checking, close that diagnostic session; for regular work, run Claude Code without the flag. Ordinary stdout from most events goes to the debug log and doesn't necessarily show up in the transcript. The exceptions depend on the event, so arbitrary diagnostic lines in stdout can end up affecting the context instead of becoming a convenient log.
For a first pass it's safer to write a diagnostic mark to a separate local file and remove it once you're done checking. The official documentation has no universal dry-run flag for hooks; a trial mode has to be built into the specific handler you're using.
The hook fires one time too many
Two identical lines after a single request can point to a duplicate registration: similar but not fully identical hooks sitting in, say, both the user and project settings. If it's literally the same command just repeated across files, it still runs once. Copies coming from a plugin or a skill count separately, so the source shown in the menu matters too. Start by handing the agent the result of /hooks and asking it to list every entry for this event across all available settings files. If the settings check out, look at whether the handler itself writes twice per run. It's too early to delete the first line you find.
Another common cause is a condition that's too broad. Say a hook is supposed to leave a diagnostic mark after files change under src/, but it reacts to every use of Edit and Write. Two levels of filtering are needed here:
- the matcher selects the
EditandWritetools; - a separate condition checks the path from the tool's arguments and skips anything outside
src/.
One check doesn't replace the other. The matcher filters out the wrong tools, then the condition on the arguments keeps only paths under src/. The diagnostic mark only appears once both checks come back "yes".
A settings fragment might look like this:
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "python .claude/hooks/mark-source-edit.py" }
]
}
]This is one element of the hooks object inside an existing settings.json, not a full JSON document. The path check belongs in the handler; the mark-source-edit.py script here is a placeholder, and its body isn't in the article. The command assumes python is available and that it runs from the project root. The agent should verify the interpreter, the shell, and the path for your own setup; don't copy the example as a drop-in setup.
This fragment is easy to read as a single sentence:
- After a successful file edit
- If the tool is Edit or Write
- Run mark-source-edit.py
The chain shows only what the setting actually defines. The handler decides "leave a mark or skip the file" after it checks the path. So having a matcher confirms the tool choice, but it doesn't yet prove correct filtering on src/.
| Action | Expected trace | What it checks |
|---|---|---|
Edit src/card.js via Edit | One new diagnostic mark | The right tool and the right path pass the condition |
Create src/theme.css via Write | One new diagnostic mark | The second allowed operation is covered too |
Edit README.md via Edit | No new marks | A file outside src/ is excluded |
In this table, Edit and Write mean Claude Code's tools. Editing a file by hand in your regular editor doesn't call them, so a matcher like this never sees it. Before each action, note where the diagnostic file currently ends, then compare only the new entries afterward.
Until the boundary is confirmed, keep the action harmless: one mark that it fired, no formatting, blocking, or bulk file edits. First make sure the wanted case passes exactly once and the unwanted one doesn't pass at all.
An extra run always adds work. That said, a local command that just writes a file doesn't reach out to the model on its own. A prompt or agent type hook works differently and does use the model. There's no need to dig into the cost breakdown here - it's enough not to mix up these two kinds of handlers.
How to turn off a reaction
If a hook is getting in your way right now, find its source entry and remove just that one from the array for the relevant event. Leave the other hooks alone. This is turning off future runs: it doesn't undo a command that already ran, doesn't restore changed files, and doesn't remove lines that already made it into the log.
Before deleting, save a copy of the entry if you might want it back later. After the change, open /hooks again and make sure that entry is gone. If the menu still shows the old setting, close the session and open a new one from the same project. Then run a control action and compare the log before and after. There should be no new lines from the disabled hook.
There's a setting for temporarily turning off every hook: "disableAllHooks": true. But that's a master switch (all or nothing), not a way to turn off a single entry. For this setting itself, file priority does apply: "disableAllHooks": false in the project file overrides true from your user settings. On top of that, hooks set by a managed organization policy can't be turned off by an ordinary user or project setting.
A disabled hook stops running going forward. If the handler already changed a file, restoring that file is a separate step, done only after looking at the diff or a saved version.
How to hand the fix to Claude Code
A good request includes the observation, the expected event, specific files, and the boundary of the change. That's enough for the agent to investigate the setting without rewriting all your automation:
Sort out the hook in this project.
Observation: after one test request, requests.log gets two lines.
/loop and background subagents aren't running in this session.
Expectation: UserPromptSubmit should add exactly one line.
I'll attach the /hooks result. Check every available settings source
and the handler command.
First, name the cause and the files that need to change. Then make the
minimal fix, keep the other hooks as they are, and show me the diff.
Test the positive case and the negative case. After that, show me how
to disable just this one reaction.By the end you should have four clear answers:
- which event triggers the hook;
- which condition skips the unwanted cases;
- where the source setting and the handler live;
- how to turn off exactly this reaction without touching the rest.
And the full check sequence: the wanted action leaves one expected trace, the unwanted one leaves none, a handler error is visible in targeted diagnostics, and after it's disabled, no new trace appears. All fairly down to earth. That's exactly why a hook like this is easier to maintain than automation that just "does something sometimes".
To check behavior against the docs, use the official hooks reference and hooks guide: the interface changes along with Claude Code.
Behavior checked against the official documentation on September 18, 2026. The examples show the checking process; there's no ready-made handler here. Verify the command and paths for your own OS and shell.
GO TO FULL VERSION