
An editorialmachine witha humanhand on it.
A Ghost publication on a theme we built, an agentic content pipeline that fills it, an iOS app that reads the same structured data the theme renders, and the infrastructure all three run on. We designed it, we operate it, and we are the ones who get paged.
Ongoing: we run the pipeline, the theme and the infrastructure
- Foundation
- Ghost 6, on a theme we build
- Custom templates
- Sixteen
- One recipe, rendered by
- Search, the site, and the app
- App status
- In TestFlight, submitted for review
This is a Ghost site first. Everything else exists to serve it: the pipeline creates drafts in Ghost, the social scheduler distributes what Ghost publishes, and the iOS app reads Ghost through its content API.
One decision shaped all of it: make a recipe a piece of structured data rather than a page. Everything after it follows.
The theme
One recipe feeds search, the site and the app
Every recipe post carries a schema.org Recipe block: ingredients, method, times, yield, nutrition. Three separate consumers read that same block. Search engines read it for rich results, which is the entire organic acquisition strategy for a recipe publication. The theme reads it in the browser to build the card a reader actually cooks from. The iOS app parses it out of the content API and renders it natively. One canonical representation, three renderers, no second database. Change an ingredient and it changes in all three, because there is only one place it was ever written.
Cards render from structured data, not post fields
The recipe card is a Handlebars partial with no content in it at all: structure, hydrated in the browser from the structured data. That is what makes the previous point true. If the template rendered ingredients from post fields, the post would be the source of truth and the structured data would be a copy that drifts.
Parsing schema.org when the real data is messy
Schema in the wild is looser than the spec suggests. The type can be a string or an array; the block can be a single node, a graph, or a bare top-level array. All are valid and all occur, so the parser accepts every one. If the card lands on a page whose structured data is missing or malformed, it removes itself rather than showing empty Ingredients and Instructions headings above a Print button that prints nothing. A missing card beats a broken one.
Sixteen custom templates on a commercial base
The base is a commercial Ghost theme, and we will be straight about that, because the work worth describing is not the typography. It is the sixteen custom templates on top: recipe posts, meal plan posts, guided programs, the hubs those sit in, membership and premium landing pages, sign-in, sign-up, account, tag archive, and several post header treatments. Around 125 commits, roughly 1,700 lines of JavaScript and 5,000 of stylesheets.
Working around a silent 100-row query limit
Ghost's template query returns at most 100 rows and ignores a request for more, with no pagination and no warning. The publication carries about 490 tags, so the category index chunks its query by first letter using starts-with filters, keeping every block under the cap and stating the limit explicitly rather than relying on a silent default.
The pipeline
The brief was a publication that could hold a real posting cadence across the website and six social networks without a staff. The obvious build is a bot that writes and publishes. We did not build that, and the reason is the whole point of the system.
An automated publisher that is wrong is not embarrassing in the moment. It is embarrassing for as long as it takes somebody to notice, on a site with real readers and a real person's name on it. So the pipeline automates everything up to the decision and nothing past it.
- Scheduled runGitHub Actions, OIDC into AWS
- Draft the postClaude, per content type
- Make the imageryImagen 4, then sharp to WebP
- Validation gates
- /Taxonomy contract
- /Recipe JSON-LD shape
- /Duplicate ledger
- Ghost draftNever published by a machine
- Approval cardSlack, allowlisted people only
- Published to the siteWeb only, mails nobody
- Scheduled to socialPostiz, six networks
What the machine cannot do
These are not settings. They are structural: each one is a thing the code has no path to do.
- Cannot
A scheduled run cannot publish.
Every orchestrator ends at a draft. There is no code path from the cron trigger to a live post. The only thing that publishes is a person pressing a button.
- Cannot
Publishing cannot email the subscriber list.
The publish function is web-only and does not carry the parameter that mails anyone. A second, separate function is the only thing that can mail the list, and its single caller is a human sending the weekly recap. Two functions rather than one function with a flag, precisely because a flag is one typo away from mailing every subscriber on every recipe.
- Cannot
Being in the room is not authorization.
Every button that publishes, spends on model calls or edits a post checks an explicit allowlist and refuses everyone else. Membership of the workspace is not a trust boundary; treating it as one means the access control list is whoever happened to get invited to a channel.
- Cannot
Nothing reaches social until the captions have been read.
Scheduling generates per-network copy and then stops on a preview. Nothing is created in the scheduler until somebody confirms it, and any network that already carries that post is pre-unchecked, so the same thing cannot go out twice.
One taxonomy, held in place by a test
Four surfaces name a category: the site, the newsletter, social routing, and the theme's own archive template. All four read one file, the single source of truth for the taxonomy. The contract is strict because routing depends on it. A post carries exactly one section tag and it has to be first, because the first tag is what distribution keys on.
A drift test asserts that contract across all four surfaces at once, parsing the theme and reading the live site navigation to do it. The four cannot import each other, so nothing structural stops them drifting apart. The test is what does.
Design decisions
Candid photos beat professional ones
The instruction that produces a believable food photograph is the opposite of the obvious one. Not crisp, bright and professional, which reads as synthetic at a glance, but a candid homemade phone photograph: natural window light, imperfect, crumbs and drips, not studio. Style is picked deterministically from the dish, so the feed varies while rerunning the same recipe produces the same image choice. Reruns stay predictable, which is what matters in a pipeline you have to debug.
One affiliate registry for site and newsletter
Product links are generated from a registry that lives in the pipeline repository rather than the theme, which looks backwards until you notice that the weekly newsletter has to build the same links, and email clients run no theme JavaScript. One registry and one matcher feed both surfaces, so the same sentence links to the same product in the email and on the page. The generated theme file carries a fingerprint of its sources, and a test asserts that the fingerprint in the shipped bundle is current, so a regenerated registry cannot go out without its rebuilt bundle.
Ads at section breaks, requested lazily
In-content advertising is placed immediately before a section heading, so it reads as a break between sections rather than something dropped mid-thought, and never beside the recipe card. Each slot is handed to the ad network only when it comes near the viewport, so a reader who bounces off the introduction never paid to download three ads they scrolled past. The signed-in member check is the absence of the configuration rather than a second condition: the config renders nothing at all for a member, so there is one place that decision is made.
A companion planner instead of forking the scheduler
Scheduling published articles out to social needed a visual board, and the scheduler has no plugin system, so extending it would have meant forking and maintaining a container image. We built a small companion application beside it instead, driving it through its API, reachable only through the reverse proxy and authenticated against the scheduler itself. It shows every published article against live per-network status, generates captions with a model, and creates the scheduled posts after a human edits them.
Why we did not build Google sign-in
Single sign-on into the scheduler looked like an afternoon. It is not. The provider model is one authentication method per account, so linking an existing account breaks its password login outright; the sign-in flow reuses an unrelated integration client and redirects to a page that is itself behind authentication, so a logged-out user never completes it; and registration is disabled by design. Making it work would mean patching the callback and forking the project. We wrote down why, and left password authentication alone. Per-network connections still use OAuth, because there they genuinely work.
Billing on the web to stay inside App Store rules
Membership is billed through Stripe on the website. The app reflects what somebody has paid for and never sells anything, which keeps it inside the reader-app rules and keeps a payment platform out of the mobile release cycle. The gating ships switched off: every gate is currently inert, and stays that way until the tiers are deliberately turned on.
Shipping and operating it
Merging to main zips the working tree and posts it to the production publishing API. There is no staging environment, a deliberate trade for a one-person operation, which means the discipline lives in the repository instead.
Compiled bundles are committed artifacts rather than built in CI, so the standing rule is that a source change ships only when its rebuilt bundle ships with it, and that rule is on the first screen of the repository rather than buried. A verification script runs before every push: required templates present, every script syntax-parsed, and the platform's own theme validator run as a ratchet, where any violation not already on the known list blocks the push.
Continuous integration reaches the secret store through federated identity rather than a stored key, so there is no long-lived credential to leak from the pipeline and nothing on either host holds a copy of anything. There is an operator runbook written for somebody who did not build it and is looking at it under pressure at two in the morning: symptom, which host, what to do. The failure modes that cost real time are written down as failure modes, including the silent ones.
The iOS app
React Native and TypeScript, reading the same content API the website is built on, and parsing the same structured recipe the theme renders. Currently in TestFlight and submitted for review.
How the app is built
Offline-first, for a kitchen with no signal
Reads go through a cache with a time-to-live: inside the window it serves from cache; past it, it refetches and falls back to the expired cache on any network error. Favorites, the shopping list and the meal plan live on the device. The result is that a kitchen with no signal is a working app rather than a spinner, which is the actual context this gets used in. The content API also caps every page at 100 rows, the same cap that broke the category index, so the client paginates rather than trusting a request for everything.
Feature flags and automatic degraded-service detection
Each area of the app is gated by a flag combining an owner override, remote configuration and a shipped default, then combined with automatic degraded-service detection, where repeated network failures mark a capability degraded on their own. Both layers are offline-safe and neither can throw or block a screen. The standing rule: a new feature ships with its flag, its gate, its health wiring and a three-case test, or it does not ship.
Release signing without a shared certificate repo
Releases authenticate and sign through an App Store Connect API key with automatic provisioning, rather than a shared certificate repository, which does not work on this account. Version comes from the git tag and the build number is derived from what is already uploaded, so two people cannot collide. A tag produces a TestFlight build; submission is a deliberate, separate action.
Stack
- Ghost 6
- Handlebars, sixteen custom templates
- SCSS and Gulp
- schema.org structured data
- MySQL
- nginx
- Stripe memberships
- Node.js and TypeScript
- Claude
- Imagen 4
- sharp
- GitHub Actions on a schedule
- AWS Secrets Manager over federated identity
- Slack approvals
- Postiz, six networks
- React Native
- TypeScript
- Offline-first cache
- Feature flags
- App Store Connect API signing
- TestFlight





