A recipe book for a two-person household, running at wecooked.kitchen. The live site sits behind a shared password and is not a public service. Anonymous visitors see a bare sign-in form, so the screenshots below are the demo. The code is here for anyone curious about how it is built.
Demo data. Light and dark mode follow the phone's setting.
- Capture a recipe: paste a link or the recipe text into one box, or photograph cookbook pages (up to 8 at once). Claude extracts it into a structured draft, and a person confirms it before it is saved. Typing one in by hand works too.
- Generate a recipe: describe what is in the fridge, how long you have and how much mess you can take, and Claude proposes three recipes to compare and pick from. The pick goes through the same review form as a capture (iOS app only for now).
- Variations: scale a recipe to a different yield and keep the result as its own variation next to the original.
- US and metric: every recipe is stored in both, with a toggle remembered per device. Hand edits are reconverted in the background.
- Shared shopping list: pick recipes, build a merged list, and tick items off from either phone.
- Cooking screen: keeps the phone awake, lets you strike through steps and ingredients, and installs to the home screen as a PWA.
- Trash: deletes are soft and can always be restored.
- Capture. A recipe comes in as a URL, pasted text, or up to 8 cookbook photos. The request queues a background job and returns right away, and the phone polls for the result.
- Extract. The job sends the input to Claude and asks for JSON that matches a fixed schema. For a URL, the server fetches the page itself and hands any schema.org Recipe data it finds to Claude as the authoritative ingredients and steps.
- Review. The draft opens in an editable form. Nothing is saved until a person confirms it.
- Save. The recipe is stored in both US and metric units. If the extraction did not return the second system, a conversion job fills it in. Scaling to a new yield is a background job too.
Everything runs on a single Fly.io machine. SQLite on its volume is the only database, and Litestream streams every write to Cloudflare R2, which also holds the photos.
- A job queue inside SQLite. Workers claim a job with one
UPDATE ... RETURNING, so two claims can never grab the same row. Jobs still running when the process died are marked failed on boot.src/lib/server/jobs.ts - A spending cap no code path can skip. Every Claude call goes through
one wrapper that takes a slot from a daily quota with an atomic upsert,
forces JSON output, and sets
maxRetries: 1so one transient failure cannot bill three extractions.src/lib/server/claude.ts - The machine never overwrites a human edit. A scaling job skips a
variation someone edited by hand, and a unit conversion job refuses to
convert a hand-written body into itself.
scale.ts,reconvert.ts - Pasted URLs cannot reach the private network. The fetcher blocks
loopback, private and link-local ranges including cloud metadata
addresses, IPv4-mapped IPv6, and Fly's
*.internalnames. It follows redirects by hand and rechecks every hop.src/lib/server/extract.ts - 40 recorded decisions in docs/DECISIONS.md, and the full build contract in docs/SPEC.md.
SvelteKit (Svelte 5, TypeScript) on one Node process, SQLite on a Fly.io volume replicated to Cloudflare R2 with Litestream, photos in R2, and the Claude API for extraction, scaling, and unit conversion.
- PRODUCT.md: what and why
- GLOSSARY.md: the glossary
- docs/SPEC.md: the build contract
- docs/DECISIONS.md: the architecture decision records
- docs/UI.md: UI decisions
- docs/RUNBOOK.md: backup and restore
Needs Node 22 or newer, the version the Dockerfile ships.
cd server
npm install
cp .env.example .env
npm run devIn .env, local development needs:
-
APP_PASSWORD_HASH: an Argon2id hash of the login password. Vite expands$in.envvalues, so the dollar signs must be escaped. This prints a ready-to-paste line:node -e "require('@node-rs/argon2').hash(process.argv[1]).then(h => console.log('APP_PASSWORD_HASH=' + h.replaceAll('\$', '\\\\\$')))" 'your-password'
-
SESSION_SECRET: any long random string, for example fromopenssl rand -hex 32. -
ANTHROPIC_API_KEY: needed for capture, scaling, and conversion. -
The
R2_*values: needed only for photos.
The LITESTREAM_* values are only used in production.
From server/, npm test runs the unit tests, npm run check typechecks, and fly deploy
ships.




