flowingkhaosSign inBrowse the marketplace
New here? flowingkhaos is a build-in-public workshop. This is its weekly column.What this site is

Claude Code Hooks: Count Your Refusals Before You Write One

Luke Sidneyupdated 16 min read

Before writing Claude Code hooks, count your refusals: 48 refusals in 11 runs fell to about 1 per run with six allow rules.

Count Before You Hook: 48 Refusals in 11 Runs

Before you write any Claude Code hooks, count the times Claude got refused or handed a command back to you, because in our own 30 sessions that count was 48 refusals inside 11 unattended runs and 37 hand-offs, and six read-only allow rules plus one runner prompt line cut refusals from about 5 per run to about 1; the prompt line is not an allow rule. (That after figure reflects the allow rules and the runner prompt line together, a caveat I’ll repeat.) Let’s get one thing straight: the first thing I counted was not a hook. It was refusals, and there were 48 of them.

A tally sheet with grouped pencil strokes sits on a workbench beside a ruler, pencils and a cobalt-blue card box.

I went through the audit window, 2026-09-28 to 10-05, thirty sessions of our scheduled vault runs, and tallied what actually went wrong. The numbers were humbling. 48 permission refusals inside 11 unattended runs. 37 commands Claude handed back to me with the ! prefix so I could run them by hand, and 7 of those 37 died on the 120-second Bash timeout. Another 22 auto-mode refusals, the scary ones, merges and production deploys and deletes, quietly turned into hand-offs. On top of that: 26 context compactions, with a peak context of 782k tokens. And here’s the part that stings: about 25 working rules lived only in prose, CLAUDE.md and memory notes, while ~/.claude/settings.json had zero hooks in it.

Twenty-five prose rules doing nothing. I’ll be the first to admit I wrote most of them.

This is where Anthropic’s own framing actually helps. The extension-layer map lays out the whole menu: CLAUDE.md, skills, subagents, hooks, MCP, plugins. Hooks are one rung among several, not the first tool you reach for. The docs would have you add the CLAUDE.md first. I’d add the count before the file. And the best-practices write-up puts the distinction that matters in one line: CLAUDE.md instructions are advisory, hooks are deterministic. Which explains my 25 rules perfectly. Advisory means “please don’t refuse git log.” Deterministic means the refusal physically cannot happen. I had prose. All prose.

Refusals per run before and after rung 1
Before rung 1
5refusals/run
After rung 1
1refusals/run

Source: Our own measurements

The count said allow-list problem, not hook problem

Here’s the punchline I did not expect, and honestly I’d have liked a more exciting answer. When I actually looked at the 48 refused commands, they were read-only stuff. date. git log. git status. git diff. systemctl --user list-timers. My scheduled runner only allowed five tools, so it was refusing safe commands because I’d never let it run them. That’s an allow-list problem. Six allow rules and one line in the runner’s prompt later, refusals dropped from about 5 per run to about 1. Again, the after number is the rules plus the prompt line working together; I can’t separate their contributions and won’t pretend otherwise.

Is a hook the answer to everything? No. The biggest number fell without one. The rest of the ladder, and the four mods we built after it, is where the remaining sections go. But if you’re anything like me, you were about to skip straight to the cool part. Count first. The count decides whether you even need the cool part.

Climb the Ladder Before You Write a Hook

I did not start at the bottom of this ladder either. I wanted mods. TypeScript that draws panes and blocks tool calls? Extremely cool. Permission rules? Boring config JSON. But the ladder exists precisely because the boring rung is where 48 of my 48 refusals lived.

The ladder, in order, and you stop at the first rung that holds:

  1. A permission rule in settings.json. Cheapest fix, no code, instant.
  2. A fix to the skill or script that keeps making the mistake.
  3. A settings hook. Deterministic, but it’s still shell-on-an-event.
  4. A mod. The fun one. Earn it.

Rung 1: six rules beat everything

