21STARK
All posts
11 min read views

My blog generates its own covers. V1 lived 4 hours.

The cover pipeline behind this blog: two stages, two human picks, and a same-day rewrite that fixed bad AI art by moving the human earlier instead of buying a better model.

Every post on this blog gets its cover from the blog itself. The pipeline pitches 3 concepts. I pick 1. It renders 3 variations of the winner. I pick again. Two human decisions, 896 lines of TypeScript across six files, and no design tool anywhere in the loop.

The whole contract fits in two constants:

From src/lib/admin/cover.ts:

/** How many distinct concepts stage 1 proposes. */
export const MAX_COVER_CONCEPTS = 3;
/** How many image variations stage 2 renders for the chosen concept. */
export const MAX_COVER_OPTIONS = 3;

The first version of this feature lived for about 4 hours. It shipped on Friday 2026-06-19 at 16:36. By 20:31 the same evening it was deleted and replaced. Not patched. Rewritten. The fix for bad AI images was not a better model or a bigger prompt. It was moving the human earlier.

One button, bad art

Comic panel of an oversized red button wired straight to an automated easel machine, three crooked muddy paintings dumped on the studio floor, and an empty office chair beside the machine.

V1 was the obvious design. One route (/api/admin/cover), one button. Press it and the model analyzes the post, proposes three concepts, and renders one image per concept, immediately. No human in the middle. Demo-perfect.

The covers were bad. The rewrite's commit message says it without ceremony: "Replace the one-shot cover generator with a two-stage flow and an editable prompt surface, and address poor image quality."

The reason it failed is the useful part. Image models are high-variance. The same prompt gives you a keeper or a mess depending on the roll. V1 gave every concept one roll. A weak concept cost a full render to prove it was weak. A strong concept got a single throw of the dice. All the budget went to the widest part of the funnel, and the one component that could have killed weak ideas for free (me) stood at the end of the line, looking at finished images.

Four hours later the human moved. V1 was +474/-20 across 6 files. The V2 rewrite was +972/-279 across 21 files: two new routes, an editable prompt surface, a model bump. Both commits co-authored with Claude Opus 4.8. Same Friday.

The mechanism: pitch before paint

Stage 1 pitches ideas, not prompts

POST /api/admin/cover/suggest packs the post (title, summary, body, with the body capped at 12,000 chars) into one source block and asks the authoring text provider for exactly three concepts. Structured output through a Zod schema ({suggestions: [{title, description}]}), 1,200 max tokens.

A pitch is deliberately NOT an image prompt. It is a title ("2 to 5 words, a human label") plus a description ("1 to 2 sentences naming the central visual metaphor"). Pitches are written for me to judge, not for the image model to execute. If the model returns fewer than three, the code retries once and keeps whichever attempt yielded more.

The human pick is not UI politeness. It is in the API contract: calling render without a chosen concept returns a 400, "A chosen concept is required." There is no fully automated path through this pipeline, on purpose.

Stage 2 spends everything on the winner

Claymation scene of three plasticine robotic arms painting three variations of the same flower sketch onto three easel canvases, with the original sketch pinned to a corkboard above them.

POST /api/admin/cover/render does two things with the chosen pitch. First, the text provider expands it into ONE detailed image prompt (900 max tokens), written for the specific image model that will execute it. Then it renders 3 variations of that same prompt in parallel.

The per-model tuning is a deliberate code/data boundary, documented right in the source:

From src/lib/admin/cover.ts:

/**
 * Per-model guidance appended to the stage-2 system prompt. This is technical
 * knowledge about how each image model reads prompts (NOT brand voice), so it
 * lives in code rather than the editable templates.
 */
const PROVIDER_IMAGE_HINTS: Record<ImageProvider, string> = { /* ... */ };

Everything a human with taste should edit lives in data instead: three strings in a Firestore config/prompts doc (brandContext, suggestionSystem, promptSystem), edited at /admin/prompts, Zod-validated on the way in. They meet in one small function:

