Coding
Project Postcard
One place, one issue, every month: an email travel magazine written and illustrated by an AI pipeline where the only human step is approve or reject.
- Role
- Sole author: product, pipeline architecture, evaluation design, implementation
- Years
- Present
- During
- Exit Zero Labs
- Built with
- Cloudflare Workers, Cloudflare Workflows, Cloudflare D1, Cloudflare R2, Cloudflare Queues, Cloudflare KV, Cloudflare Access, Astro, Hono, TypeScript, Zod, Stripe, Resend, OpenRouter, Workers AI, resvg-wasm, Turborepo
What a subscriber actually receives
Project Postcard sends one email a month about one destination. Not a digest and not a list of ten places: a single issue laid out as a vertically scrolling magazine, with a downloadable postcard, a written piece about visiting and staying, and a curated set of images and links.
The interesting constraint is stated in the product document and it is not a technical one. Everything a subscriber sees has to feel intentional. If any part of it reads as generic, the issue has failed, whether or not every fact is right.
That is a hard requirement to hold when the content is generated.
Nothing advances on its own say-so
The pipeline is eight steps: select a destination, research it, write the issue, produce the postcard, curate the media, assemble the whole thing, review, approve. Each one runs as a durable step that can fail and resume on its own. Nothing depends on a single long call surviving.
Between the steps sit the evaluation gates, and they are the actual architecture. No artifact moves forward without passing a multi-persona rubric — several evaluator personas with different priorities, scoring the same output against explicit criteria. A model does not get to advance its own work by asserting that it is good.
initialize-budget
Commits the run's cost ceiling before any paid work.
select
load-revision-plan#rev
research
write
postcardone budget phase
caption
front
back
print
curate
assemble
enter-review#rev
The only human decision in the run.
renew-review#rev
persist-review#rev
compile-legacy-plan#rev
persist-legacy-plan#rev
advance-revision#rev
Sends a rejected issue back to the step that caused it.
runPipelineWorkflowexit-zero-labs/project-postcard · apps/pipeline/src/workflow.ts
Each name is a durable boundary: a run that fails resumes at the name it stopped on. The names marked #rev take a revision suffix when a rejection sends an issue back through them.
Every generated artifact carries its provenance: which model produced it, which prompt version, what it scored, what it cost. When an issue is weak, that record is what makes the weakness diagnosable instead of mysterious.
The human appears once, at the end, with two options: approve the draft into an immutable library, or reject it with feedback. A rejection re-enters the pipeline at the step that caused the problem. That precision is what makes a single reviewer viable, because a system that answers every rejection by regenerating everything teaches the reviewer to stop rejecting.
The model does not draw anything that means something
The postcard is the piece a subscriber keeps, and it is where generated output is least forgiving. Image models are unreliable with text, layout, and alignment in exactly the ways a printed card exposes.
So a model never lays out the card. Text, structure, and layout are built as deterministic SVG and rasterized. The only thing a model produces is a decorative background containing no text, and that background passes its own gate before anything is composited on top of it.
The result is a card whose typography is correct every time, because typography was never a generation problem. Use a model where judgement helps, and refuse to use one where determinism is available.
A loop that can spend
A pipeline that loops on quality can loop on cost. Retrying a failed gate is the correct behaviour and also the expensive one, and the failure mode is a run that quietly spends until someone notices.
Budgets are enforced in code. Nothing here relies on a person watching a dashboard. A run has a spend ceiling and each step has its own, and exceeding either trips a circuit breaker that stops the run. Per-issue cost is tracked as a first-class number next to the evaluation scores, because the two are related: a topic that keeps failing its gates is a topic that keeps costing money.
The hosting choices follow the same logic. The whole system runs on primitives that cost nothing at rest, so the baseline for a publication with no subscribers yet is close to zero and the marginal cost of an issue is dominated by inference.
// Initialize the run's immutable cost ceiling once, before any paid work (#91): stores the
// ceiling `deps.budget` was actually constructed with only while unset, so a later deployment
// value can never widen an already-committed run's ceiling. Safe on every replay — a repeat
// call matches zero rows once the ceiling is set.
if (deps.budget && deps.budgetCeilingUsd !== undefined) {
await step.do("initialize-budget", async () => {
await deps.runTracking.initializeBudgetCeiling(
instanceId,
toCostMicros(deps.budgetCeilingUsd as number),
deps.now(),
);
return { initialized: true } as const;
});
}workflow.tsexit-zero-labs/project-postcard · apps/pipeline/src/
This runs once, before any paid work in the run. Every model call after it is recorded against the ceiling it commits, including the calls a replay repeats.
Built, and not published
The surfaces are implemented and the infrastructure is provisioned. Delivery is wired end to end, from the hourly cron that claims a month's outbox row through to a queued send.
It has not been published. No issue has gone out, the domain is not serving, and deploying is a decision I have not made yet. Calling it live would be the kind of claim the project itself is built to reject.
The pipeline's four surfaces
Each surface has its own hostname and its own trust posture. The pipeline has no public entry point at all.
- Public web
- Subscription, double opt-in confirmation, and the current issue. The only surface open to the internet.
- Review portal
- The single human decision. Protected by access control, and its browser never calls the API directly.
- Pipeline worker
- Durable, resumable generation steps with evaluation gates. No public route; invoked only through a binding.
- Delivery queue
- Sending, carrying opaque identifiers only, with a dead-letter queue for messages that cannot be delivered.