The evidence is embarrassing. The 48 refused commands in my 11 unattended runs were read-only: date, git log, git status, git diff, systemctl --user list-timers. My scheduled runner allowed five tools, so it refused safe commands because I’d never told it it could run them. 25 of the 48 were compound commands. The fix was six read-only allow rules (vault commit 63570fd, for the record) plus one line in the runner’s system prompt. Note the prompt line is not an allow rule; it’s a vibe. It worked anyway.

Measure Before After
Refusals per scheduled run about 5 about 1
Read-only commands refused 48 across 11 runs rules added for date, git log, git show, git status, git diff, systemctl --user list-timers

Two caveats before you copy those rules. The after figure in that table is the allow rules and the runner prompt line together; I can’t give you a per-fix split. And the 25 compound commands mirror the prompt-line change, so there’s no per-command after count either. I won’t pretend I have one.

And there’s a syntax trap that cost me actual refusals: many of my git log calls were written git -C <dir> log, which no git log * rule matches. Write your patterns with a space, Bash(git log *). The :* form means the same thing only at the very end of a rule; Bash(archon complete:*--force*) would match a literal colon.

Rung 2: the rule you wrote wrong

Sometimes the mistake is in the procedure, not the permissions. The skills docs make the point that a procedure living in CLAUDE.md belongs in a skill, loaded only when used. I agree, and I’ll go one further: rung 2 is where the fix lives when the skill itself is the bug. If the script Claude keeps running is wrong, no hook saves you from running it.

That pairs with the advisory-versus-deterministic split I hammered last section: my 25 prose rules were advisory. Rung 1 was deterministic. The choose-the-right-extension piece on Medium pitches hooks as “it handles it so you don’t have to remember,” and that’s right for a formatter. It’s the wrong pitch for a guard. Formatters fix. Guards refuse. Different jobs.

Which brings me to my seven deny rules: bunx tsc (it wipes node_modules in our repos), bunx eslint (installs the wrong major), pkill -f (kills its own shell, incredible), archon complete --force, and reads of three credential files. Here’s the gate that sold me on deny rules: a fresh claude -p with --allowedTools "Bash(bunx *)" was still refused bunx tsc --version. Deny beats allow. Every time. That’s the kind of guarantee prose could never give me.

Two Kinds of Hook: Settings Hooks Versus Mods

So you’ve counted, you’ve climbed the ladder, and the mistake survived rung one and rung two. Now you actually need a hook. Fine. But there are two kinds of hook in Claude Code, and picking the wrong one is the new mistake you’ll be making instead.

Version 2.1.287 shipped mods on 2026-10-01, and that’s the line that split the world in two. A settings hook is a shell command in your settings.json that fires before or after a tool call: PreToolUse, PostToolUse, PermissionRequest, Notification, Stop, UserPromptSubmit. It’s a process sitting outside Claude Code, waiting for its event. A function hook, or mod, is TypeScript that runs inside Claude Code, receives the event, can change it or block it, and can actually draw on screen. Panes. Bands. Toasts. Things you can see.

Pluto Security’s pre-release analysis put the contrast best: classic hooks run as separate processes and cannot change the interface. Your settings hook can refuse a tool call. It cannot show you why while you’re still looking at the terminal. If you’re anything like me, that distinction sounds like a nice-to-have until the first time a headless run refuses something at 3 AM and leaves you a JSON receipt to decipher.

Where the matcher bites and where the hook fires

The hooks guide covers the mechanics you need: matcher syntax like "Edit|Write" and "Bash(git log *)", a /hooks step to verify your config loaded, and a PermissionRequest example that allows with a single echo. Test the matcher before you trust it; the matcher is where most of the mistakes hide. As for that allow-with-one-echo example, it technically works, but I want a config like that to earn its place before I hand it the keys.

And here’s the detail that decides pre versus post, stolen happily from The Rundown’s tutorial: a failed post-edit check leaves the change saved, because PostToolUse runs after the tool succeeds. The file is already on disk. A pre-hook is the one that stops the thing. If you’re guarding against damage, you guard before, not after.