From src/lib/admin/cover.ts:

export function composeSystem(base: string, brandContext: string, extra?: string): string {
  const parts = [base.trim()];
  const brand = brandContext.trim();
  if (brand) parts.push(`Brand design language:\n${brand}`);
  const tail = extra?.trim();
  if (tail) parts.push(tail);
  return parts.filter(Boolean).join("\n\n");
}

Stage 1 gets the suggestion prompt plus brand. Stage 2 gets the prompt-writer prompt plus brand plus the provider hint. One editable text box re-skins the entire pipeline. Change brandContext in the admin and both the pitches and the final image prompts shift to the new look. No deploy.

The default brandContext is my favorite artifact in the repo, because it teaches the model the cover's job, not just its style:

Covers are wide landscape banners that sit behind a title, so reserve one quiet region of empty space for text to land. ... Absolutely NO text, words, letters, numbers, logos, or typography of any kind in the image.

A cover is not art. It is furniture with a function: a title has to sit on it. The prompt says so explicitly, because image models will happily fill every pixel and decorate the result with garbage typography unless told otherwise.

Both stage prompts end with the same instruction: "Never use em-dashes." House rule. It binds the machine and it binds me. This post complies.

Three vendors, two jobs

Underneath sits a small LLM layer that routes 5 features (authoring, seo, tags, chat, image) across 3 providers (Claude, OpenAI, Gemini). The cover flow consumes two of them: authoring for both text stages, image for the renders. Anthropic is text-only in this stack, so the pitch-writer and the painter are usually different vendors. Claude writes the brief; OpenAI's or Google's image model paints it.

The provider resolver states the failure philosophy in its own comment: "callers should refuse cleanly rather than 500." The render route applies it in a deliberate order: auth first, then resolve the image provider BEFORE spending a text call. No image provider enabled means an immediate 503 with instructions ("Enable OpenAI or Gemini in Admin → AI"), not a wasted authoring call followed by a crash.

Renders fail; the picker should not

The render loop carries the best comment in the file:

From src/app/api/admin/cover/render/route.ts:

// 3. Render MAX_COVER_OPTIONS variations of the SAME prompt, in parallel,
//    retrying any shortfall ONCE so a transient render failure does not quietly
//    shrink the picker. Each render is wrapped so it resolves (never rejects),
//    so a thrown error can't 500 the batch and the slot stays eligible.

Every render resolves instead of rejecting, the shortfall gets one bounded retry pass, and the response reports requested versus generated honestly. Two of three comes back as two of three, and the editor says so out loud: "Generated 2 of 3; a render failed. Pick one or try the concept again." No silent shrinkage.

The base64 previews are never stored. Only the variation I pick gets uploaded: a 5MB cap, four allowed image types, a server-generated object name, a private bucket with no signed URLs, streamed back through a read proxy with immutable cache headers. And next to the finished options sits a "Prompt used" disclosure. I can always read what the machine asked the painter for. That auditability earns its keep the first time a batch looks wrong, because the prompt tells you which stage to blame.

Nine unit tests cover the normalization and prompt-composition edges. The real check is a 95-line smoke script that runs the full flow against live providers and admits it in its own comment: "NOT a unit test (hits live APIs + costs a few tokens / one image)." Pipelines like this die in the gaps between mocked stages. Test live.

What broke anyway: prompt text rots too

Flat vector illustration of a sleek modern robot painter frowning at a yellowed, cobwebbed instruction sheet that shows a diagram of a rusty old-fashioned boxy robot.

Two drifts in this codebase right now. Opposite verdicts.

