Each morning at six, I get one email. It holds the weather, the calendar for today, what the house did overnight, the health of the server, the shopping list, the results of my teams, my golf numbers, what to watch tonight, and a three-phrase Spanish lesson. The voice is a man who lives in a shed in a Canadian trailer park.
Before this email, I opened six apps and assembled the same picture badly. The full system is one Cloudflare Worker. It runs on the edge, and no machine in my house must be awake for it to fire.

Here is the build. The interesting parts are not the parts that I expected.
Data comes in two directions, and that is the core design
Most of my infrastructure lives on a private mesh network. A Cloudflare Worker cannot reach that network. So the data flows the other way: cron scripts on my home server push snapshots out to the Worker over HTTPS, into KV, approximately 20 minutes before send time. The snapshots hold the server stats, the calendar, fitness, golf, the movie candidates for tonight, and the house status. The Worker never reaches in. The house pushes out.
Data that lives on the public internet gets the other treatment: the Worker pulls it live at send time. That covers the weather, the scores, and the shopping list.
That split decides what occurs when something breaks. Pushed data has a timestamp and a staleness threshold. Server stats go stale at 30 minutes, and the daily cards go stale at 26 hours. Pulled data answers in five seconds, or it does not answer. Two failure modes get two treatments.
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),
]);
A try { ... } catch { return null; } wraps each of those fetchers. Each renderer treats null as a valid state and prints an honest “no data” line. One dead source never kills the brief. The assembly step numbers the sections, and nothing hardcodes the numbers. When a section drops out, the other sections renumber, and nobody notices.
The model writes the voice. It does not write the facts.
I will defend this decision hardest.
There are two LLM calls. The main call, to 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 is all. Ordinary TypeScript functions render each factual section from raw data. The model never touches a number.
The model does not receive JSON. It receives a purpose-built plaintext digest. One format*ForPrompt() function per data source assembles the digest. Each function is null-safe, and each function marks its own freshness inline:
Randy: STALE (45m old).
So the model reads the same “STALE” and “no data” language that I read during debugging. When it hedges about the server, it hedges for the same reason that I would.
Both calls use structured outputs with a JSON schema. The previous approach asked nicely and parsed the reply. The earlier model sometimes dropped fields. A sibling worker asked for five picks and got three. No quantity of prompt polish repaired that. A schema constraint at the API level repaired it. The “extract JSON from prose” fallback still exists in the code, and it earned nothing in months.
Code computes the trends, because the model has no yesterday
A stateless LLM cannot notice the third dry morning in a row. So it does not have to.
The Worker keeps a rolling 30-day array of daily snapshots in KV. Before each send, a function compares today against that history. It produces sentences such as “3 straight dry, deck-worthy mornings” or “Tank down 3pts over 7d (61% → 58%).” Those sentences go to the model as a pre-digested block, with an instruction 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.
The philosophy for anomalies is the same. A pure function checks hard thresholds: a download queue over 50, an unhealthy container, a disk over 80%, or stats older than 30 minutes. It returns a structured object. That object drives the prompt and, independently, the rendering. So the status dots and the commentary of the model anchor to the same constants. “Is this a problem” is not a judgment that I delegated.
The anti-padding contract
The rules for the TL;DR in the system prompt 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, so the model can decline. Give a language model a slot with the name heads_up and no way to say “nothing”. You will then get a manufactured concern every morning, until you stop the reading of the email.
There is also a split between stable and volatile context. Biographical facts live in the system prompt and change on redeploys. The current events of my life live in a KV value. A POST /life updates that value, with no deploy. The prompt tells the model to treat the value as authoritative, and to never contradict it with stale assumptions.
The cron bug that sent the Friday email on Thursday
My favorite thing in this system is a scar.
The sibling movie-night worker had the schedule 3 21 * * 5. In standard cron, that is Friday. The email arrived on Thursday. The Cloudflare dashboard displayed “only on Thursday — Next: Thu, 21 May”. The dashboard was cheerful about it.
The cron parser of Cloudflare uses Quartz-style day-of-week numbers: Sunday is 1 and Saturday is 7. In Unix cron, Sunday is 0. The docs said otherwise. So 5 meant Thursday, and it meant Thursday the entire time. A second worker in the family also fired one day early, silently. Nobody noticed, because who checks that a weekly email arrived on the correct weekday.
The annual meeting of all cron authors, 1979: the theme for this year is Sunday. Zero, one, seven. Those are all perfect numbers for it.
The fix: never write the number. Use FRI. Each wrangler config in the family now carries the comment as scar tissue.
Two more scheduling traps came from the same era. When you deploy a Worker on the same day as its cron, the next cron cycle can silently skip. I reproduced it. The health check confirmed that the new version was live, and the scheduled run simply did not occur. A manual trigger operated correctly. And on the server side, scripts in /etc/cron.d do not inherit the PATH of your shell. A script called ZFS tools from /usr/sbin, failed quietly, and put wrong disk math in the brief for two days.
The daily brief avoids the daylight-saving problem in this way: it fires on two UTC crons, and a gate in code asks if the time is 6 AM in New York. That is a belt and suspenders. I use both, because the belt fell off before, and I have the proof.
Things that I found only when I looked
A skeptical second look, from new eyes with an order to refute and not confirm, found this: the dark mode was “token-inverted, not designed.” A fitness warning callout rendered light text on a light panel, at a contrast of approximately 1:1. The one sentence in the email with real safety relevance was invisible at night.
Mobile was broken for the entire life of the system. I found out when I finally opened the email on my phone and said “what happened to the font size?” The media queries were fine. The outer table had an intrinsic minimum width of approximately 520px, because of two nowrap cells in the masthead and the footer. Mail clients cannot reflow a table below its min-content width, so Gmail scaled the entire email to 57%. A script now renders the email at a 390px viewport and asserts the scroll width, before each deploy.
And a download-status cell held a hardcoded “100% / on-line” that was never one time real. It survived multiple redesigns. Decorative placeholder data is the most patient bug that there is.
The Spanish lesson, and why it is a slot
The newest section is a daily three-phrase Spanish lesson with a Leitner spaced-repetition deck. The boxes are at 1, 3, 7, 21, and 60 days, and the deck is a single JSON blob in KV. Due cards become quiz questions, and the direction alternates, so both recall paths get exercise. The last 80 phrases go into the prompt as a dedup list. The register is locked to conversational South Florida Spanish: the cafecito counter, contractors, neighbors, and golf.
The engineering detail that I like: the deck advances only on a real send. /preview builds the identical brief but never calls commit(). So I can hammer the preview endpoint all afternoon to debug the layout. That burns no vocabulary day and does not advance a card two times.
The module comment states the rule that the rest of the system follows: the brief must never fail because the lesson did. Day one tested that rule. A transient API error occurred during the first run. The section silently dropped, and the email went out on time. The deck was untouched, because nothing committed.
It is a slot, not a feature. To add another track — golf rules, or a dinner-table quiz for the kids — is its own KV deck and its own prompt, approximately one hour of work. The stated limit is two live tracks. That is approximately 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. The Spanish lesson runs on a small fast model, in parallel and not in sequence. The fourteen sources fan out in one Promise.all. Heavy data goes into KV in advance, not at send time. And the expensive model writes only six short strings, not the full email.
With an unlimited time budget, I would have asked a language model to write the whole email. That version would be slower, more expensive, and occasionally wrong about a number that mattered.
The version I read each morning keeps the facts boring. Code assembles them. Bubbles gets the jokes and a few lines of connective tissue. I can enjoy the voice because I do not have to wonder whether the weather or calendar came from the same imagination.