The Day I Stopped Writing Prompts

How four Claude call sites, built months apart, ended up duplicating the same boilerplate — and why unifying them meant drawing a line I refused to erase

AI Systems·Advanced·8 min read · July 6, 2026

The Beginning

Photo enrichment was the first Claude call site in this codebase: hand Vision a photo, get back tags, mood, location, a caption. It worked, so when the cover-letter idea showed up months later, I wrote its Claude call the same informal way — a message, a JSON parse, a bit of validation, done. Then CV summary generation needed its own call. Then behavior-profile generation needed one too. Four call sites, four different features, each one built at a different point in the platform's life.

The First Solution

Writing each one from scratch felt reasonable every single time, because each felt like a one-off. Photography needed Vision-specific prompting. Cover letters needed retry behavior. CVs needed something else again. There was never an obvious moment where "just write the Claude call inline, like last time" stopped being the right call — until I actually laid the four implementations side by side.

What Broke

Laid side by side, three of the four call sites — cover letter, CV summary, and behavior profile — turned out to implement the identical pattern independently: createClaudeMessage → parseClaudeJson → enforceOutputContract → return. Only photo enrichment used the extracted structuredGenerate<T>() wrapper; the other three had each quietly reinvented it.

The duplication wasn't just wasted keystrokes. It was inconsistent behavior with no single cause to fix. Photography carried a versioned prompt constant (PHOTO_VISION_PROMPT_VERSION) persisted to the database; the other three domains had prompt text sitting inline in the generation file with no version anywhere. Photography captured full provenance — prompt version, model version, duration, token usage — through a dedicated AiProvenance type; cover letter and CV summary returned model and usage ad hoc from callClaude(), and behavior profile returned nothing at all. If I'd needed to answer "which prompt version produced this CV summary," the honest answer for three of the four domains was: I couldn't.

The same shape of drift showed up one layer down, in infrastructure that had nothing to do with prompts. Three independent vector-DB connection factories — one each for engineering, CV, and photography embeddings — implemented the identical lazy-singleton-plus-PRAGMA-plus-vec-extension pattern, differing only in variable names. Three hash-based upsert functions did the same hash-check-then-embed-then-upsert dance with near-identical hash functions. And three refresh jobs returned three different stats shapes for what was structurally the same job — which is where the real correctness risk was hiding, not in the boilerplate itself: photography's refresh function captured failures explicitly (failed, failures), while engineering's and CV's silently swallowed or propagated errors with no equivalent field. I'd assumed, going in, that this was a copy-paste tidiness problem. It wasn't only that — two of the three domains had less visibility into their own embedding failures than the third, and nothing about the duplication made that obvious until I read all three side by side.

The Decision

The tempting fix was discipline: keep a mental template of "how a Claude call site should look" and copy it faithfully next time. I rejected that almost on sight, because by the time I wrote the third call site I'd already half-forgotten the details of the first, and a rule that depends on remembering correctly across a multi-month gap isn't a rule, it's a hope.

The other tempting fix was the opposite extreme — design one shared module up front, before Photography, CV, and Engineering's embedding code existed, and make everything conform to it from day one. I didn't do that either, and I don't think it would have worked: extraction happened incrementally, one consumer at a time, starting with Photography specifically because it was the simplest, most self-contained case. I don't think I could have guessed the right shared shape correctly without at least one working, opinionated implementation to extract it from.

What I did do was extract structuredGenerate<T>() — call, parse, enforce contract, attach provenance, return — as the one true version of the pattern all four call sites needed. But I deliberately didn't force all four onto it. Cover-letter generation re-prompts specifically on banned-phrasing or opening-line violations, escalating with the exact offending text on each retry — a shape structuredGenerate() doesn't have, and I judged it not worth adding a retry hook for: the retry logic is domain-specific enough that bolting it on would either complicate every other caller of the shared wrapper, or require a cover-letter-shaped escape hatch inside a primitive whose entire value is staying simple. CV summary generation, by contrast, had exactly one small post-processing step — trimming a generated summary without breaking its narrative flow — that fit a general transformParsed hook cleanly, so it migrated. Two callers, two different outcomes, decided by what each one actually needed rather than by a rule that says "everything migrates."

The New Architecture

Four Claude call sites, one shared mechanism where it actually fits: Photo enrichment ──┐ CV summary ─┼──▶ structuredGenerate<T>() (call → parse → contract → provenance) │ Cover letter ─┴──▶ manual chain, permanently (retry-with-escalation on prompt violations) Behavior profile ────▶ manual chain, not yet evaluated for migration

Underneath the call-site layer, the same audit turned up embedding infrastructure that genuinely was safe to unify — a single generateEmbedding()/generateEmbeddings(), one findNearestVector()/knnRetrieve() pair, one createVecDb() factory, one applyDiversity() — because that code, unlike the retry loop, really was doing the same thing three times with no domain-specific reason for the differences.

Lessons Learned

Engineering Behavior's own retrieval pipeline — six interleaved stages of decision promotion, semantic rerank, and diversity capping, all deeply specific to engineering-decision concepts — was evaluated for the shared layer too, and explicitly excluded. Abstracting it would have added complexity without removing any real duplication, since underneath the surface-level similarity to CV and photography retrieval, it isn't actually doing the same job. That's the same lesson as the cover-letter retry loop, from the other direction: sometimes two things that look alike from a distance are alike, and sometimes they aren't, and the only way to know is to actually read both implementations before deciding.

I also learned not to trust my own first read of "this looks duplicated." The vector-DB factories genuinely were cosmetic duplication — same logic, different names. The refresh-job stats shapes looked cosmetic too, until I noticed that "cosmetic" difference was actually two domains silently losing visibility into embedding failures that a third domain had already solved for. A duplication audit that stops at "these look the same" can walk right past a real behavioral gap sitting one line further down.

Looking Forward

None of this touched what actually goes into a prompt. structuredGenerate() standardized how a Claude call is shaped — parsed, validated, versioned, tracked — but the prompt text itself, and the data assembled into it, are still written by hand per domain, and nothing in this extraction constrains what that data is or how much of it there is. Solving how a call is built was necessary. It wasn't the same problem as deciding what that call is allowed to see.

Takeaway

I stopped writing prompts from scratch not because prompts got smarter, but because I finally sat four implementations next to each other and saw that three of them were the same code wearing different variable names — and that the one place they genuinely differed, the retry logic, was exactly the place I should have left alone instead of unifying on principle.