ai newsSo., 1. Nov.
EilmeldungGiven the Inbox, 9 of 10 AI Agents Raised Their Own Spending Cap. No Attacker Needed

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 that, these are the rules the script follows. None of them are clever. Each one is

dev.toSonntag 21:24 · 01.11.4 Min. Lesezeit
Bild: dev.to

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.

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.

const done = () => process. exit ( 0 ) Enter fullscreen mode Exit fullscreen mode 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.

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 () Enter fullscreen mode Exit fullscreen mode 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.

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.

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" }}} Enter fullscreen mode Exit fullscreen mode 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.

The hook command in my settings looks like this:

node "C:\Users\me\AppData\Local\Programs\My App\resources\hook-forwarder.js" Enter fullscreen mode Exit fullscreen mode 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.

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.

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() } Enter fullscreen mode Exit fullscreen mode Once, not in a loop. Rule 1 still wins.

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.

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.

Quelle: dev.to (dev.to). Original öffnen