The order they run in, and two limits

On our setup, the execution order is: managed PreToolUse hooks first, then prepended plugins, then installed mods, then built-in mods, then settings hooks. Why does that matter? Because of what Pluto flagged: without managed settings, a mod’s approval can override your deny rules and skip the auto-mode classifier. The thing running first and the thing running last do not have equal power.

Two limits while you’re here. The best-practices post notes Claude Code overrides a Stop hook after 8 consecutive blocks, so a Stop hook alone is not a wall, it’s a delay. And if you’ve seen “hooks” in an enterprise pitch, check which kind: Agent SDK hooks are callbacks in the agent you built, while Anthropic-side inference hooks sit on the wire for Enterprise orgs. Different wire, different owner. Don’t confuse them, and don’t build your safety on either one solo.

Our Four Mods, Honest Status as of 2026-10-08

Here’s the section nobody’s tutorial will give you: the scoreboard. Four mods, built 2026-10-06 and 10-07, so about a week old at writing. One caught something. One has caught nothing yet. Two are on trial. I’m not going to dress any of that up.

Mod What it does What happened
prod-db-guard Holds a migration or schema change whose database host is on a production list; asks in a terminal, refuses in a headless run No real catch yet. Live headless probe from our CMS folder refused and named the host, never the URL [measured 2026-10-06]. 10 tests. Insurance against two past incidents (2026-08-12 and 2026-08-26) where unattended runs migrated production, which I’ve written up separately.
handed-commands Collects commands Claude hands over, blocks ones with placeholders, runs each only on a human press of Run First live press (2026-10-07): the command ran, the result row arrived, and Claude said nothing until I typed again. Fixed the same day by sending the result as a prompt. 8 tests. [observed once]
context-band A band above the prompt: context size, minutes until the prompt cache goes cold, amber at 250k tokens On trial; remove-check on or after 2026-10-20. 6 tests.
spend-band Shows image and video spend from the API answers the session printed Built after a fal bill of about $5.20 that our own estimate showed as $1.86. 6 tests. [measured from our logs]

The one that caught nothing, and the one that lied to me

prod-db-guard’s honest status: zero real catches. Its whole résumé is ten tests and one live headless probe that refused correctly, naming the host and never the URL. Am I disappointed? A little. But this mod exists because on 2026-08-12 and again on 2026-08-26, unattended runs migrated production. Twice. Insurance that never fires is doing its job; insurance that fires constantly is a nuisance you uninstall. Ask me again in a month.

handed-commands is my favorite war story from the week. First real press, the command ran, the result landed in the transcript, and Claude just… sat there. Nothing. Because a transcript row is read at the next request and starts no turn. The fix took a day: send the result as a prompt instead. But notice what that bug is. It’s not a logic error, it’s an interface assumption, and no test suite catches those. You find them when a human presses the button. Which is why I’m labeling this one [observed once] and not “proven.”

spend-band is the one that changed how I think about input data. The fal bill came in around $5.20 against an estimate of $1.86. The root cause: the pricing API answered for the cheapest tier, 360p at $0.03 per second, while our drafts actually ran at 720p at $0.10 per second. Here’s the trap: a band that just displayed the estimate would have repeated the wrong number, prettily, forever. The bug was the estimate. I fixed the estimate first, then built the band.

context-band gets a one-liner and a deadline. Context size, minutes to cache-cold, amber at 250k tokens, six tests, and a scheduled remove-check on or after 2026-10-20. If it hasn’t earned its pixel by then, it goes.

One warning before you install anything from anyone else. In the mods-versus-hooks breakdown, the presenter demos a force-push guard that rewrites the command and scrubs personal data from what a mod shows you, not what the model works with. Clean demo. But remember the security point from last section: a mod’s function-hook capability can be hidden from the install screen, and it runs with your rights. Ours are four mods we wrote, with ten, eight, six, and six tests respectively, and each mod is gated by claude plugin validate and claude plugin test before it loads, and a failing mod is disabled on the next install run. Yours should too.

