Anti-Cliché: spending more time correcting than writing

Anthropic, Cursor, and the rest are already part of the work. They also repeat architecture, structure, and especially UI. I researched, iterated for a while, and built Anti-Cliché: not the best skill on the planet, but it grows every time a new tic shows up.

I asked for a form. The agent wrote it in seconds.

tsx
function preventDefault(event: { preventDefault: () => void }) {
  event.preventDefault();
}

function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
  preventDefault(event);
  createUser(formData);
}

const form = (
  <form onSubmit={handleSubmit}>
    <input name="email" type="email" required />
    <button type="submit">Create account</button>
  </form>
);

return <section>{form}</section>;

Two tics in the same dump. A one-line preventDefault function. And the form stuffed in a const, as if JSX were a value and not a component.

It was not a bug: it compiled, the type checked, and the name sounded like architecture. The problem was dumber. The helper did nothing the browser would not already do if you called the method in the handler, once, without extracting it. And the form was already UI: it had markup, a role, it was going to grow. That is a function, not a const.

It was not that I had to “review agents.” It was that I spent more time correcting the dump than if I had written the form myself.

That is when I stopped treating the output as a one-off error. I started treating it as a dialect. The rejection does not live in a PR comment: it lives in Anti-Cliché. This note is the why. The text the agent has to load is the skill.

The fatigue was not about speed

The agent wrote in seconds. I undid the helper, pulled the form out of the const, and by the time the PR was ready I had spent more than if I had implemented it myself. I work with agents every day. It is not an experiment: it is how the code ships. Claude, Cursor, Anthropic’s models —the brand does not matter— are already part of the job. The problem is not that they exist. The problem is the average they spit out when nobody draws a border, and the time it costs you to undo it.

That average is not just one absurd function. It shows up in three layers at once. Architecture: a UserService for a fetch. Structure: a <Box> that only sets flex, a barrel index.ts, "use client" from habit. And especially UI: the purple gradient, Inter, three icon cards, glassmorphism, the sparkles button. You see a screenshot and you already know which model built it.

I already covered another direction with Knip: the project graph tells you what nobody uses anymore. The problem before Knip is different. The code lands. TypeScript passes. The linter passes. It is still the 2024 default copied into a 2026 repo.

A preventDefault function extracted next to the handler. A const form = (<form />) where a component should be. useMemo around a string. A try/catch that console.errors and returns null. A const STYLES = { wrapper: 'flex flex-col gap-4' } so the class does not sit in the JSX. None of that breaks the build. All of it is the dialect.

I got tired of saying the same thing in every review. “Do not extract this.” “The form already does this.” “This does not need a wrapper.” “This looks like a Cursor landing.” The model does not take offense. The model also does not remember the last review unless you leave it in a file it will read again.

What an AI cliché is (and is not)

A cliché is not “code I dislike.” It is a pattern the model picks because it appeared a thousand times in training, not because it solves the problem in this file.

The preventDefault helper is the clearest example I have. The 2018 React tutorial says: in onSubmit, call e.preventDefault() so the browser does not reload. The model learned that. Then it learned “extract repetition.” It glued the two lessons and shipped a one-line abstraction. It looks careful. In practice it adds nothing that was not already in the handler.

The principle in Anti-Cliché fits in one sentence: every banned pattern substitutes form for content, or complexity for value. The fix is not “write cleaner.” It is to be specific, respect what already exists in the repo, and use the platform default (React 19, App Router, Tailwind in the markup).

Same mechanism in UI: purple gradient, 3D blob, “Generate” button with sparkles, text-gray-400 on white because it “looks clean.” Same in prose: *delve*, *tapestry*, *landscape*, *pivotal*, the jaw that tightens, the light that spills. Different domains, same mechanism.

I am not fighting React. I am fighting the reflex to wrap a native call and give it a system name.

The preventDefault case, without theater

The generic { preventDefault: () => void } is the tell on the function: the model does not know if this is a submit, a click, or a drag. It wrapped the method and left. The const form is the tell on the markup: the linter sees a value; I see a component that would not name itself. In the skill that lands together: a helper that does not name a domain, type inflation for an object used once, and JSX assigned to a variable.

In a React 19 form the path is different. The form is a component. The action runs in a transition. You do not cancel the native submit by hand. The browser is not the enemy:

tsx
export function SignupForm() {
  return (
    <form action={createUser}>
      <input name="email" type="email" required />
      <button type="submit">Create account</button>
    </form>
  );
}

If you are still on onSubmit, the call lives in the handler. Once. No helper. No HOC. No withPreventDefault(onSubmit). And the markup lives in a function’s return, not in a const.

tsx
function SignupForm() {
  function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault();
    createUser(new FormData(event.currentTarget));
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="email" type="email" required />
      <button type="submit">Create account</button>
    </form>
  );
}

Intent stays in the component: this submit does not reload, this submit creates a user. The form has a name in the tree.

Constants for everything, including JSX

