Every morning at six, I get one email. Weather, today’s calendar, what the house did overnight, the server’s health, the shopping list, whether my teams won, my golf numbers, what to watch tonight, and a three-phrase Spanish lesson. Written in the voice of a man who lives in a shed in a Canadian trailer park.

It replaced opening six apps to assemble the same picture badly. The whole thing is one Cloudflare Worker, it runs on the edge, and nothing in my house has to be awake for it to fire.

Here’s how it’s built, because the interesting parts aren’t the ones I expected.

Data comes in two directions, and that’s the core design

Most of my infrastructure lives on a private mesh network that a Cloudflare Worker fundamentally cannot reach. So the data flows the other way: cron scripts on my home server push snapshots out to the Worker over HTTPS, into KV, ~20 minutes before send time. Server stats, calendar, fitness, golf, tonight’s movie candidates, house status. The Worker never reaches in; the house pushes out.

Everything that lives on the public internet gets pulled live at send time instead — weather, scores, the shopping list.

That split matters because it decides what happens when something breaks. Pushed data has a timestamp and a staleness threshold (server stats go stale at 30 minutes, the daily cards at 26 hours). Pulled data either answers in five seconds or doesn’t. Two different failure modes, handled differently.

The fan-in is one Promise.all across fourteen sources:

const [randyRaw, calRaw, fitRaw, golfRaw, watchRaw, tideRaw, house, weather,
       shop, lifeContext, history, sports, staples, techPulse] = await Promise.all([
  env.RANDY_KV.get(RANDY_KV_KEY),
  // ...
  fetchWeather(env, location),
  fetchSports(),
  fetchTechPulse(env),
]);

Every one of those fetchers is wrapped in try { ... } catch { return null; }, and every renderer treats null as a legitimate state that prints an honest “no data” line. One dead source never kills the brief. Sections are numbered at assembly time rather than hardcoded, so when a section drops out the rest renumber and nobody notices.

The model writes the voice. It does not write the facts.

This is the decision I’d defend hardest.

There are two LLM calls. The main one — Sonnet — generates the subject line, the greeting, a verdict on the weather, the TL;DR list, an optional snark line, and the sign-off. That’s it. Every factual section in the email is rendered from raw data by ordinary TypeScript functions. The model never touches a number.

What it receives isn’t JSON. It’s a purpose-built plaintext digest, assembled by one format*ForPrompt() function per data source, each null-safe, each annotating its own freshness inline:

Randy: STALE (45m old).

So the model reads the same “STALE” and “no data” language I’d read while debugging. When it says something hedged about the server, it’s hedging for the same reason I would.

Both calls use structured outputs with a JSON schema, which replaced the previous approach of asking nicely and parsing whatever came back. The earlier model would occasionally just… drop fields. A sibling worker asked for five picks and got three. No amount of prompt-polishing fixed that; a schema constraint at the API level did — the “extract JSON from prose” fallback still exists in the code, but it hasn’t earned its keep in months.

A stateless LLM can’t notice that this is the third dry morning in a row. So it doesn’t have to.

The Worker keeps a rolling 30-day array of daily snapshots in KV. Before each send, a function diffs today against that history and produces sentences like “3 straight dry, deck-worthy mornings” or “Tank down 3pts over 7d (61% → 58%).” Those get handed to the model as a pre-digested block, with instructions to use one only if it earns a line:

if the data includes a trends block, weave the best one in where it fits — motion beats snapshots. Don’t force it.

Same philosophy for anomalies. A pure function checks hard thresholds (download queue over 50, any unhealthy container, disk over 80%, stats older than 30 minutes) and returns a structured object. That object drives both the prompt and, independently, the rendering — so the status dots and the model’s commentary are anchored to the same constants. “Is this a problem” is not a judgment call I’ve delegated.

The anti-padding contract

The system prompt’s rules for the TL;DR are the most-edited lines in the codebase:

2 to 5 items, SIGNAL-DRIVEN — an item must earn its line. A tight 2-item brief on a quiet day beats 4 padded ones; never manufacture an item. Randy gets a line only when something’s wrong or genuinely interesting — “Randy is fine” is NOT an item unless the day is otherwise empty.

The snark field is nullable in the schema specifically so the model can decline. Give a language model a slot called heads_up and no way to say “nothing,” and you will get a manufactured concern every single morning until you stop reading the email.

