Seven ways to steer a coding agent — one of them is binding
I had written the same instruction three times.
Once in CLAUDE.md: "Do not commit directly to master — it is the protected/published branch." Once in step 0 of my /ship command: "Never commit to master — it's the protected/published branch." And once more in step 4, as a parenthetical about staging. Same rule, three places, because the first two hadn't been enough.
That repetition is a diagnostic. An instruction you have to restate is telling you something about the layer it lives in — not about your wording. So I spent a session pulling my repo's Claude Code configuration apart and putting each instruction in the layer that actually fits it. CLAUDE.md went from 250 lines to 163. The master rule stopped being a sentence and became a hook. And a code review caught that my new hook was wired to the wrong shell.
Seven mechanisms, three axes
Claude Code gives you seven ways to shape behaviour. They trade off along three axes: when they load, what they cost in context, and whether they are advisory or absolute.
Cost and authority run opposite to each other: the cheapest mechanism has the most authority. Hooks and permission rules cost essentially nothing in context and are the only two that are actually binding, because they are code rather than persuasion. Everything else — CLAUDE.md, rules, skills — is a suggestion to a probabilistic system.
Which reframes the question. I had been asking "how do I word this so the model follows it?" The better question is "what class of instruction is this?" A fact belongs in CLAUDE.md. A constraint tied to certain paths belongs in a rule. A procedure belongs in a skill. A search whose output is noise belongs in a subagent. An invariant that must never break belongs in a hook or a deny rule. Most of my steering pain turned out to be a category error, not a phrasing problem.