The const form did not arrive alone. The model extracts to feel organized, not to name a concept. The same reflex, one step further back, is the constants file.

It starts harmless: CARD_PADDING = 'p-6'. Then a STYLES object that is a parallel stylesheet. wrapper, header, body, footer — each key is a Tailwind string that already describes itself. Now, to see what the component looks like, you jump to another file. Tailwind IntelliSense does not follow the key. And three weeks later you no longer remember what STYLES.hint is.

ts
const STYLES = {
  wrapper: 'flex flex-col gap-4 rounded-lg border p-6',
  title: 'text-lg font-semibold',
  hint: 'text-sm text-muted-foreground',
};

<div className={STYLES.wrapper}>
  <h2 className={STYLES.title}>{title}</h2>
  <p className={STYLES.hint}>{hint}</p>
</div>

That does not add meaning. It renames. A class that never changes is written in the JSX. cn() exists for conditionals, not to concatenate two static strings. CVA exists for real variants, not to hide p-6.

If it behaves like a component —it has markup, a role, it will grow or repeat— it is declared as a component. With function. Not const Icon = () =>. Not JSX stuffed in a variable and interpolated later.

The criterion is short. Class string that does not vary: inline. Domain number or label used in three places: a constant named after the domain, not helpers.ts. A chunk of UI with a face and a name: function. If the only thing extraction does is lengthen the scroll and give it an alias, do not extract. That rule is in Anti-Cliché because that day they arrived together —the helper and the const form— and I kept seeing them apart afterwards. The model thinks const is hygiene. In the tree, it is a half-component.

What the skill actually does

preventDefault is what made me write it. Anti-Cliché is not that anecdote. It is a protocol the agent runs *before* writing, plus a set of references by domain: architecture, components, server, Tailwind, UI, prose.

First I went looking for whether this had a name. On Reddit —mostly r/ClaudeAI threads and people working in Cursor— the most repeated tell was visual: purple-to-blue gradient, Inter everywhere, centered hero with three cards, rounded-2xl and glass on every surface. If the screenshot looks like that, nobody chose the design. The model chose the average of Tailwind + shadcn in the corpus.

I also found more formal lists. They helped. They were not enough. I was not only seeing identical landings. I was seeing const form = ( next to a preventDefault function. The UI cliché is the one you photograph. The architecture cliché gets merged and stays.

I iterated for a while. Editor rules, then a longer document, then cuts because kilometer-long rules dilute. I ended on a skill. It is not, remotely, the best skill on the planet. In my projects it does the work: the first draft arrives without the empty helper and without the purple hero.

That does not close it. Every new tic in a diff goes in. If the average moves from purple to cream + serif, the skill moves too. The criterion does not change: if nobody chose it, it does not ship.

The skill does not say “write nicely.” It says the question. Does this already exist in the repo? Is it used more than once? Does this constant add meaning, or just name a string? Is "use client" here for a browser API, or from habit? The rest —React 19, barrels, Result, sparkles, *delve*— lives in the file the agent loads. This note does not replace that text.

None of those rules is original as an idea. The value is having them together, before the agent touches the repo, not in a PR comment that arrived late.

Turning it into a gate

A skill that is only read when someone remembers is useless. Same rule as Git Auditor and Knip: if it matters, it runs without being asked.

The canonical text is published. It installs like this:

bash
npx skills add enderpuentes/ai-agent-skills --skill anti-cliche-agent

The pointer on this site is the Anti-Cliché resource. This note does not replace that text. If the article and the skill disagree, the skill wins.

In practice, in my repos, Anti-Cliché sits in the project (ts/tsx/md globs), the agent loads it on UI and review tasks, and the human review stops repeating “this is an empty wrapper.” It does not replace TypeScript. It does not replace Knip. It cuts the dialect before it enters the diff.

The loop looks like this: Grill.me forces context. The agent writes. Anti-Cliché names the tic. Knip and the hooks look at what remains. Same point I already made with self-hosted skills: a skill is a document the agent picks when the work matches. The frontmatter says it: *when in doubt, use this skill*. None of those pieces is a chatbot with sparkles. They are verifiers.

What it does not do

It does not format. It does not argue about tabs. It does not lock you to a framework. If the repo already has a pattern, the skill says respect it — inventing a “cleaner” parallel is also a cliché.

It is also not an automatic CI detector. It is instruction for the model, not an AST. If you want a failing build, that is ESLint or a test. I wanted something else: that the first draft does not arrive with an extracted preventDefault and a form stuffed in a const.

Close

Absurd AI code is not random. It is the average of a thousand tutorials —and of what Claude and Cursor will generate again tomorrow. Extracting preventDefault is that average with types. The purple hero is the same average with CSS. Anti-Cliché exists because I got tired of returning that average by hand, and because the catalog does not finish: every new tic goes in.

If the agent is going to write faster than you, someone has to name the tics. I wrote them down: this note for the why, the skill for the protocol. Load the second. When the next cliché shows up, it goes in there too.

More notes on AI Engineering.