Security Facts: Claude Code Hooks and the Fail-Closed Rule

Here’s the part the tutorials skip: mods are not sandboxed. They run with your rights. Yours, the person typing the sudo password and pushing to main. I built four mods last week while knowing that, and the rules I set afterward are the whole reason I’m comfortable loading them at all.

The Pluto findings, and what runs as you

The scariest finding comes from Pluto Security’s pre-release analysis, which found that without managed settings, meaning Team or Enterprise managed policy, a mod’s approval can override your deny rules and skip the auto-mode classifier entirely. Read that again. Your carefully written archon complete --force deny rule can be undone by a plugin’s approval, on the exact calls where one mistake reaches production. The same analysis flagged that a mod’s function-hook capability can be hidden from the install screen, so a plugin can look harmless in the inspection UI and still be packing function hooks. That second part is what got me. Visibility was the entire value proposition of inspecting a plugin before you trust it, and apparently even that’s negotiable.

Wavect’s model router write-up lands on the same warning from a different angle. Their piece documents their own evaluation configuration, separate from upstream defaults, and points out that command hooks run with your account permissions while a PreToolUse hook can rewrite tool inputs before they ever execute. So one source says your plugin can hide what it’s allowed to do, another says the thing it’s allowed to do runs as you and can edit inputs mid-flight. Two independent sources, one conclusion. The thing you installed is you. I’ll be honest, I sat with that for a minute before touching anything.

Our rules, not a recommendation

I’m not going to pretend there’s a general fix, because there isn’t one as far as I can tell. What I can give you is our configuration, which is ours, not a recommendation for yours:

  • Only mods we wrote load. Nothing third-party, no marketplace installs, full stop.
  • Every gating hook fails closed. One .catch that refuses whenever the hook throws. A broken guard refuses, it never waves things through.
  • No hook returns “allow.” A mod may hold, ask, or refuse. Only a human approves. On our setup, that’s not paranoia, it’s arithmetic: Pluto showed that a mod’s approval overrides deny rules when managed settings are absent, so an auto-approver would be the single point where a mistake reaches production.
  • Headless has no one to ask, so a guard that would ask refuses instead. prod-db-guard’s live probe on 2026-10-06 did exactly that: refused and named the host, never the URL.

Prove your fail-closed rule actually bites

A rule you never test is a vibe, and I’ve written enough vibes for one career. So here’s the proof, measured on 2026-10-08: I deleted the guard’s .catch, and its test failed, and claude plugin validate reported hasCatch: false. Deleting the safety net produces a visible failure. That’s the whole design. If you can’t break your guard and see the break, you don’t know if it holds.

Every mod passes the same gate before it loads, and this gate is mechanical, not vibes:

  1. claude plugin validate
  2. claude plugin test, which fails any mod that has no tests at all
  3. A type-check
  4. A check that no hook returns allow
  5. A check that every gating hook has its .catch

A mod that fails any of those gets disabled on the next install run. No judgment call, no “it’s probably fine.” The gate is the deterministic version of the promise my 25 prose rules could never keep, and honestly, that’s the entire argument of this piece in miniature. Count first, climb the ladder, and whatever guard you end up with, make breaking it a test failure, not a discovery.

What We Decided Not to Build, and Your Next Count

There’s one mod I never built, and honestly, deciding not to build it was the most senior-engineer move of the whole week.

Picture the pitch. Twenty-two auto-mode refusals across my audit window, all of them merges, production deploys, and deletes that turned into hand-offs. Every single one cost me a press of a button. The obvious “efficiency” play is an auto-approver: a mod that sees a blocked call, approves it, and lets the run keep going while I’m asleep. Would save maybe twenty-two button presses a week.

