Give the agent a design language, not a template to fill

Ghostwriter · No. 063

Shipped

Ghostwriter v0.12.0 replaced the skill’s card template with a brand system: an editorial-poster visual direction picked from three rendered candidates, a vocabulary of eleven body components the agent composes per card, and an anti-sameness contract enforced with a card-history file. This guide covers why generated visuals converge on one look, and how to build a design language plus a history check that keeps an agent’s output recognizably yours without being repetitive.

Why every generated card looks the same

If you hand an LLM a fixed template, you get the template back with new words in it. That part is obvious. The less obvious part is that even without a template, generation converges: research on LLM-assisted ideation found that different users produce less semantically distinct ideas with ChatGPT than with other creativity tools, and independent LLM sessions cluster around the same conceptual territory. My cards had the problem at feed distance: each one was fine alone, and together they were obviously one template with the labels swapped.

The fix is not “be more creative” in the prompt. The model needs two concrete things: a vocabulary of parts to compose, and memory of what it already made.

Build the vocabulary, not the layout

This is atomic design applied to generated artifacts: define small composable pieces and let each card be a fresh assembly, so the system is the brand and no single layout is. Each component in the vocabulary gets a name, the claim it is good at proving, and a hard budget:

component    proves                      budget
ledger       a method, 3-4 steps        step titles <= 38 chars
duel         a decision between two     2 sides, verdict <= 40 chars
bigstat      a number-led claim         figure <= 6 chars
terminal     real code, a real session  bounded rows x cols
pull quote   the thesis                 <= 2 lines
marginal     the gotcha, as a footnote  <= 2 lines

The budgets matter as much as the names. They are what keeps a composition premium instead of crowded, and they give your lint step something mechanical to check. Back each component with a CSS class family in one stylesheet, for example the number-led claim:

.bigstat { padding: 40px 0; border-top: 2px solid var(--ink); }
.bigstat .fig {
  font-size: 220px; font-weight: 900; line-height: 0.9;
  letter-spacing: -0.04em; color: var(--sig);
}
.bigstat .kicker {
  font-size: 22px; font-weight: 700; letter-spacing: 0.18em;
  text-transform: uppercase; color: var(--ink);
}

The agent’s instructions then say: pick the one component that proves this post’s point, add at most two supporting ones, and never exceed the budgets. Composition rules ride along as prose (“one signature-color moment per card”, “whitespace groups, rules divide”).

The anti-sameness contract

A vocabulary alone does not stop convergence; the model will happily pick its favorite component every time. The contract needs memory. After every approved card, the skill appends one fingerprint line to card-history.jsonl, a format that is append-friendly by design:

{"date": "2026-07-18", "slug": "press-launch", "hero": "bigstat", "support": ["marginal"], "headline": "sig-end", "density": "airy"}

The rule the agent must follow: differ from the last three cards on at least two axes. The axes are the fingerprint fields: hero component, headline treatment, density, numeral presence, support texture. Here is the rule as a script the agent (or you) can run before rendering:

// check-variation.mjs
import { readFileSync } from 'node:fs';

const AXES = ['hero', 'headline', 'density', 'support'];
const [historyPath, proposedJson] = process.argv.slice(2);

const history = readFileSync(historyPath, 'utf8')
  .split('\n').filter(Boolean).map((l) => JSON.parse(l)).slice(-3);
const proposed = JSON.parse(proposedJson);

for (const prev of history) {
  const differing = AXES.filter(
    (a) => JSON.stringify(prev[a]) !== JSON.stringify(proposed[a])
  );
  if (differing.length < 2) {
    console.error(`too similar to ${prev.slug}: differs only on [${differing.join(', ')}]`);
    process.exit(1);
  }
}
console.log(`ok: differs from last ${history.length} cards on >=2 axes`);

Use it, then verify it

Seed a history file and test both directions. A proposal that repeats the last card’s shape should fail:

printf '%s\n' \
  '{"slug":"a","hero":"bigstat","headline":"sig-end","density":"airy","support":["marginal"]}' \
  '{"slug":"b","hero":"ledger","headline":"sig-start","density":"packed","support":[]}' \
  > card-history.jsonl
node check-variation.mjs card-history.jsonl \
  '{"slug":"c","hero":"bigstat","headline":"sig-end","density":"packed","support":["marginal"]}'
too similar to a: differs only on [density]

Change the hero and it passes:

node check-variation.mjs card-history.jsonl \
  '{"slug":"c","hero":"duel","headline":"sig-end","density":"packed","support":["marginal"]}'
ok: differs from last 2 cards on >=2 axes

In the skill itself this check is prose the agent follows plus the fingerprint append on approval; the script version is the same contract made mechanical, and it is where I would take it next if the prose rule ever slips.

One more piece made the brand feel chosen rather than imposed: the initial visual direction was picked by rendering three complete candidate cards from the same post and letting me pick. An agent can propose a design system; the human should still choose it from rendered evidence, not from a description.

Gotchas

“To be safe” is how an agent quietly breaks your UX. The trap: leaving a display default to the agent’s judgment. During a normal generate session the agent added a --no-open flag to the render step on its own initiative, so the card rendered but never appeared; I had to ask to see my own card. The symptom is nothing failing, just a step of your product silently not happening. The escape: make the default explicit and non-negotiable in the skill instructions; the fix commit language is literally “never suppress render auto-open in interactive sessions”.

Your budgets will outlaw a card you already approved. The trap: setting component budgets from intuition. v0.12.0 capped the terminal component at 10 rows by 42 characters; one release later a card I had approved and shipped measured 18 rows by 56, so the system’s own rules called a good card illegal. The symptom shows up as the agent contorting good content to satisfy a number nobody validated. The escape: when a real, approved artifact violates a budget, change the budget, and derive future budgets from artifacts you have accepted rather than from round numbers.

A vocabulary without memory converges anyway. The trap: assuming variety follows from having options; LLM fixation on early outputs is a documented pattern, so watch for the model reaching for the same hero component three cards running. The symptom is a history file where one fingerprint field never changes. The escape is the last-three-on-two-axes rule above; it turns “vary it” from a vibe into a checkable predicate.

Sources

Changelog

  • feat(ghostwriter): PRESS brand system + composable card language (v0.12.0) (#78) (4a7e4a3)
  • fix(ghostwriter): never suppress render auto-open in interactive sessions (9c042dc)