There’s also a split between stable and volatile context. Biographical facts live in the system prompt and change on redeploys. Whatever’s actually going on in my life right now lives in a KV value I can update with a POST /life — no deploy — and the prompt tells the model to treat it as authoritative and never contradict it with stale assumptions.

The cron bug that sent Friday’s email on Thursday

My favorite thing in this whole system is a scar.

The sibling movie-night worker was scheduled with 3 21 * * 5. Standard cron: Friday. It arrived on Thursday. The Cloudflare dashboard cheerfully displayed “only on Thursday — Next: Thu, 21 May.”

Cloudflare’s cron parser uses Quartz-style day-of-week numbering — Sunday is 1, Saturday is 7 — not Unix, where Sunday is 0. Their docs said otherwise. So 5 meant Thursday, and had meant Thursday the entire time. Another worker in the family had been firing a day early too, silently, and nobody had noticed because who checks whether a weekly email arrived on precisely the right weekday.

The fix is to never write the number: use FRI. Every wrangler config in the family now carries the comment as scar tissue.

Two more scheduling traps from the same era. If you deploy a Worker on the same day as its cron, the next cron cycle can silently skip — reproduced it, health check confirmed the new version was live, the scheduled run simply never happened, manual trigger worked fine. And on the server side, scripts in /etc/cron.d don’t inherit your shell’s PATH, so a script calling ZFS tools from /usr/sbin failed quietly and put wrong disk math in the brief for two days.

The daily brief itself dodges daylight saving by firing on two UTC crons and gating in code on whether it’s actually 6 AM in New York. Belt and suspenders, because the belt has demonstrably fallen off before.

Things I only found by looking

A skeptical second look — new eyes, briefed to refute rather than confirm — found that dark mode had been “token-inverted, not designed.” A fitness warning callout was rendering light text on a light panel at roughly 1:1 contrast. The one sentence in the email with actual safety relevance was invisible at night.

Mobile had been broken for the entire life of the system, and I found out because I finally looked at one on my phone and said “what happened to the font size?” The media queries were fine. The outer table’s intrinsic minimum width was about 520px — two nowrap cells in the masthead and footer — and mail clients can’t reflow a table below its min-content width, so Gmail scaled the entire email to 57%. There’s now a script that renders at a 390px viewport and asserts the scroll width, run before every deploy.

And in a download-status cell, there was a hardcoded “100% / on-line” that had never once been real. It survived multiple redesigns. Decorative placeholder data is the most patient bug there is.

The Spanish lesson, and why it’s a slot

The newest section is a daily three-phrase Spanish lesson with a Leitner spaced-repetition deck — boxes at 1, 3, 7, 21, and 60 days — stored as a single JSON blob in KV. Due cards become quiz questions with alternating direction so both recall paths get exercised. The last 80 phrases taught go into the prompt as a dedup list. Register is locked to conversational South Florida Spanish: the cafecito counter, contractors, neighbors, golf.

The engineering detail I like: the deck only advances on a real send. /preview builds the identical brief but never calls commit(), so I can hammer the preview endpoint debugging layout all afternoon without burning a vocabulary day or double-advancing a card.

Its module comment states the rule the rest of the system follows: the brief must never fail because the lesson did. That got tested on day one — a transient API error during the very first run, the section silently dropped, the email went out on time, the deck untouched because nothing had been committed.

It’s built as a slot, not a feature. Adding another track — golf rules, a dinner-table quiz for the kids — is its own KV deck and its own prompt, about an hour of work. The stated limit is two tracks live at once, roughly a forty-second read.

The constraint that shapes everything

A Cloudflare Worker gets 30 seconds. The Sonnet call takes 25 to 27 of them.

There is no headroom, and that single fact explains most of the architecture: why the Spanish lesson runs on a small fast model in parallel rather than in sequence, why all fourteen sources fan out in one Promise.all, why heavy data is pushed into KV in advance instead of gathered at send time, and why the expensive model only writes six short strings instead of assembling the whole email.

Constraints are good for you. Left alone with an unlimited time budget, I would absolutely have built something that asks a language model to write the entire email, and it would be slower, more expensive, and quietly wrong about numbers a few mornings a month.

Instead the computer does the facts and the model does the jokes, which is the division of labor that has worked best here so far.