You've written it a hundred times. In your CLAUDE.md, in your system prompt, in ALL CAPS:
NEVER put
"use client"at the page level. NEVER commit@ts-ignorewithout a reason.
And your agent does it anyway. Not always — that would almost be easier to deal with. It follows the rule for the first 50k tokens, then quietly stops. Or Sonnet follows it and Haiku doesn't. Or it follows nine rules and forgets the tenth.
Here's the thing I finally accepted: a rule in a prompt is a request. The model can decline it. So I stopped asking, and started enforcing.
TL;DR
- Prompt adherence is probabilistic. It degrades with context length and with model size.
- But half of my coding rules never needed a model at all — they're grep-able.
- Claude Code hooks +
exit 2turn those rules into a deterministic reviewer that runs after every single edit, costs zero tokens when nothing is wrong, and fires at 100% regardless of which model wrote the code. - Once the mechanical rules are enforced from below, you can safely downgrade the model doing the typing. That's the real payoff.
- Everything below ships in ccteams v0.3.0, but the pattern takes 30 minutes to build yourself.
Two kinds of rules
Some background in three lines: I run Claude Code with orchestrated agent teams — a builder writes code, a reviewer verifies it, and both get a stack-specific "playbook" of rules distilled from the mistakes mid-tier models actually make. It works well. I wrote about the prompt-engineering side of it before.
But rereading my playbooks, I noticed the rules split cleanly into two categories.
Rules that need judgment:
Trace the Server/Client boundary by hand.
Don't write a fix until you can state the root cause.
These need a model. Prompts are the right place for them.
Rules that are just string matching:
"use client"at the top ofapp/**/page.tsx→ wrong.
process.env.SECRETin a client file → wrong.
@ts-ignorewith no justification → wrong.
Why was I asking a language model to remember these? A regex doesn't get tired at 200k tokens. A regex doesn't perform worse on a smaller model. I was running deterministic checks on the most expensive, least reliable runtime available.
The mechanism: exit code 2
Claude Code has hooks — commands that run on lifecycle events. The one you want is PostToolUse: it fires every time the agent runs Edit or Write, and it receives a JSON payload on stdin telling you which file was touched.
The magic is in the exit-code contract:
- exit 0 → nothing happens, the agent keeps working
- exit 2 → whatever your script wrote to stderr gets injected back into the agent's context as feedback on that tool call
No human in the loop. No approval dialog. The agent edits a file, and — from its point of view — the edit "responds" with a code review. It reads the feedback and fixes the problem in the same turn, before a human or a reviewer agent ever sees the mistake.
A minimal version is embarrassingly simple:
#!/usr/bin/env node
// .claude/hooks/check.mjs — wired to PostToolUse in .claude/settings.json
const input = JSON.parse(await readStdin());
const file = input.tool_input?.file_path;
if (!file?.endsWith(".tsx")) process.exit(0);
const src = fs.readFileSync(file, "utf8");
if (/^\s*['"]use client['"]/.test(src) && /\/app\/.*page\.tsx$/.test(file)) {
process.stderr.write(
"route-level \"use client\": this makes the whole page render client-side. " +
"Push it down to the smallest interactive leaf component."
);
process.exit(2); // ← this line is the entire trick
}
That's a code reviewer that never sleeps, never gets context-drunk, and works for free.
What I shipped
In ccteams v0.3.0, every stack-specific team now bundles a check script built from its playbook's known failure patterns:
| Team | Checks (excerpt) |
|---|---|
next-ts |
route-level "use client", non-NEXT_PUBLIC_ env in client files, useEffect+fetch for initial data, fetch() without explicit cache intent, @ts-ignore/as any
|
go-api |
http.Error not followed by return, wrapping errors with %v instead of %w, errors discarded with _, context.Background() mid-request |
python-fastapi |
bare except:, Pydantic v1 API, mutable default args, time.sleep/requests.* inside async code |
rails |
SQL interpolation in where, update_column/save(validate: false), default_scope, params mass-assignment, Time.now
|
django |
naive datetime.now(), fields = '__all__', injection-prone .raw()/.extra(), post_save signals |
react-native |
.map inside ScrollView, index as key, DOM APIs like localStorage, unconditional behavior="padding"
|
frontend |
onClick on a <div>, <img> without alt, outline: none with no :focus-visible, z-index escalation |
When an agent writes a violation, it gets this back instantly:
ccteams next-ts check — app/dashboard/page.tsx:
- route-level "use client": this page and its entire import tree now render
client-side. Push "use client" down to the smallest interactive leaf component.
- client file reads process.env.API_SECRET: non-NEXT_PUBLIC_ env vars are
undefined in the browser (or a secret leak if inlined).
Fix these now, or state in your report why each is intentional.
Note the escape hatch in the last line. These are nudges, not walls — the edit already happened, and sometimes useEffect + fetch is legitimate. The agent can push back with a reason, and the reviewer checks that it did one or the other.
Why this beats prompt rules
1. It fires at 100%. A prompt rule needs to be read, retained, and recalled at the right moment. A hook is a grep. Token 500k? Fires. Haiku wrote the code? Fires.
2. You pay per violation, not per instruction. Prompt rules cost tokens on every delegation even when they're followed. A hook costs zero tokens until something is actually wrong — then it costs three lines.
3. Mistakes die before the review round-trip. Builder writes bug → reviewer catches it → sends it back → builder fixes it: that loop is the expensive part of multi-agent setups. Hooks kill the mistake at write time, so your reviewer spends its (expensive) tokens on things that actually need judgment.
The payoff: you can downgrade the model
This is the part I didn't expect to matter so much.
ccteams agents ship in two tiers — builders on Sonnet, reviewers on Opus. v0.3.0 adds model profiles:
ccteams use next-ts --profile budget # builder: haiku / reviewer: sonnet
ccteams use next-ts # builder: sonnet / reviewer: opus
ccteams use next-ts --profile max # everyone: opus
Getting Haiku to remember a playbook through prompts alone is a losing game. But hooks fire on Haiku's code with exactly the same precision as on Opus's. With deterministic checks backing it from below, --profile budget stops being "cheap and broken" and becomes a legitimate configuration for routine work.
The more discipline you mechanize, the less intelligence you need to rent.
The boundary (don't hook everything)
Hooks only cover rules that are mechanically checkable without false positives. "Trace the data flow," "run the actual build and quote its output," "state the root cause before fixing" — those stay in prompts and reviewer gates, and I have no intention of moving them.
The rule of thumb I've landed on:
If a violation can be detected with grep, enforce it with a hook. If it needs judgment, put it in the prompt.
One design note if you build this yourself: make your hook scripts fail silent (catch → exit 0). A buggy check that crashes loudly will poison every editing session. And namespace your hook entries (ours all live at .claude/hooks/ccteams-*) so that installing/removing them never touches hooks the user wrote themselves.
Try it
npm install -g ccteams
ccteams use next-ts --profile budget # or go-api, rails, django, python-fastapi...
# restart Claude Code — hooks and agents load at session start
One command gets you the agent team, the playbook, the hooks, and the cost profile. Switching teams swaps the hooks cleanly.
If this saves you a round-trip or two, a star on the repo genuinely helps. And I'd love to hear what checks you'd add for your stack — the whole point of this design is that a new rule is one regex away.
Top comments (10)
The upstream-payload case needs a black-box probe through the actual hook path, not a fixture passed directly to the regex. A CI check could make one controlled edit to a disposable file and assert that Claude Code receives the exit-2 feedback; if the payload shape changes and the checker silently no-ops, that probe fails even though unit fixtures still pass.
Agreed — that's the piece a fixture can't cover. The probe has to go through Claude Code itself, not around it: one controlled edit, then assert the exit-2 feedback actually came back. The friction is operational — it needs a live headless session in CI (key, tokens, some flakiness) — so it'd land as a scheduled canary rather than a per-commit check. The cheaper variant I'd try first: a recording hook that dumps one real payload per run and diffs its shape against what the checkers expect. Less complete than asserting the whole feedback loop, but it catches the schema change, which is the failure that actually worries me.
The recorder catches the schema change only if something still reads the payload, and the drift you are worried about disables that read:
input.tool_input?.file_pathfollowed byif (!file?.endsWith(".tsx")) process.exit(0)turns a renamed field intoundefinedand then into a clean exit 0, so the checker reports "not a .tsx file" rather than "I could not find the path". That also argues for diffing against a recorded golden payload instead of against what the checkers expect, since the expectation is the thing that drifted. And the recorder needs a liveness count of its own: zero dumps in a session reads exactly like the hook never firing, which is the case you want it to catch.You're right — the conflation was the bug. Fixed in v0.3.1: a matched Edit/Write payload without a file_path is now its own case, exit 1 with a one-line stderr, which Claude Code surfaces as a hook warning. So payload drift gets loud on the first edit after it happens, instead of no-oping as "not my file type". Internal errors still fail silent; only the contract assumption fails loud. That covers the case cheaply enough that the probe/recorder machinery can stay on the shelf for now.
The cost comparison is what really sells it for me—a regex doesn't burn through tokens getting tired at 200k. Moving deterministic stuff out of the prompt feels like it frees up budget for the rules that actually need a model to think about.
That's the part that surprised me most in practice too :) — the token saving is real, but the bigger effect was on the rules that stayed. Every mechanical rule you delete from the prompt stops diluting the judgment rules next to it, so the prompt you're left with is shorter and better followed. Fewer instructions, each carrying more weight.
The split between judgment rules and rules you can grep for is the part that stuck. Prompt rules hold for the first stretch of a session, then a smaller model quietly writes use client at the page root once context gets crowded. Moving the string matches to a PostToolUse hook that exits 2 is the first setup that makes cheaper builders feel safe, because the miss dies on the edit instead of surviving into the review round. Curious whether you have seen agents start gaming the escape hatch yet, inventing a reason every time instead of fixing the pattern.
Not so far. Two structural things seem to keep gaming unprofitable. The justification doesn't go to the hook — it lands in the builder's report, which the reviewer is told to hold against the playbook, so a lazy excuse has to get past a second model whose job is to reject it. And the hook re-fires on every later edit of the same file, so an invented reason doesn't buy quiet — fixing does. The cheapest way out of the nagging is compliance, which is exactly the incentive you want pointing at the model. If a builder ever starts mass-producing plausible justifications, that's when I'd tighten "state why" into something the reviewer can verify mechanically.
"A rule in a prompt is a request" is the sentence that reframed this for me — the failure isn't only the rule the model forgot, it's the one it read and declined, and a grep doesn't care which happened. The part I keep turning over is your fail-silent note: a check that crashes and exits 0 goes dark exactly the way an ignored rule does, except nothing surfaces it. I keep wanting a canary — one known violation in a fixture that should always trip — so a dead check shows up as a missed catch instead of a clean run. Is there anything like that in ccteams, or have the scripts stayed simple enough not to need it?
The latter have stayed pretty straightforward. Each check is just a zero-dependency regex run against a single file, with no risk of drift between installs. So really, the only points of failure are environmental—like a missing Node setup or broken packaging. But those won't fail silently. Claude Code flags any hook that fails to run; only exits 0 and 2 stay quiet. A canary would mostly just be re-testing things that already fail loudly anyway.
The one thing simplicity doesn't account for is upstream drift—if a future version of Claude Code changes the hook payload structure, the scripts would just silently no-op forever. Even a fixture canary wouldn't catch that, since it would be testing against synthetic input I wrote myself. That said, if that ever happens, the failure mode just gracefully degrades to the prompt/reviewer layer rather than leaving us completely unprotected. That's why I'm comfortable leaving things as they are.