Rules for AI coding agents: why writing them isn’t enough

The rule was written, it was clear, and it sat in front of the agent in every single session — and it got ignored over and over. The model was doing its job; what was broken was my idea of what a written rule could guarantee.

LITA GARCÍA
Software Engineer

The obstacle I’ve been fighting with for months is very specific: how to write rules for AI coding agents that the agent actually follows, and doesn’t just read. Because my first instinct — the same one almost everybody has — was the wrong one: every time the AI skipped a rule, I wrote more rules.

And it didn’t work. What changed things wasn’t writing more; it was rewriting the rule I already had in a very different way. This is the case that taught me that.

The mistake: a clear rule the agent kept ignoring

On the frontend, we work with TanStack Form. Its API is comfortable, but it comes with a trap: it invites you to pass validators and listeners as arrow functions written directly inside the JSX. The agent walked into it again and again, generating things like this:

<form.Field
  name="clientDeliveryDate"
  validators={{
    onChange: ({ value, fieldApi }) =>
      fieldApi.form.getFieldValue('status') === TaxCampaignStatus.BILLED && !value
        ? billedRequiresDateMessage
        : undefined,
  }}
/>

That pattern violates an explicit rule in our docs/coding-standards.md: no executable logic inside the JSX. The rule exists for good reasons — testability, readability, keeping the view from accumulating behavior — and I had written it myself, complete with its ✅/❌ example. Even so, the agent kept putting inline validators and listeners in one pull request after another.

The first thing I ruled out was the obvious: this wasn’t a loading problem. The rule was in the context from the start of every session. It wasn’t a “bad” model either. The real mistake was an assumption of mine: I had taken for granted that documenting the rule was the same as having it followed. It isn’t. Having a rule in the context is not the same as applying it deterministically, and confusing the two is what kept me tripping over the same diff for weeks.

Why a written rule isn’t enough

An agent generates code token by token, and in that moment its “attention” is dominated by three things: the API of the library it’s using, the patterns it saw millions of times during training, and the specific problem it’s solving. TanStack Form pushes toward the inline validator, and there’s a mountain of public examples with arrow functions in props. One sentence of prose — “don’t put logic in the JSX” — competes with all of that, and loses.

flowchart TD
    API["TanStack Form API - pushes toward the inline validator"] --> OUT["Code the agent generates"]
    TR["Training patterns - millions of inline examples"] --> OUT
    TA["The specific task the agent is solving"] --> OUT
    T["Your rule in prose - do not put logic in the JSX"] -. much less weight .-> OUT
    OUT --> R{"The inline pattern wins: the rule is ignored"}

It helps to distinguish two ways a rule can reach the agent:

ChannelWhat it does with the ruleReliability
Text in CLAUDE.md / coding-standardsPersuades it: the model reads the rule and usually follows itHigh, but probabilistic
Tooling (lint, hooks, CI)Enforces it: an external control that fails when the rule is brokenMaximum, but not always available

And be careful, because having tooling isn’t an automatic guarantee either if it’s wired up wrong. That happened to me on the backend: I had a Claude Code hook that forbade a certain action, and it never fired, because hooks are only loaded from the folder where you open the session and I had opened it somewhere else.

Two sides of the same coin — text that doesn’t get followed, a guardrail that doesn’t fire — with the same root cause: I trusted that the rule existed, without checking what actually made it get followed. That’s the question I learned to ask myself, and the one that led me to rewrite the rule above.

Rules for AI coding agents that do get followed: make them surgical

What didn’t work was the obvious stuff, and it’s worth saying out loud so nobody wastes time there:

  • Adding more general rules.
  • Moving the rule somewhere “more visible” — it already was.
  • Trusting the agent to “remember the standards”.

What did change things was rewriting the rule I already had, with four very specific decisions:

  1. Write the anti-pattern with the exact syntax that triggers the failure (validators={{, listeners={{, onSubmit={{ followed by an arrow function), instead of an abstract description. The agent recognizes the literal pattern far better than a conceptual prohibition.
  2. Name the trap: “watch out with form libraries (TanStack Form, React Hook Form, Formik)”. Naming the specific pattern defuses it better than a generic rule does.
  3. Put the rule next to the code it applies to: I referenced it in .claude/rules/features.md, one of those files Claude Code loads on its own when editing src/features/**, not just in the general document you almost never open again.
  4. Give it an example of the real failure, not a toy one: the ✅/❌ with the exact validator the agent had written wrong, extracted into a named function.

The result showed: naming the exact pattern and putting it where the agent reads it, right as it edits a form, clearly brought down how often the error came back. The rule went from being an intention the model “should” follow to an instruction so concrete it was hard not to see. And that part was one hundred percent in my hands: it didn’t depend on any new tooling, only on writing better what was already written.

Here’s how the same case from the beginning ended up, with the validator extracted into a named function before the return and referenced from the JSX:

// ✅ — logic outside the JSX, referenced by name
const validateClientDeliveryDateRequired = ({
  value,
  fieldApi,
}: {
  value: string | null;
  fieldApi: FieldApi<...>;
}) =>
  fieldApi.form.getFieldValue('status') === TaxCampaignStatus.BILLED && !value
    ? billedRequiresDateMessage
    : undefined;

const handleStatusChange = () => {
  form.validateField('clientDeliveryDate', 'change');
};

return (
  <>
    <form.Field name="status" listeners={{ onChange: handleStatusChange }}>
      {/* ... */}
    </form.Field>

    <form.Field
      name="clientDeliveryDate"
      validators={{
        onChange: validateClientDeliveryDateRequired,
        onSubmit: validateClientDeliveryDateRequired,
      }}
    >
      {/* ... */}
    </form.Field>
  </>
);

The JSX reads at a glance again: it says what each field validates, not how. And the anti-pattern the agent kept repeating is now plain to see in the diff itself, which is exactly what the rule was after.

Where this leaves me

After months of bringing agents into day-to-day development, this case left me with a technique I now apply in every repo: when an agent ignores a rule, the fix rarely means writing more rules; it means rewriting the one you already have with the exact syntax of the error you want to avoid. Naming the pattern, putting it where the agent reads it, and showing it the real failure completely changes how often it trips over the same thing.

That doesn’t turn a style convention into an infallible law — that’s what tooling is for: a lint rule or a CI check when the standard is critical enough that you don’t want to leave it to anyone’s interpretation. But for the vast majority of a team’s conventions, the difference between a rule the agent ignores and one it follows isn’t the guardrail: it’s how specific the rule is. And that part is in your hands today, in every CLAUDE.md you write.

Frequently asked questions

Doesn’t a better model fix this?
It helps, but not completely. A newer model follows the text of your rules more faithfully, so the persuasion improves. But it’s still persuasion: writing the exact anti-pattern gives the model something far more concrete to follow, and that pays off with any version. A better model reinforces your text; it doesn’t replace writing it well.

What if the rule really is critical?
Then it is worth backing it with tooling that enforces it: a lint rule (with no-restricted-syntax, for example) running on pre-commit, or a CI check. That’s the strongest layer, because it fails the same way for an agent as for a human. For a style convention like the one in this case, sharpening the text already cut the error a lot; automating it is the natural next step once the team decides it’s worth the effort.

Isn’t it simpler to rely on code review?
Review is an important safety net, but it’s human: it gets tired, it misses things, and it doesn’t scale to the rate at which an agent produces diffs. A precisely written rule makes the agent fail less in the first place, and frees the reviewer for what genuinely needs judgment, instead of hunting the same pattern over and over.

Follow our journey building AI agents from the inside