# Claude Code Hooks: The Measured Ladder Before You Build

By Luke Sidney (https://lukesidney.me) · 9 Oct 2026 · updated 16 Oct 2026
Source: https://flowingkhaos.com/column/claude-code-hooks-measured-ladder

> A 30-session audit found 48 refusals and zero hooks. Six allow rules cut refusals from 5 per run to 1, and the one catcher mod built has caught nothing yet.

## Count Your Refusals First

Six read-only allow rules and one line in my runner's prompt took my scheduled Claude Code runs from about 5 refusals per run to 1, and of the four mods I built, the one meant to catch mistakes (prod-db-guard) has not caught a real one yet. If you're weighing Claude Code hooks against permission rules and mods, that number is where the decision starts.

Yes, that's the answer, and it's deliberately unflattering. If you came here for a copy-paste config dump, every other result on page one will happily oblige. I'm going to ask you to do something boring first: count.

Here's how I got here. Before I wrote a single hook, I audited 30 sessions, from 2026-09-28 to 10-05, and logged what Claude actually refused. My `~/.claude/settings.json` had zero hooks the entire week. That's the part nobody admits.

So here's the ladder, in order: permission rule, fix the skill or script that makes the mistake, settings hook, mod. Stop at the first rung that holds. In my setup, rung 1 did most of the work. The rest of this article is what the top rung actually caught, and, more usefully, what it didn't.

## What the Audit Actually Found: 30 Sessions, 48 Refusals, Zero Hooks

Let me be upfront about something that should embarrass me: I spent a week logging refusals before I wrote a single hook, and the loudest finding was that I had not earned the right to write one. My `~/.claude/settings.json` was empty the entire audit. Not sparse. Empty.

The setup: I audited 30 sessions of my own transcripts, 2026-09-28 through 10-05. Not a vibe check. I went through the actual logs and counted what Claude refused, what it handed back to me, and how much context it burned doing it. Here's the raw record.

| Metric | Count | Source within my record |
|---|---|---|
| Permission refusals | 48 | 11 scheduled unattended vault runs |
| Commands handed off via `!` | 37 | Same runs; 7 hit the 120-second Bash timeout |
| Auto-mode refusals turned into hand-offs | 22 | Merges, production deploys, deletes |
| Context compactions | 26 | Peak context hit 782k tokens |
| Working rules living only in prose | ~25 | All in CLAUDE.md and memory notes |
| Hooks in `~/.claude/settings.json` | **0** | The file had nothing in it |

Twenty-five rules in prose. Zero in config. That's the whole disease in one row, honestly. I'd been writing paragraphs at Claude asking it to please behave, and it kept refusing read-only commands like `date` and `git log` because my scheduled runner only had five tools allowed. Was it Claude's fault? No. Was it a hook problem yet? Also no.

Before we go further, definitions, because the config-list articles blur them. A **settings hook** is a shell command sitting in settings.json that fires on a lifecycle event, `PreToolUse` or `PostToolUse`, meaning before or after a tool call runs. A **mod** (function hook) is TypeScript that receives the event itself, can change it, block it, or draw on your screen. Mods only shipped in Claude Code 2.1.287 on 2026-10-01, which is why half the tutorials you'll find don't mention them. One more wrinkle the tutorials skip: hooks fire in a set order, managed first, then prepended plugins, then installed mods, then built-in mods, then your settings hooks last. Order matters when two things want to veto the same call.

Look at that table again. The 48 refusals were mostly the machine asking permission to *look at things*. The 22 auto-mode refusals were the dangerous ones, and they correctly became hand-offs instead of actions. Nothing in that week was asking for a mod. I could be completely wrong here, and maybe your setup genuinely starts at rung 4, but mine started at rung 1, and I only found that out by counting. Do your week first. Then come back and we'll talk about what I actually built.

## The Ladder: Where Claude Code Hooks Sit

Here's the ladder, formally, because I want it burned into your brain before you open a mod template.

| Rung | What it is | What it fixed in my setup |
|---|---|---|
| 1 | Permission rule in settings.json | The 48 refusals. About 5 per run down to 1 |
| 2 | Fix the skill or script making the mistake | Nothing confirmed in my setup |
| 3 | Settings hook | Nothing, in my case. I skipped it |
| 4 | Mod | The hand-offs and the prod-database danger, not the refusals |

The rule that governs the whole thing: stop at the first rung that holds. Not "start at rung 4 because mods are new and shiny." Stop at the first rung that holds. Those are opposite strategies and only one of them respects your time.

Rung 1 did most of my work, and I'll keep saying that until the listicle format dies. Six read-only allow rules, one line in my runner's prompt. Done. The framing I keep bumping into is [hidekazu-konishi's complete hooks guide](https://hidekazu-konishi.com/entry/claude_code_hooks_complete_guide.html), which lays out the whole lifecycle-event model and argues that a system prompt is a request while a hook is a guarantee. I agree with the sentiment while pushing back on the implication. A permission rule is also a guarantee. It's deterministic, it's two lines of JSON, and in my setup it fixed the biggest number on the board. A guarantee that costs a few lines of JSON beats a guarantee that costs TypeScript.

Now, the uncomfortable confession: my path skipped rung 3 entirely. My settings.json had zero hooks during the audit week and I added zero settings hooks after it. I went from permission rules straight to mods, because the problems that survived rung 1 (commands Claude handed back to me with a `!`, unattended runs near production databases) needed things a shell one-liner in settings.json doesn't do well: ask a human, draw a band, hold a call. That's not an argument that rung 3 is useless. It's an argument that you don't get to know which rung you need until you've counted.

And here's where I differ from my favorite quote-cousin. Blake Crosley's writeup on [why each of his 95 hooks exists](https://blakecrosley.com/blog/claude-code-hooks) is the closest thing to my posture on page one: every hook exists because something went wrong first, and he built all 95 that way. I love that. But notice what he counts. Hooks. Ninety-five of them. I count refusals, and so far my catch count is zero. Those are different ledgers, and the second one is the only one that tells you whether your hook is doing its job or just existing, vibes-based, in your config.

So climb in order. Count at every rung. If rung 2 holds, you fixed a bug instead of building a tripwire around it, and honestly? That's the better outcome.

## Rung 1 Did the Heavy Lifting: 13 Permission Rules

Here's the part that should genuinely annoy you. The single biggest improvement in my whole setup was six read-only allow rules for the scheduled runner, seven deny rules, and one sentence in a prompt. No TypeScript. No lifecycle events. No mods. Written on 2026-10-06, they took my scheduled runs from about 5 refusals per run to 1. That's it. That's the rung 1 story, and I'm telling it first because the config-dump articles bury it under their hook templates.

The six allow rules are embarrassingly boring: five of them are read-only covers for `date`, `git log`, `git status`, `git diff`, and `systemctl --user list-timers`, plus `git show`. Separately, and not in settings.json at all, I added one line to the runner's system prompt saying the runner commits and pushes. The prompt line plus the six read-only allows together is what moved the number. The allow side is pure "let the machine look at things without asking me" energy, because that's what 48 refusals mostly were. My unattended runner asking permission to run `date`. Repeatedly. Nobody around to say yes.

Two gotchas on the allow side that no tutorial mentions. First, write `Bash(git log *)` *with the space*. The docs say `:*` means the same thing only at the very end of a rule, so if I'd written `Bash(archon complete:*--force*)` I'd have been matching a literal colon, not `--force`. The docs example uses `:*` and my one-liner doesn't, and a config dump article has no reason to notice the difference. I'm noticing it for you. Second: 25 of the 48 refused commands were compound, and many were written `git -C <dir> log`, which no `git log *` rule matches, so those commands still refuse. The ladder is not a clean funnel. Fine. Count anyway.

Now the deny side, and this is where I get opinionated: these seven rules aren't "general safety hygiene." Each one prevents a specific failure. `bunx tsc`, because running `tsc` through bunx wipes `node_modules` in my repos. `bunx eslint`, because it installs the wrong major version. `pkill -f`, because it happily matches its own shell and kills it. `archon complete --force`, the force-flag refusals. And reads of three credential files, which should not require explanation.

And the gate, because this is the detail that separates a rule from a vibe: deny beats allow. I tested it in a fresh `claude -p` headless run with `--allowedTools "Bash(bunx *)"` explicitly granted, and it still refused `bunx tsc --version`. The deny list wins. That's the guarantee. [hidekazu-konishi's guide](https://hidekazu-konishi.com/entry/claude_code_hooks_complete_guide.html) frames hooks as deterministic enforcement, and the deny rule is the most deterministic thing in this entire stack: it fired exactly when it should, against a tool I had just explicitly allowed. Six allow rules and seven deny rules. Rung 1. Most of your refusal volume is probably sitting there too.

## The Four Mods I Shipped: Honest Status as of 2026-10-08

Okay. Deep breath. Time for me to present my four mods, aged about one week, with the same energy I'd use presenting a first draft. Because that's what these are. Mods only shipped in Claude Code 2.1.287 on 2026-10-01 (the [therundown mods tutorial](https://app.therundown.ai/guides/claude-code-mods-tutorial) requires v2.1.287+ if you want to follow along, which is a handy date-stamp for how new this surface is), and I built all four of mine on 2026-10-06 and 10-07. I am not your guru. I am a person whose catcher has caught nothing.

Here's the honest status table, labels and all.

| Mod | What it does | What happened | Status |
|---|---|---|---|
| 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 my CMS folder refused and named the host, never the URL (2026-10-06, 10 tests) | Catcher, not yet triggered |
| handed-commands | Collects commands Claude hands me with `!`, blocks ones with placeholders, runs each only on a human press of Run | First live press 2026-10-07: command ran, result row arrived, Claude said nothing until I typed again. Fixed same day by sending the result as a prompt | Hand-off runner, observed once |
| context-band | Band above the prompt: context size, minutes until the prompt cache goes cold, amber at 250k tokens | On trial; remove-check is on or after 2026-10-20 | On trial |
| spend-band | Shows image and video spend from the API answers the session printed | Built after a fal bill of about $5.20 my own estimate showed as $1.86 | Spend display |

Let's read it together, because every other article would call all four of these "hooks that save me hours." Only one is a catcher. Only prod-db-guard exists to stop a mistake, and it hasn't stopped a real one yet. It's insurance against two specific past incidents, 2026-08-12 and 2026-08-26, when unattended runs migrated production, and its 10 tests pass. That's the whole claim. I refuse to inflate it.

handed-commands is my favorite despite (because of?) its embarrassing debut: Claude read the transcript row at the next request and started no turn, so it just... sat there. Silent. Am I being too harsh on a mod I wrote on 2026-10-07? Probably. But the fix is the lesson: the result had to come back as a prompt, not a transcript row.

And spend-band gets my most self-aware footnote, because the $5.20 bill versus $1.86 estimate? The pricing API answered the cheapest tier, 360p at $0.03/s, while my drafts ran at 720p at $0.10/s. A band reading the estimate would have shown the same wrong number. The bug was the estimate. The mod just displays the truth that was already in the logs. Build your instrumentation, then audit your instrumentation. In that order. Always.

## Before a Mod Loads: The Gate, the Sandbox, and What I Chose Not to Build

Here's the part the four-mod table doesn't show you: what each mod had to survive before it could load at all. Because mods are not sandboxed, and I do not use that word casually. A mod runs with my user's rights, and a process a mod starts runs outside Claude Code's sandbox entirely. The [orbilontech mods guide](https://orbilontech.com/claude-code-mods) makes exactly this point, that a mod can approve a tool call one of your own PreToolUse hooks blocked, and the Claude Code docs state the same thing plainly: without managed settings (Team or Enterprise plans), a mod's approval overrides deny rules and skips the auto-mode classifier. Read that again. The thing you built to protect you can legally override the thing you configured to protect you.

So I gate everything. Every mod passes five checks before it loads, and a failing mod is disabled on the next install run.

| Check | What it catches |
|---|---|
| `claude plugin validate` | Schema errors |
| `claude plugin test` | A mod with no tests at all |
| Type-check | Type errors |
| No hook returns "allow" | A mod that self-approves |
| Every gating hook has its `.catch` | A hook that throws and fails open |

That last row is my favorite, because I proved it bites. On 2026-10-08 I deleted prod-db-guard's `.catch` and watched its test fail and `claude plugin validate` report `hasCatch: false`. My rule is that every gating hook fails closed: one `.catch` that refuses when the hook throws, no hook ever returns "allow," a mod may hold, ask, or refuse, and only a human approves. Headless has no one to ask, so a guard that would ask refuses. Fail open in an unattended run is just an outage with extra steps.

One more reason I trust my own measurements over tutorial wisdom: the hex/claude-sessions [hooks.md](https://github.com/hex/claude-sessions/blob/main/docs/hooks.md) is an external hook-authoring post that bothers measuring its own behavior, and their findings are humbling. Their headline example: a timer started before a `/clear` keeps firing after one, which means event handlers survive context wipes and keep doing things you can no longer see. That's their measurement, not mine, but it's exactly why I want my gating hooks wrapped in `.catch` blocks that refuse rather than assume. If a timer can outlive a `/clear`, a hook that throws can outlive your guardrails. It's the genre I want more of.

And the negative space. I decided not to build an auto-approver for those 22 refused merges, deploys, and deletes. On my setup, its approval would override my deny rules on exactly the calls where one mistake reaches production. So the Run button stays. One press of friction, every time, on purpose. Am I leaving efficiency on the table? Yes. Knowingly. Gladly.

## Sources

- [Claude Code Hooks: Why Each of My 95 Hooks Exists](https://blakecrosley.com/blog/claude-code-hooks)
- [Claude Code Hooks Complete Guide - Deterministic Enforcement Across the Tool Lifecycle | hidekazu-konishi.com](https://hidekazu-konishi.com/entry/claude_code_hooks_complete_guide.html)
- [Claude Code Mods: What They Reach and What to Install](https://orbilontech.com/claude-code-mods)
- [Claude Code Mods Tutorial: Create a File-Review Check](https://app.therundown.ai/guides/claude-code-mods-tutorial)
- [claude-sessions/docs/hooks.md at main · hex/claude-sessions · GitHub](https://github.com/hex/claude-sessions/blob/main/docs/hooks.md)

## Questions, answered

### What is the ladder for deciding when to use Claude Code hooks?

The ladder is: permission rule first, then fixing the skill or script making the mistake, then a settings hook, then a mod. Stop at the first rung that holds.

### What did the audit of 30 sessions find?

Across 30 sessions from 2026-09-28 to 10-05 there were 48 permission refusals, 37 commands handed off with `!`, 22 auto-mode refusals turned into hand-offs, 26 context compactions, about 25 rules in prose, and zero hooks in settings.json.

### How much did permission rules reduce refusals?

Six read-only allow rules plus one line in the runner's prompt took scheduled runs from about 5 refusals per run to 1. Seven deny rules were also added, and deny beats allow when both match.

### What are mods in Claude Code?

Mods are function hooks in TypeScript that receive the event itself and can change it, block it, or draw on screen. They shipped in Claude Code 2.1.287 on 2026-10-01, and they run unsandboxed, so a mod's approval can override deny rules without managed settings.

### Has the prod-db-guard mod caught a real mistake?

No. It passes its 10 tests, and a live headless probe from the CMS folder refused and named the database host, but it has not caught a real production mistake yet.
