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-ignore
without 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.
exit 2
turn those rules into a deterministic reviewer that runs after 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.SECRET
in a client file β wrong.
@ts-ignore
with 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.
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:
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.
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.
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.
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.
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.
npm install -g ccteams
ccteams use next-ts --profile budget # or go-api, rails, django, python-fastapi...
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.