8 rules for Claude Code hooks that never get in Claude's way
My app runs one small Node script on every hook event Claude Code fires. Eleven events, every session, all day. If that script is slow, Claude is slow. If it crashes the wrong way, Claude stops. After a few weeks of tha
My app runs one small Node script on every hook event Claude Code fires. Eleven events, every session, all day. If that script is slow, Claude is slow. If it crashes the wrong way, Claude stops.
After a few weeks of that, these are the rules the script follows. None of them are clever. Each one is there because I broke it once.
1. Exit 0. Always.
Claude Code reads your exit code. Exit 2 blocks the action and hands your stderr to Claude as feedback. Any other non-zero code shows up as a hook error. Neither is what you want when your JSON parse fails or your server is down.
So the script has exactly one way out:
const done = () => process.exit(0)
Missing environment variable: done(). Broken JSON on stdin: done(). Connection refused: done(). The only time a hook should change what Claude does is when you decided it should.
2. Bring your own deadline
Claude Code waits for your hook before it moves on, and the default timeout is generous. Don't rely on it. Set a short timeout in the hook config (I use 5 seconds) and add a hard stop inside the script:
let guard = setTimeout(done, 1500)
guard.unref()
unref() matters: the timer never keeps the process alive on its own, it only cuts it off. The HTTP request to my app gets its own 1.2 s timeout on top. If the app is frozen, the session doesn't notice.
3. stdout is not a log
For some events, whatever you print goes straight into Claude's context. On SessionStart, that's a feature. A stray console.log("got event") there is a few tokens of noise in every session, forever.
For others, stdout is read as a decision. So print only when you mean it. My script forwards the event and writes to stdout only if the app answers with a body, which it does in exactly two cases: context at session start and a permission decision. Nothing else ever reaches stdout.
4. PermissionRequest is the one hook that should wait
Every rule above says "be quick". PermissionRequest is the exception, and it's the most useful hook there is.
It fires the moment Claude shows a permission prompt, and you can answer it from the hook. Keep the hook open until a human decides somewhere else (a card in an app, a phone), then print:
{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}
A deny can carry a message, and Claude reads it as the reason. Printing nothing means "no decision": Claude falls back to the normal prompt in the terminal, so the worst case is the behaviour you had without the hook.
For this one event I set the hook timeout to 600 s and the script's own deadline to 595 s, so the script always gives up first and cleanly.
Side note: the Notification hook also tells you Claude is waiting, but the permission notification only arrives after a short idle delay. For "Claude needs me right now", use PermissionRequest.
5. On Windows, quote the path, not the program
The hook command in my settings looks like this:
node "C:\Users\me\AppData\Local\Programs\My App\resources\hook-forwarder.js"
The program is unquoted, the path is quoted. That one line runs the same in cmd, Git Bash and PowerShell. Quote the program too and PowerShell reads the first token as a string instead of a command. Install paths on Windows love spaces, so this is not hypothetical.
6. No dependencies, no bundler
The whole forwarder is about 85 lines of plain CommonJS. The only import is http. It runs with the system node, straight from the app's resources folder.
Startup time is paid on every single event. A bundled script that pulls in a framework to send one POST request is a few hundred milliseconds you pay hundreds of times a day.
7. Survive your own restarts
Claude sessions outlive the app that started them. When my app updates and restarts, the port and token in each session's environment are stale.
So when the server answers 403, the script rereads a small endpoint file that the app rewrites on every start, and retries exactly once:
if (res.statusCode === 403) {
res.resume()
return again() // reread endpoint.json, try once more, else done()
}
Once, not in a loop. Rule 1 still wins.
8. Stop hooks: check stop_hook_active
A Stop hook can answer {"decision":"block","reason":"..."} and Claude keeps going with your reason as its next instruction. I use that once per turn: if Claude changed files but didn't write down what it learned, it gets a short nudge to do that before it finishes.
The input then has stop_hook_active: true. If you see that, never block again. Otherwise you've built a loop where Claude finishes, gets sent back, finishes, gets sent back.
The short version
- exit 0, every path
- a hard deadline inside the script
- stdout only when you mean it
-
PermissionRequestwaits, everything else hurries - quote the path, not the program
- no dependencies
- reread your endpoint once after a restart
- respect
stop_hook_active
All of this comes from VibeDesk, the free, open-source Windows desktop I build for running several Claude Code sessions side by side. The forwarder is a single file if you want to copy it: hook-forwarder.js.
What's the worst hook bug you've run into? I'm collecting them.
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.