Back to work

AI Automation · 2026

Weekly Ops Digest

A scheduled pipeline that turns a month of contact-form inquiries into a weekly read: the code counts, the model narrates, and a digest lands in my inbox every Monday.

Weekly Ops Digest
Role
Design Engineer
Tools
Claude API, Vercel Cron, Vercel Blob, Resend, TypeScript
Year
2026

The problem

The triage pipeline on this site solved the wrong half of the problem, and I didn't notice for a month.

Every inquiry now arrives sorted, scored, and with a draft reply attached. That's a good email. It tells me everything about one inquiry. What it cannot tell me is anything about the shape of the month: what people actually ask me for, which of those requests were worth answering, what I keep politely declining, whether the thing I'm quietly best known for is the thing I want to be known for.

Notification emails describe a tree. Nobody was describing the forest.

That's not a reporting problem, it's a business problem wearing a reporting problem's clothes. So the digest is the second half of the same system: the intake pipeline decides what each message is, and this one decides what a week was.

The pipeline

Capture, schedule, compute, narrate, deliver. Only one of those steps is allowed to be a model.

Persistence happens inside Next's after(), so it runs once the response is already on its way back to the visitor. It adds nothing to their wait, and — more importantly — it cannot turn a successful submission into a failed one. A logging step that can break the thing it's logging is not a logging step. It's a liability with good intentions.

The cron endpoint is a public URL, so it's treated like one: no Authorization: Bearer $CRON_SECRET, no digest, 401, goodbye. A weekly job that anyone can trigger by guessing a path is a weekly job that anyone can use to spam my inbox.

The model writes prose. The code does arithmetic.

This is the part I'd defend hardest, so I want to be blunt about it.

Every number in the digest — how many inquiries came in, how they split by type, the spread of fit scores, how many got a real triage versus how many fell back — is computed in TypeScript, from the records, before the model is invoked. Claude is handed the finished statistics and the per-inquiry summaries, and it is asked for exactly one thing: the words. A headline. A short narrative. Which leads look worth chasing. What to watch. What to do this week.

It is never asked to count. It is never asked to compare. It is never asked how many of anything there were.

A model that counts is a model that can be confidently wrong about your business.

The failure mode here is nastier than a bad sentence, and it's worth naming precisely. A model that miscounts does not miscount loudly. It produces "a steady week — four inquiries, mostly branding," and it reads exactly like the truth, in the same confident register as the truth, in an email you skim on a Monday morning while the coffee is still landing. There is no error to catch, no exception to log, no red in the dashboard. You just quietly believe a wrong thing about your own pipeline, and you keep believing it, and eventually you make a decision on it.

Arithmetic is the one thing a computer has been reliably better at than a person since before either of us was born. Handing that job to a probabilistic system — because the probabilistic system is right there and it's so easy to ask — is a genuinely bad trade. The stats are a reduce. They're twenty lines. They are correct by construction, every week, forever.

What a model is extraordinary at is the thing the twenty lines can't do: reading nine summaries and noticing that three of them are the same request in different words. That's a judgment about meaning, and it doesn't have a right answer I could have written a test for.

So the model gets meaning. The code keeps the numbers. Structured output enforces the boundary — the schema has no numeric fields for the model to fill, which means it has no opportunity to invent one.

What isn't stored

Each triaged inquiry becomes one private JSON blob: timestamp, name, email, project type, inquiry type, scope signal, fit score, and the short summary.

The draft reply is not stored. Neither is the visitor's raw message.

This wasn't a storage optimization — the whole corpus is a rounding error on disk. It's that I don't need a stranger's words sitting on a disk somewhere in order to know what they wanted. The classification already tells me that. Keeping the raw text would mean keeping an archive of everything anyone ever confided to a contact form on the off chance a future feature might want it, and "we might want it later" is how you end up custodian of a liability you never chose.

The model's prose doesn't survive either. The draft reply was written for one moment — the moment I answered — and it has no business in a permanent record. What persists is the classification, not the generation. The judgment, not the transcript.

The blobs are private, not public-with-an-unguessable-URL. The records contain names and email addresses; the correct access control for that is access control.

The email is the heartbeat

An empty week still sends a digest.

Not skipped. Not suppressed. Not "nothing to report, we'll catch you next Monday." A digest arrives, it says zero inquiries came in, and if there's nothing to narrate the model isn't even called.

This looks like a rough edge until you sit with the alternative. If the digest only shows up when there's something to say, then a silent Monday has two possible meanings: a quiet week, or a dead cron. And you cannot tell them apart. Worse — you don't try to tell them apart, because silence is the same thing you saw in every genuinely quiet week before it, and the human brain files it under "normal" for free. A scheduled job can be broken for three months and never once look broken.

So the email is not just a report. It's a liveness check that happens to contain a report. The digest arriving is the signal; what it says is the payload.

The same reasoning is why a failed model call doesn't cancel the send. If Claude times out, errors, or the API key is missing, the digest goes out anyway — with the real numbers, and a note saying the narrative is unavailable. The stats are the part that was always true. The prose was the enhancement. An enhancement that can take down the thing it enhances was never an enhancement.

What it demonstrates

It ships this week. It has not had its first real Monday yet, and I'd rather tell you that than dress up a number I don't have. There's no "reduced triage time by 40%" here, because there isn't a week of production data to compute one from, and inventing one would undercut the entire argument I just spent a page making about which parts of a system are allowed to make things up.

What I can show you is the shape, and the shape is the transferable part:

Capture what happened, at the moment it happens, out of the request path. Schedule the read on a cadence that matches how you actually make decisions. Compute every fact deterministically. Narrate only the part that needs a reader, with a bounded model call against a fixed schema. Deliver idempotently, so a retry can't double-send. And put a fallback under every step, so the pipeline degrades from excellent to useful instead of from excellent to silent.

Swap the contact form for a support queue, a CRM, or a sales pipeline. The records change. The stats change. Not one thing about the architecture does.

Most "AI reporting" I've seen hands the model a pile of rows and asks it to be an analyst. That gets you a fluent, plausible, occasionally fictional summary of your own business — which is worse than no report at all, because you'll act on it. Do the arithmetic in code, and the model becomes what it's actually excellent at being: the writer, not the accountant.

Next

Zendenta — Dental Clinic Management

Zendenta — Dental Clinic Management