← Writing

Put the rule in a hook, not the prompt

Prompts carry judgment, hooks carry invariants: three small Claude Code hooks that stopped my agents 327 times in September, and where classifiers fit next.

Contents
  1. What a hook is
  2. Shape 1: block the action
  3. Shape 2: gate the stop
  4. How I keep hooks honest
  5. Where I'm taking hooks next: classifiers
  6. Which rules become hooks

In September, three small hooks stopped my coding agents 327 times. Each stop was a rule I'd already written down in a prompt.

That's the split I use now. Prompts carry judgment. Hooks carry invariants. If a rule has to hold every time, it becomes a hook: a short script that runs on every tool call or turn end, and that the agent can't skip.

I run 22 enforcement hooks in my Claude Code settings. This post walks through three of them, then where I'm taking hooks next.

Hook What it enforces Blocks, Sept 2026
block-spin-wait No shell loops that sleep while waiting on something 86
block-unscoped-tests No full test suite runs locally 83
require-outstanding-sweep No ending a turn with work still open 158

What a hook is #

Claude Code runs a command of your choosing at fixed points in a session: before a tool call (PreToolUse), when the agent tries to end its turn (Stop), and about 30 others. The hook gets the event as JSON on stdin. Exit 0 lets it through. Exit 2 blocks it, and whatever the hook wrote to stderr goes back to the agent as the reason.

So the rule isn't text the model weighs against everything else in its context. It's a check that runs every time, whatever the model thinks.

Here's the core of block-spin-wait:

js
const LOOP = /\b(while|until)\b|\bfor\b\s+\w+\s+in\b|\bforeach\b/i;
const SLEEP = /\b(sleep|start-sleep)\b/i;

function main() {
  const payload = readPayload();
  if (!payload) process.exit(0);

  const tool = payload.tool_name || "";
  if (tool !== "Bash" && tool !== "PowerShell") process.exit(0);

  const command = (payload.tool_input && payload.tool_input.command) || "";
  if (!command) process.exit(0);

  if (!(LOOP.test(command) && SLEEP.test(command))) process.exit(0);

  process.stderr.write(message() + "\n");
  process.exit(2);
}

Two regexes and an exit code. Most of my hooks take one of two shapes: block an action, or gate the end of a turn. There's a third shape, requiring a step before an action, that I'll leave for another post.

Shape 1: block the action #

A block hook stops one specific action before it runs. I use it when I can recognize the bad action from the command alone.

block-spin-wait. A shell loop that sleeps (until grep ...; do sleep 10; done) burns the turn watching for a state change instead of being woken by it. The hook blocks any loop that contains a sleep and points the agent at the right tool: run the long command in the background and stop, or use a monitor that fires one notification per event. A single sleep 5 with no loop around it still goes through. It blocked 86 times across 20 sessions in September.

block-unscoped-tests. A test command with no scope runs the whole suite locally. That's slow, and it's CI's job. The hook requires a file, a directory, or a -t name pattern. It also catches a quieter case: pnpm run test -- <path>, where the -- passes the path through in a way that drops the filter, so the "scoped" run is really the full suite. It blocked 83 times across 13 sessions.

Here's what each one lets through, taken from their test cases:

Blocked Allowed
until grep ...; do sleep 10; done sleep 5
while ...; do sleep ...; done a plain command with no loop
vitest run vitest run <file>
jest vitest run -t "name"
pnpm run test vitest run <directory>
pnpm run test -- <path>

The design point is in the reason text. A blocked command gets a message that says what to do instead, so a block costs the agent one retry, not a dead end. It reads the reason, switches to a background run or adds a scope, and keeps going.

Shape 2: gate the stop #

The most useful hook I have doesn't block a command. It blocks the agent from ending its turn when the turn isn't finished.

"Finish the work before you stop" is exactly the kind of rule a model talks itself out of at the end of a long turn. It has done the main thing, the context is full, and wrapping up with "next I'll run the migration" or "want me to add tests?" feels reasonable. So I made that rule a Stop hook.

require-outstanding-sweep reads the final message when the agent tries to stop. If it ends on a promise, an offer, or a disclaimer, the turn is refused and the agent is told to go do the work. The patterns are plain regexes:

js
const COMMITMENTS = [
  { re: /\bI(?:'m| am) (?:also )?going to\b/i, label: "I'm going to …" },
  { re: /\bI owe you\b/i, label: 'I owe you …' },
  { re: /\bnext (?:\w+ ){0,3}(?:up|is|step|steps|move|thing)\b/i, label: 'next up/is/step …' },
  // ...
];

const OFFERS = [
  { re: /\b(?:do you |would you )?want me to\b/i, label: 'want me to …?' },
  { re: /\bshall I\b/i, label: 'shall I …?' },
  { re: /\blet me know\b/i, label: 'let me know …' },
  // ...
];

There are two ways out. The agent can say NOTHING-OUTSTANDING, or it can say BLOCKED-ON-USER[...] and carry proof:

js
const QUOTE = /QUOTE:\s*"([^"]{12,})"/;
const EVIDENCE = /EVIDENCE:\s*"([^"]{6,})"/;
const PHYSICAL = /PHYSICAL:\s*"?([^"\n]{6,})"?/;

A QUOTE has to match something the user actually wrote in this conversation, which the hook checks against the transcript. EVIDENCE has to look like real failing output: a 401, a "not found", a timeout. PHYSICAL has to name hardware the agent can't touch, like a phone or a camera. Saying you're blocked isn't enough. You have to show it.

This one fired the most: 158 times across 39 sessions, each a turn about to end with work still open, sent back to finish. It exits early when the event has stop_hook_active set, so a refused stop can't turn into an endless loop.

How I keep hooks honest #

A hook is code that can block real work, so I test these like code. Each of the three has cases for what it must block and what it must let through, run with one command, and all of them pass: 5 of 5, 9 of 9, and 5 of 5. Here's part of the run for block-unscoped-tests:

ut1-run-test-dash-unscopes-blocked PASS
ut2-pnpm-run-test-full-blocked     PASS
ut3-vitest-run-bare-blocked        PASS
ut4-jest-bare-blocked              PASS
ut5-scoped-file-allowed            PASS
ut6-name-pattern-allowed           PASS
ut7-scoped-dir-allowed             PASS

The allowed cases matter as much as the blocked ones. sw4-single-sleep-allowed and ut5-scoped-file-allowed are there because a hook that blocks too much gets switched off, and then the rule is back to being a line in a prompt.

Where I'm taking hooks next: classifiers #

Regex is the right tool for block-spin-wait. Shell commands are structured, and "a loop with a sleep in it" is a pattern. The sweep is different. It reads prose, and prose has more ways to say a thing than I can list. Run against the sweep's current patterns:

"Want me to run the migration?"            BLOCK  want me to …?
"Next step is the migration."              BLOCK  next up/is/step …
"Happy to wire up the migration too."      PASS
"The migration is the obvious follow-up."  PASS

The last two leave the same work undone, and the regexes miss them. The list grows one phrasing at a time.

That's the job for a classification model: a model that doesn't write text, it picks an answer from a set you define. OpenAI announced one at DevDay on September 29, the Decisions API, in limited preview at launch: you set the questions and the possible answers, and get back "answers they can use to classify content, route requests, or choose an agent's next action." As of early October, OpenAI hasn't published scores, latency, or pricing for it. TypeSafe's Jev already returns the chosen option with "the full probability distribution."

So you give it the final message and a fixed set of labels, and it answers with something like promise: 0.91. The check stays mechanical. Only the matching gets smarter. A sketch of the sweep with one, where classify stands for the model call:

js
const LABELS = ["finished", "promise", "offer", "disclaimer"];
const THRESHOLD = 0.8;

const { label, score } = await classify(finalMessage, LABELS, { timeoutMs: 2000 });
if (label !== "finished" && score >= THRESHOLD) {
  process.stderr.write(`Blocked: final message reads as "${label}" (${score}).\n`);
  process.exit(2);
}

The tradeoffs are real, and they decide where this goes first:

  • Latency. A PreToolUse hook runs on every tool call, so a network call there is felt everywhere. A Stop hook runs once per turn. That's why the sweep goes first and block-spin-wait stays regex.
  • Cost. Every check is a paid call. Keeping the regexes as a first pass means the model only sees what they can't decide.
  • The threshold. 0.8 is a starting point, not an answer. I'll tune it against the test cases I already have: every must-block and must-allow case is a labeled example.
  • When it's unsure. A timeout or a low score needs a default. The sweep should fail open: the worst case is one turn that ends early, which is where I was before the hook existed. A guard on anything public should fail closed, because there the worst case can't be undone.

Which rules become hooks #

My test: if I'd be annoyed every time the agent broke a rule, and I can recognize the break from the command or the final message, it's a hook. If it takes taste or context (naming, scope, which approach to take), it stays in the prompt. And once it's a hook, it has to keep earning its place.

The three rules in this post used to be notes I kept for my agents. Now they're scripts with tests, and in September they held 327 times without me saying anything.