Paste messy project notes; get a case-study page you can send to a client.
notes + screenshots
└─► INTAKE (claude-haiku-4-5) → title, client guess, ≤4 gap-filling questions
└─► answers
└─► CASE STUDY (claude-opus-5) → structured study → D1 + R2 → public page
Two models, chosen per job, each labelled in one line:
/** Model for the cheap, high-volume intake pass. */
export const INTAKE_MODEL = "claude-haiku-4-5";
/** Model for the thing the freelancer puts their name on. Do not downgrade. */
export const CASE_STUDY_MODEL = "claude-opus-5";
The intake prompt is the product. It encodes a theory rather than a persona —
“Freelancers write down what they built; prospects want to know what changed.”
— and then constrains the ways the model would go wrong. Ask at most four questions, because every extra one is a chance for the freelancer to abandon the form. Never ask about something already answered in the notes or in an attached screenshot: “if a chart shows the completion rate going 41.2% to 63.8%, the completion rate is answered, and asking for it anyway tells the freelancer you did not look at what they gave you.” Ask for specifics, not essays. Give every question a one-line “why” so the field doesn't get a shrug. And, the rule most prompts are missing — if the notes are genuinely complete, return an empty list; do not invent questions to fill space.
Caching is treated as an economic decision. Every prompt is a frozen constant, because caching is a prefix match and one byte of drift before the breakpoint invalidates the entry — “the difference between ~87% and ~40% gross margin, and expensive to retrofit.” Cache minimums are documented as non-monotonic across model generations (512 tokens for Opus, 4,096 for Haiku) and then deliberately not gamed: padding the intake prompt purely to earn a cache hit would cost more than it saves. Images go before text in the user turn, both because that is the documented ordering and because it keeps the varying text where caching wants it. Token usage is logged per generation, so margin per study stays visible instead of arriving on a bill.
Failure is typed. A ConfigError — bad key, dead model id — is loud in the log and never invites a retry; a GenerationError is written for the user to read. Without that split, “a 401 reads as ‘try again in a moment’, which is how you end up retrying a misconfigured API key forever.”
That behaviour is not theoretical: it fired while this page was being written. The Anthropic account is out of credit, so a live generation returned a 400 — and the Worker classified it as a deployment fault, logged CONFIG ERROR — needs a fix, not a retry, and declined to suggest trying again. That is why there is no generated-page screenshot here; the error path did exactly what it was built to do.
Why it matters hereModel selection justified per call site, a prompt that constrains behaviour rather than describing a character, an explicit licence to return nothing, and an error taxonomy that separates “the model failed” from “we are misconfigured.” Those are the same distinctions that separate a useful model-behaviour report from a useless one.