BOOK A CALL

All projects

Case study

Cookbook Press

Recipes saved off food blogs and TikTok, parsed by a model running at home. The library lives on the phone; the server stores nothing.

Platform
iOS
Stack
SwiftUI · SwiftData · FastAPI · a local LLM
Status
Builds and runs; API live
Scale
2,000 lines Swift · 1,150 Python · 69 validated feeds

The problem

Recipes live on food blogs wrapped in a thousand words of preamble, and increasingly on TikTok where they live in a caption and a voiceover. Saving one means either a bookmark that rots or copying it out by hand. The existing recipe managers either want a subscription or send every page you read to someone else’s server.

The decision worth defending

Deterministic first, model second

The obvious build is “throw the page at an LLM.” That is worse in every measurable way for the majority of pages, and the measurement is in the repo: about 58% of the catalog publishes schema.org recipe data, which gives exact title, times, yield, ingredient lines and steps.

So the pipeline is ordered. Structured data if the page has it — strictly better than inference. The model then splits ingredient lines and picks a category from that structured data. Only pages with no structured data at all get read by the model as prose. The model is used for the two things parsing genuinely cannot do, and everything else is parsing.

This has a second benefit that matters more than it sounds: the deterministic path can be exercised against live pages with the model server switched off. Most of the extraction logic is testable without a GPU in the loop.

Decisions

  1. A model that returns a different ingredient count than it was given is rejected

    The structured lines are ground truth. A silently merged or dropped ingredient produces a recipe that looks fine and cooks wrong; an unsplit list is visibly less useful but never lies. The split is discarded and the recipe saves with a warning.

  2. Every ingredient keeps its raw line

    The original text as the site wrote it is stored alongside the parse, so a bad split is recoverable rather than destructive.

  3. Categories are a fixed enum of ten

    The model must choose from it. A free-text category would fragment the library into “Dessert”, “Desserts” and “Sweets” inside a week.

  4. The share extension is the product

    Safari runs a content script first and hands the extension the live DOM, which defeats the bot walls that would refuse a server-side fetch of the same URL. A large fraction of food blogs will not serve their page to a datacentre — and the phone is already logged in and already trusted.

  5. Feeds are proven rather than trusted

    Nothing gets into the source list without a live fetch proving it parses and carries items, using the same user agent the running server uses — so a feed that validates also works in production.

Privacy posture, stated plainly

The server is stateless: it fetches, extracts, returns. There is no database, no user record, no history. The library is on the phone. The model runs on a machine in the house.

The honest caveat is documented rather than hidden: a share extension cannot complete an interactive login, so there is no access proxy in front of the API. A shared key is the only guard between a public hostname and that machine’s GPU. That is a considered trade for a single-user app, and it is the one secret that must not leak.

Engineering notes

  1. Relationship arrays do not preserve order

    Ingredient order is carried by an explicit field; reading the relationship directly scrambles the method. This is the kind of bug that produces a recipe which is subtly, deniably wrong.

  2. Two processes, one schema

    The share extension compiles the app’s models, services and design directories into its own target rather than duplicating them.

  3. A project name saved another project

    The compose project name is pinned, because it otherwise takes the name of its directory — which already belonged to a different live stack on the same host. Sharing the name would let a teardown here stop another app’s backend.

Where it stands

The app builds and runs, the API is live behind a tunnel, and the extraction path is exercised against live pages by two tools in the repo.

Screens

The screenshot that makes the architecture visible. Under the title: “Read from the site’s own recipe data”, and a warning that ingredient amounts were not split because the model pass did not run. The app tells you how it knows what it knows.
The screenshot that makes the architecture visible. Under the title: “Read from the site’s own recipe data”, and a warning that ingredient amounts were not split because the model pass did not run. The app tells you how it knows what it knows.
The feed, pulling live from the validated source list. The coloured rule on each row is per-source.
The feed, pulling live from the validated source list. The coloured rule on each row is per-source.
The library, shelved by a fixed ten-value category enum. The type-led design is deliberately unlike the feed: the library is the part you own.
The library, shelved by a fixed ten-value category enum. The type-led design is deliberately unlike the feed: the library is the part you own.
The 69 sources, each tagged by kind. Nothing is in this list that did not survive a live fetch proving it parses and carries items.
The 69 sources, each tagged by kind. Nothing is in this list that did not survive a live fetch proving it parses and carries items.