Drift one is a bug. PROVIDER_IMAGE_HINTS still describes gpt-image-1 and gemini-2.5-flash-image. The model registry has since moved to gpt-image-2 and gemini-3-pro-image-preview. The very rewrite that added the hints also bumped the models, and neither I nor the agent writing alongside me updated the English. Nothing failed. That is the problem: nothing can fail. Stale prompt guidance throws no exception, fails no test, trips no type check. It just quietly tunes prompts for a model one generation old. Code comments rot and reviewers shrug; prompt knowledge rots and your output degrades with no stack trace. It is on my fix list, and the lesson is already booked: a change to a model constant must also touch the prose that describes that model, because no compiler will connect them for you.

Drift two is the design working. The repo's default brandContext still describes this site's original look, "engineering blueprint, after dark": near-black canvas, bone linework, one signal-orange accent. The live site reskinned to the Forge palette on 2026-07-06. No panic, because brand language is data. The live Firestore doc is the truth and gets edited in the admin; the repo string is only the seed fallback. A fallback lagging the data it backs up is fine. Knowledge-in-code lagging reality is not. Same symptom, different severity, and the code/data boundary is what tells them apart. The split was drawn by asking who should edit each piece: taste belongs to the author, vendor mechanics belong to the engineer. The split is right. The surprise was which side rotted first: vendor facts moved faster than brand taste this month.

One more honest gotcha. In local dev (Turbopack) the app cannot read Firestore, firebase-admin double-fires under HMR, and the LLM config silently falls back to a default where image generation is disabled. In dev, the entire Generate button looks switched off. Verification runs against the production runtime instead: npm run build, then node .next/standalone/server.js. A pipeline is not verified until the deployed shape of it ran.

The Cheap Veto

Name for the pattern: the Cheap Veto. Put the human decision at the point where saying no costs the least, and release budget only downstream of a yes.

Count what each veto costs here. Killing a bad concept in stage 1 costs a slice of one 1,200-token text call. Killing a bad image in V1 cost a finished render, and V1 rendered one per concept before I saw anything: three renders per post, each idea getting one throw. V2 spends the render budget exclusively on a concept a human already committed to. The second pick chooses between rolls of a known-good idea. Direction was settled a stage earlier.

That ordering is the entire fix. Direction mistakes are the expensive ones, so the direction veto goes first, where a no is nearly free. Execution variance is survivable, so it gets three rolls and an honest 2-of-3 fallback when one fails.

The pattern generalizes far past cover images. Review the plan before the 4,000-line diff lands. Demand three pitch paragraphs before the four-week migration. Fan out options cheap, converge with a human, spend on the survivor. Models are excellent at producing options and mediocre at knowing which one is right. Humans flip that: generating is slow, but a no takes two seconds and costs nothing. A pipeline that respects both facts puts the veto early. V1 was me forgetting that for four hours.

What to steal

  • Pitch before paint. Make stage 1 output human-judgeable text, never machine-executable prompts. Rejecting a pitch should cost a few tokens at most.
  • Split prompts by who should edit them. Taste is data behind an admin page. Vendor mechanics are code behind review. Then guard the code side: prose about models goes stale with zero errors.
  • Put the human in the contract. A 400 without a chosen concept beats a UI that merely encourages choosing.
  • Report partial success honestly. requested versus generated, surfaced to the picker. Silent shrinkage is how trust in a tool dies.
  • Expose the intermediate artifact. The prompt the machine actually used is one <details> away. When output is wrong, that is the first thing you will want.

Where the human sits

This whole site is 79 commits and exactly one month old. The cover feature is 896 lines across six files, written and rewritten in a single Friday, and it is not automation in any honest sense: I pick twice per post, and publishing is still a human act. What the machine took over is the part I was never going to do myself anyway: producing three considered visual directions for every post, on demand, then three executions of the winner.

What changed is the price of judgment. It finally got cheap enough to apply everywhere. The pipeline proposes, expands, renders, retries. It never decides. The only component in those 896 lines that cannot be regenerated is the veto.

Make the machine earn the render.

Get in touch

I write about AI-first engineering on LinkedIn. Specs in, production out, nobody types code. Follow along there, or send a note.

hi@21stark.com · LinkedIn opens my profile, message me from there


Or send it from here