Here’s why it doesn’t get built. On our setup, without managed settings, a mod’s approval overrides deny rules and skips the auto-mode classifier. That’s not my paranoia talking, that’s the Pluto finding from last section, and it’s a genuinely unsettling one. So an auto-approver wouldn’t just approve the twenty-two safe-ish calls I imagined. It would approve everything, including the calls my seven deny rules exist to stop: archon complete --force, reads of credential files, the whole list. The one tool that would sit on exactly the calls where a single mistake reaches production is the tool I’d be handing blanket approval to. That’s not automation. That’s wiring a “yes” directly into the blast radius.

And my rule is simple: no hook returns “allow.” A mod may hold, ask, or refuse. Only a human approves. Which brings me to handed-commands, and why I’m keeping the friction it adds. Yes, I press Run. Once. Per command. The hand-offs that actually need me, 37 of them that week, still get me. The 7 that died on the 120-second timeout get a script fix on rung two, not a button that removes me from the loop. One press is the right amount of friction when the alternative is zero presses between Claude and production.

The ladder: four rungs, stop at the first that holds
  1. Rung 1Permission ruleAllow or deny in settings.json, no code.
  2. Rung 2Fix skill or scriptRepair the procedure that keeps making the mistake.
  3. Rung 3Settings hookA shell command that fires on a tool event.
  4. Rung 4ModTypeScript inside Claude Code, earned last.

Your count, your ladder, your one press

So here’s your homework, and I mean it as homework, not a vibe. Before you write a single hook, open your own transcripts and count two numbers: how many times Claude got refused, and how many times it handed a command back to you. That’s it. Those two numbers decide everything. If they’re mostly read-only commands getting refused, you have an allow-list problem and six rules will beat any hook you’d write. If they’re dangerous calls getting through, the ladder gives you the order: permission rule, fix the script, settings hook, mod. Stop at the first rung that holds. My biggest number died on rung one. The mods only exist because four smaller problems refused to die on rungs one through three. If you’d rather see the whole build done step by step, that’s where it lives.

Two hedges before you go, because I owe you honesty over marketing copy. My four mods are about a week old at writing. prod-db-guard has caught nothing yet, handed-commands has one observed press, and every “it works” in this article is a week’s worth of evidence wearing a confident font. Treat them accordingly.

Count first. Climb the ladder. Then, and only then, write the hook. And when your guard breaks, make sure breaking it shows up as a test failure, not a 3 AM bill.

Sources

Questions, answered

Should I write a hook first when Claude Code keeps refusing commands?
No. The article found that 48 refusals across 11 unattended runs were mostly read-only commands like `date` and `git log`, an allow-list problem. Six read-only allow rules plus a runner prompt line cut refusals from about 5 per run to about 1, with no hook needed.
What is the difference between a settings hook and a mod in Claude Code?
A settings hook is a shell command in settings.json that fires before or after a tool call, running outside Claude Code. A mod (function hook, shipped in v2.1.287 on 2026-10-01) is TypeScript running inside Claude Code that can change or block events and draw visible UI like panes and toasts.
Are Claude Code mods safe to run?
Mods are not sandboxed and run with your account permissions. Pluto Security found that without managed settings, a mod's approval can override deny rules and skip the auto-mode classifier, and a mod's function-hook capability can be hidden from the install screen. The article's own rule: only load mods you wrote, every gating hook fails closed, and no hook returns "allow".
Does a PreToolUse or PostToolUse hook stop a bad edit?
Only a pre-hook stops it. PostToolUse runs after the tool succeeds, so a failed post-edit check leaves the change already saved on disk. Guard before, not after.
Can a Stop hook block Claude indefinitely?
No. Claude Code overrides a Stop hook after 8 consecutive blocks, so a Stop hook is a delay, not a wall.

Keep reading

The column · weekly · free

The next piece, the morning it is published.

One written column a week. The email service holds the list.

Luke Sidney writes and builds everything here, alone, in the open. lukesidney.me