I own about ten domains. Two of them are things I actually want to grow — DailySpin, a daily music blog, and Southbound, the marketing site for my iOS app. The rest are personal and family domains that mostly exist so nobody else has them.

All ten sit behind Cloudflare, which means all ten have been generating analytics this entire time. Traffic, threats, bot activity, referrers, the works. I looked at almost none of it.

Not because I didn’t care. Because looking requires remembering to look, and a dashboard is a place you have to go. Email arrives whether you remember or not. So I built the thing that goes to the dashboard for me and writes back in sentences.

The teaching mandate

The one new idea in here started as an admission: I was new to web analytics. I could read “bounce rate” and not really know whether mine was bad.

That single fact changed the whole product. The system prompt opens with it:

Ryan is new to web analytics, so explain concepts naturally when they come up — don’t assume he knows jargon like “bounce rate” or “UTM parameters” without briefly explaining what they mean and why they matter.

An analytics tool built to report to an expert produces a dashboard. An analytics tool built to teach produces something else entirely — it defines the term the first time it uses it, then tells you whether your number is good. Six months later I don’t need the definitions anymore, which means the tool worked.

The rest of the prompt is mostly anti-padding rules, which is where most of the editing time went:

Only mention zones with noteworthy activity. “All quiet across all 10 sites” is a perfectly valid report. Don’t waste Ryan’s time with normal traffic patterns.

And on recommendations, the rule that stops the model from producing consultant mush:

Be specific (“write more posts about 90s alt-rock artists” not “create more engaging content”).

Every section is allowed to be one line. A quiet day should produce a short email. If you don’t say that explicitly, you get four paragraphs about nothing every single morning, and within two weeks you stop opening it.

The number I was measuring was the wrong number

This is the finding that changed the product.

The first version pulled everything from Cloudflare’s GraphQL analytics API. Requests, page views, threats, country breakdowns — real data, straight from the edge. And it was reporting numbers that meant nothing.

Cloudflare’s edge sees requests. That’s bots, crawlers, uptime monitors, favicon fetches, CSS files, and — somewhere in there — humans. A page with a stylesheet, three images, and a font is six requests from one person, and that’s before the AI crawlers show up. My “traffic” was mostly machines fetching assets.

The fix was adding a real analytics tool that counts humans, and then teaching the prompt that these two data sources are not the same species. There’s now a ground-truth clause at the top:

Umami = humans, CF edge = bots + crawlers + assets — never conflate. If Umami says 3 humans and CF edge says 196 requests, that’s normal.

Those numbers are real, and they’re mine. Three humans. A hundred and ninety-six requests. If I’d kept optimizing against the big number I’d have spent months feeling great about a website nobody was reading.

The current version reports in tiers: two sites get deep human analytics, two more get a numbers-only glance, and the edge data survives as an explicitly-labeled appendix — bots and assets, not readership. A couple of toy projects got dropped from the email entirely, because a site with no goal doesn’t need a daily report.

Tell the model what shipped

A subtler failure: the prompt described Southbound as having a waitlist. Then the app actually launched, the waitlist went away, and the model — reasoning perfectly from its instructions — kept recommending I optimize a signup form that no longer existed.

So the prompt grew a product-state block, stated as ground truth: the app is live, there is no waitlist, the funnel is page view → store click → install, never recommend a waitlist form. Facts about your own product drift faster than anything else in the prompt, and a model has no way to notice. It’ll keep giving you excellent advice about the thing you used to be building.

When a workflow tool wins, and when it loses

I built this in n8n — a visual workflow tool — and I have a sibling system, my personal morning brief, that does something structurally similar in hand-written code on a Cloudflare Worker. Same shape: gather data, hand it to a model, render an email. Different tool. Both choices were right, which is the interesting part.

n8n wins when the job is gluing. This workflow is eight nodes: schedule, build queries, fetch edge data, fetch human analytics, build the prompt, call the model, format, send. Most of the value is in credential storage, retries, and a UI where I can click a node and see exactly what JSON came back at 5 AM. Debugging a data-shape problem in a visual runner is genuinely faster than adding log statements and redeploying.

Code wins when the logic is the product. The personal brief has fourteen data sources, per-source staleness rules, trend computation across a rolling window, and a schema-constrained model call — that’s a program, and a program wants version control, code review, and tests. Expressing it as boxes and arrows would just be decoration.

The dividing line I’d give someone else: if the hard part is reaching the services, use the workflow tool. If the hard part is what you do with the data once you have it, write code.

And then there’s the deployment story, which is why I’d think twice.

The workflow that ran old code for two days

n8n compiles a workflow into memory and uses a version identifier as its cache key. Importing a new workflow file via the CLI updates the stored definition — but doesn’t bump that version identifier. So the scheduler keeps running the old compiled code, through restarts, indefinitely.

Two consecutive morning sends ran stale code while the database plainly showed the new code. Nothing errored. The email arrived on time, looking correct, generated by a version I thought I’d replaced.

It got worse before it got better. On a later version, editing the stored definition directly stopped reaching the runtime at all — schedule triggers silently never fired, webhooks never registered, and the boot log printed “Activated workflow” the whole time. The edit was even visible in the editor UI. Only the thing that actually runs ignored it. I burned about three hours across two sessions watching a workflow not fire at times I’d carefully scheduled.

The only reliable deploy turned out to be an app-layer save — going through the application’s own API so the server bumps the version itself and re-registers the schedule. The log line you want to see is the one about deregistering crons, because that means it’s recompiling.

That’s the real price of a visual workflow tool: the state that matters lives in a database, not in your files, so “I changed it” and “it’s running” are two separate claims, and only one of them is easy to check. A command that lists active workflows will happily lie to you after an import cycle.

The other silent failures

The pattern continues down every layer:

  • fetch() inside a workflow code node silently does nothing — it doesn’t fail, it just doesn’t happen. I found it because a workflow was running perfectly and accomplishing nothing. You have to use the runner’s own HTTP helper.
  • Environment variable access is blocked in those same sandboxed nodes, also silently.
  • Sending a request body by stringifying an object double-encodes it. The receiving database wrote rows with every field null and returned success.
  • A daily sync workflow broke because a Python container resolved localhost to IPv6 first, and the target’s IPv6 path returned server errors on exactly one kind of query. The same code, run from the host, worked fine. It failed quietly for a while before anyone noticed.

Every one of those looked like working.

And the duplicate that hid for ten days

My favorite, because it’s so stupid.

When I rebuilt the workflow with human analytics, I ran the new version alongside the old one to compare. Both sent to the same address, from the same address, with the same subject line format.

Gmail threaded them as one conversation. For ten days I was getting two analytics emails every morning and seeing what looked like one, because the client helpfully collapsed the duplicate out of view. I only caught it when a number didn’t match itself.

If you’re running two versions of anything in parallel, make them look different. Your email client is optimizing for tidiness, not for your ability to notice that you built the same thing twice.

What it still doesn’t do

It doesn’t close the loop. It gives me one specific recommendation per site per day, and nothing anywhere tracks whether I acted on it or whether it worked. The right version keeps a rolling memory of its own advice and grades itself the next week — a pattern I’ve already built elsewhere and haven’t gotten around to here.

It also can’t see anything past the click. Store clicks are the end of my visibility; what happens in the App Store is a different system with a different login I don’t check either.

Which, now that I write it down, sounds like a job for tomorrow morning’s email.