The five layers I actually use, ordered by when they load. Only the bottom one is enforced.
What CLAUDE.md is for, and what it isn't
My CLAUDE.md had grown to 250 lines. The official guidance is to stay under 200, but the raw number was never the interesting part. The composition was. The file was doing three different jobs at once.
There were facts — "Flyway owns the schema", "the React bundle is built into Spring's static/ dir". Those belong there. There were invariants — "do not add a tool to the concierge", "every @LlmTool parameter is an int or an enum". Those are rules, and they are path-specific. And there were procedures — local dev commands, the migration workflow. Those are skill-shaped.
The concierge security invariant plus the agent tool-surface boundary came to roughly 60 of those 250 lines. A quarter of my always-on budget, describing a prompt-injection threat model that matters when I touch two directories. Editing a CSS file still paid for all of it.
And "context cost" isn't only tokens. It's attention. A sentence buried at line 180 of a file that loads on every single turn has been sitting there for forty turns by the time it matters. It has become wallpaper.
Path-scoped rules: the mechanism I had none of
.claude/rules/ is a directory of markdown files, each with YAML frontmatter that scopes it to globs:
---
paths:
- "backend/src/main/java/com/elzakaria/cvnext/concierge/**/*.java"
- "backend/src/main/resources/concierge/**"
---
# Concierge — the ChatModel is a pure text generator
Two details matter more than they look. First: a rule without a paths: field loads unconditionally, at the same priority as CLAUDE.md. Omitting it silently converts your scoped rule back into always-on context — you get the file organisation and none of the savings. Second: path-scoped rules fire when Claude reads a matching file, not on every tool call. That is the whole point. The instruction arrives at the moment of relevance instead of having been in context since before the task started.
Rules also beat nested CLAUDE.md files when a concern spans non-contiguous locations. My clearest example is a design-token block that exists in four copies — the SPA's index.css, the blog's blog.css, the CV builder's buildcv.css, and the brand kit's brand.css. Four different trees. No single nested CLAUDE.md covers that. One paths: list does.
I ended up with six: the concierge no-tools invariant, the agent tool surface, entity-to-migration pairing, the four CSS copies, some Thymeleaf traps, and the CV builder's client-only promise. About 325 lines that now cost nothing until they're relevant.
One thing I did keep: the why. It is tempting to compress a rule to its imperative. But "do not add a tool" decays the first time someone has a plausible reason. "The containment is the blast radius — aggregation is the sanitiser" survives, because it tells you what a valid exception would have to look like.
The hook that replaced three sentences
Hooks are shell commands wired to lifecycle events. A PreToolUse hook runs before a tool call and can block it, either by exiting 2 or by returning JSON:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "…shown to Claude…"
}
}
So the master rule became guard-master.mjs: parse the command, split on shell separators so git add . && git commit is caught, resolve the current branch, and deny commit or push when HEAD is on a protected branch — or when a push names one explicitly, from anywhere.
Three sentences of prose collapsed into one file that cannot be talked out of it. It constrains the agent, not me: I can still run whatever I like in my own terminal. The point is that Claude cannot do it by accident.
I added a second one, guard-invariants.mjs, for the two things no test can catch before the code exists — a tool-calling symbol appearing in the concierge package, and withAutoLlm() appearing anywhere under agent/.
Your guard's first victim is your own documentation
This is the part I did not see coming, and it is the most transferable thing in this post.
My first instinct for guard-invariants.mjs was a substring search: does this edit introduce ToolCallback, @Tool, .tools(? Before wiring it up I checked whether those strings already appeared in the codebase. They did:
/*
* … no ToolCallback/@Tool/FunctionCallback on these options, on the
* Prompt, or on a ChatClient wrapper …
*/
// No .tools()/.functions() — see the SECURITY INVARIANT above.
Both files that document the invariant name every forbidden symbol while explaining it. A naive regex would have blocked every future edit to precisely the two files that explain the rule. I would have hit that twice and deleted the hook.
So the guard strips comments and string literals before matching — a small state machine over the source handling line comments, block comments, text blocks, string and char literals. It matches code, not text.
There's a related wrinkle for Edit. The hook payload gives you new_string, which is a fragment that may begin mid-javadoc. Parsing the fragment alone misreads comment text as code. So the hook reconstructs the post-edit file — current.replace(old, new) — and parses that instead.
The theme kept recurring all session. A security plugin flagged dangerouslySetInnerHTML in a rule file whose sentence says the code doesn't use it. It flagged child_process.exec in a test. My own config verifier flagged a doc that mentions .claude/commands while explaining what replaced it. Four false positives, all the same shape: a pattern-matcher that cannot tell doing from describing. If you have ever argued with a SAST tool about a finding in a comment, this is the same failure at a smaller scale.
Test the guardrail, or you don't have one
I wrote a test suite for the hooks. Cases for the false positives above, for compound commands, for git log --grep=commit (must pass), for echo "git commit" (must pass). Then I built a throwaway repo in a temp directory to test the branch cases hermetically.
Three of them failed. Not the test — the hook.
In a fresh git init with no commits yet, git rev-parse --abbrev-ref HEAD errors out on the unborn HEAD. My hook treated that as "can't determine the branch" and failed open. It had passed every check against my real repo, which has commits. It was a guard that looked installed and wasn't.
// `branch --show-current` over `rev-parse --abbrev-ref HEAD`: it reports the branch
// even on an unborn HEAD (fresh `git init`, no commits yet), where rev-parse errors
// out and would make this hook fail open. It also returns empty on a detached HEAD
// rather than the literal string "HEAD", so detached correctly reads as "unknown".
That second property is a quiet bonus. rev-parse --abbrev-ref returns the literal string HEAD when detached, which a naive comparison could mistake for a branch name.
My repo already believes this, incidentally. There is a JUnit test in the agent module that fails the build if any LLM-exposed tool ever takes a String parameter. The rule is a test, not a comment. The hooks now get the same treatment.
deny versus ask is a real design decision
A PreToolUse hook can return allow, deny, or ask. The choice is not about severity. It's about whether a legitimate exception can exist.
withAutoLlm() in my agent module is never correct — it selects from Embabel's registered model set, which still contains models DeepSeek retired, and the right alternative is a one-token change. There is no version of that call I want. Hard deny, with the fix in the message.
A tool on the concierge is different. The rule I wrote says "if a tool ever becomes truly necessary it must be read-only, side-effect-free, and explicitly allow-listed" — which means the rule is raise it before implementing. So the hook returns ask. Escalating to me is the rule, expressed in the only layer that can guarantee it happens. And it can't false-positive me into a corner.
One more principle worth stating: fail open. Every one of these hooks exits 0 on a parse error, an IO problem, or a timeout. A guardrail that blocks work when it malfunctions is a guardrail you disable, and then it protects nothing.
The review found the actual bug — in the wiring
I ran a review before committing. It surfaced one real defect, and it wasn't in any of the code I'd been careful about.
guard-master.mjs was registered against the Bash matcher. My project is Windows and PowerShell-first — my own CLAUDE.md literally says "use PowerShell syntax (.\mvnw.cmd, not ./mvnw)" — and Claude Code has a separate PowerShell tool. A git commit issued through it would have walked straight past the guard.
The fix was one character of config. PowerShell carries its command in the same command field as Bash, so the matcher widens to Bash|PowerShell and the script needs no changes. The test suite now asserts the wiring too, not just the script:
const masterGroup = pre.find((g) => g.hooks.some((h) => h.command.includes('guard-master')))
check('guard-master matcher covers PowerShell', 'yes',
masterGroup.matcher.includes('PowerShell') ? 'yes' : 'no')
A correct script behind a narrow matcher is worth nothing. I'd spent the session thinking about comment-stripping state machines and the actual hole was in a JSON string.
Two things I chose not to do
I didn't write a custom output style. They sit highest in the stack — but they replace Claude Code's default instructions, including scope discipline, security considerations, and test-verification behaviour. With a live production box and a merge-to-deploy pipeline, trading those away for tone is a bad deal. --append-system-prompt is the additive alternative, and it's better suited to headless runs anyway.
I didn't add a compile check for Java. Running Maven on every edit is 20 to 60 seconds and would make sessions unusable. So the Java-side hook guards invariants instead of acting as a linter. If I want real Java feedback the honest shape is a Stop hook that compiles once per turn, not per edit.
I also wrote down what the hooks deliberately don't stop. Unrecognised git forms like GIT_DIR=… git commit. Writing a file through Bash instead of the Edit tool. A Read(.env) deny rule doesn't prevent cat .env in a shell. My threat model is accidental action by the agent, not an adversary evading detection. Closing those gaps means parsing arbitrary shell — a large false-positive surface against a threat I don't have. If that calculus ever changes, the honest fix is a sandbox, not a longer regex.
Two audits before committing
Moving 90 lines out of a file people rely on is exactly the kind of refactor that quietly loses something. So I wrote a throwaway script that took 40 load-bearing facts from the old CLAUDE.md — as keyword clusters, since the text got reworded — and checked each one still appeared somewhere in the new corpus of rules, skills and the trimmed file.
39 passed. One flagged as lost turned out to be my checker being too literal: I'd searched for "not exercised" where the rule now says "CI does not exercise migrations" and, better, "a green build therefore proves nothing about a migration." The fact survived in stronger words than it had before.
The second audit was structural: both settings files parse, every hook command resolves to a file that exists, every rule's paths: glob matches at least one real file. That last check is worth automating — a glob with a typo produces a rule that never fires and never errors.
The rule of thumb
If you take one thing from this: an instruction that has to be repeated to be obeyed is telling you it belongs in a lower, more deterministic layer. Repetition is the smell. Determinism is the fix.
Everything else follows from asking what class each instruction is. Facts stay in CLAUDE.md. Constraints get a paths: glob so they arrive when they're relevant. Procedures become skills — and if a procedure deploys to production, set disable-model-invocation: true so the model can never start it. Research that produces noise goes to a subagent, so only the conclusion comes back. And anything that must actually hold becomes a hook or a deny rule, because those are the only two mechanisms that aren't asking nicely.
The tell that it worked: shipping this change, I never once had to remember not to commit on master. Merging the PR hit an ask gate I'd added an hour earlier — one deliberate confirmation immediately before production. Correctness stopped depending on my attention, which is the only kind of correctness that survives a long session.
One footnote for anyone doing the same. Custom slash commands and skills are now the same mechanism — .claude/commands/x.md and .claude/skills/x/SKILL.md both give you /x. Converting isn't a fix, it's an upgrade: you get a directory for bundled files that load partway through, and invocation control. I only found out how well that second part works when my ship skill vanished from the model's own list of available skills the moment I set the flag.
Claude blog article