I subscribe to 18 AI and engineering newsletters. In a normal week they produce somewhere between 85 and 90 threads. I was reading maybe four of them.
The obvious fix is to summarize them, and the obvious way to do that is to paste things into a chat window every Monday — which is just a worse version of reading them. What I wanted was for the summary to exist without me being in the loop at all. What runs now: every Monday at 9am my time, a scheduled agent searches my Gmail, reads the week’s issues, writes a structured digest, and pushes it to master. GitHub Actions deploys the site. I find out it happened because the post is on the blog.
This is a note on how that’s wired, and — more usefully — on the five things that broke in ways I didn’t notice for weeks.
The shape of it
There are three moving parts, and the split between them is mostly about who holds which credentials.
A scheduled cloud agent runs on a cron (30 3 * * 1 UTC). It has a Gmail connector attached and clones this repo. It does the reading and the writing.
A runbook committed to the repo at scripts/newsletter-digest.md. This is the part I’d underweight if I were describing this to someone quickly, and it’s the part that matters most. The agent’s own instructions are one line: follow the runbook. Everything else — the sender allowlist, the search query, the structure of the post, the failure modes — lives in a markdown file under version control.
GitHub Actions for deploys. The agent has no AWS credentials, so it can’t run yarn deploy the way I do locally. Instead it commits to master, and .github/workflows/deploy.yml picks that up, builds, uploads to S3, and invalidates CloudFront.
That last split is deliberate. The workflow authenticates as a dedicated IAM user whose entire permission set is four S3 actions on one bucket plus cloudfront:CreateInvalidation on one distribution:
{
"Sid": "InvalidateCloudFront",
"Effect": "Allow",
"Action": "cloudfront:CreateInvalidation",
"Resource": "arn:aws:cloudfront::<account>:distribution/<distribution-id>"
}
The policy JSON is checked into the repo, so if the access key is ever lost I can recreate the user from the file rather than reconstructing what it needed by trial and error. An automated thing that pushes to production should hold the smallest possible set of permissions, and you should be able to see that set without opening the AWS console.
Why the instructions live in the repo
The instinct is to put the prompt in the scheduler’s config box. Don’t. Put it in a file next to the code.
Every hard-won detail below is a line in that runbook now. When something breaks, the fix is a commit with a message explaining why — so the next failure gets diagnosed against a written record instead of my memory of what I think I configured in March. The runbook has grown from a page to about 220 lines, and nearly every addition came from something going wrong.
It also means the automation is reviewable. I can read a diff of how my own pipeline changed.
The five things that broke
1. Gmail matches the envelope address, not the display name
This one cost the most. My allowlist had an entry for a newsletter that reads as “MyClaw” in my inbox, and I’d written the address as MyClaw@aisecret.us — display name from one newsletter, domain from another.
That address matches no mail at all. Gmail’s from: operator matches the actual envelope address. So the query silently returned nothing for that sender, and a newsletter I’d deliberately subscribed to was absent from every digest for months. Nothing errored. The digests just quietly had less in them.
It turned out to be two different daily newsletters that share some branding history — AI Secret and MyClaw, on separate domains. Both are on the list now, correctly.
The lesson isn’t “be careful typing addresses.” It’s that a filter that silently matches nothing is indistinguishable from a filter that matches nothing because there was nothing to match. Confirm a new entry actually returns mail before you trust it.
2. A sender count is a health check
Related, and the thing that would have caught the above months earlier: I now record the expected result count in the runbook. Roughly 85–90 threads a week across 18 senders. The old 9-sender list returned 38.
If the search comes back well under that, something changed — usually a sender’s envelope address. The instruction is explicit that a smaller digest is a symptom to investigate, not a light week to accept. Diff the senders actually returned against the allowlist rather than shrugging.
Any recurring job that filters a stream should assert on the volume it expects. Otherwise a broken filter looks exactly like a quiet week.
3. Read the plaintext, not the HTML
Newsletter HTML bodies run 80,000 to 160,000 characters each. Multiply by ~90 and the context budget is gone before any thinking happens.
Gmail’s API gives you a plaintextBody alongside the HTML. Extracting only that field is the difference between the job working and the job dying halfway through:
jq -r '.messages[] | "SUBJECT: \(.subject)\nFROM: \(.sender)\n\n\(.plaintextBody)"' <saved-file>
There’s a related workflow point: with ~90 threads you can’t hold everything at once anyway. Triage by subject line first, then read bodies in full only for what clears the bar.
Substack issues are often paywall-truncated in plaintext — you get a teaser. That’s expected and fine; the teaser plus the subject is usually enough to know whether the item is worth a bullet.
4. The same mail arriving twice
Some newsletters reach me at two addresses that both land in the same inbox — an old one and a current one. One sender does this on every single issue, about fifteen minutes apart, identical subject.
Without deduping, the same story gets double-weighted, as though two independent newsletters had independently decided it mattered. That’s actively misleading, because recurrence across different sources is exactly the signal I use to decide what leads the news section. A duplicate doesn’t just waste tokens; it corrupts the ranking.
Dedupe by subject line before summarizing, not after.
5. Weekly cadence does not prevent repetition
This one surprised me. I assumed that summarizing a different week of newsletters would naturally produce different content. It doesn’t.
Two consecutive digests ran deep dives on effectively the same ground — one on why switching to smaller models doesn’t lower your inference bill, the next on the inference optimizations that actually move the needle. Both were reasonable summaries of what the newsletters covered those weeks. Together they were repetitive, because the newsletters themselves cycle through the same themes: inference cost, RAG evaluation, agent loops, quantization.
The fix is to make the previous output an input. Before drafting, the agent reads the deep-dive headings from the last two digests:
ls -t src/content/blog/weekly-ai-digest-*.md | head -2 | xargs grep '^###'
Then: same topic with nothing new, drop it however well it was covered. Same topic with a real development, keep it but frame it as an update and make the delta the point. Same running news story, one bullet on what changed rather than re-summarizing the arc.
Anything that generates content on a schedule needs to know what it generated last time. Otherwise each run is independently sensible and the sequence is bad.
Two smaller constraints worth writing down
A hard character cap on the description. The blog’s card component renders description on the tiles, and it doubles as the SEO meta description. Long ones made the tiles ragged, so the runbook caps it at 178 characters and says to count. This is a layout constraint, and it’s independent of post length — a 4,000-word digest still gets a ≤178-character description.
Rotate the hero image. Six consecutive digests used the same picture before I noticed, which made the blog index look broken. The runbook now carries a table of on-theme images with the recently-used ones marked, and an instruction to pick an unused one.
Neither is interesting on its own. Both are the kind of thing that degrades quietly, which is the theme of this whole post: automation fails softly. Nothing throws. The output just gets a little worse each week until you look at it properly.
What I’d keep
If I were rebuilding this, the parts I’d carry over aren’t the model or the scheduler. They’re:
- The instructions live in the repo, in version control, next to what they operate on.
- The credentials are split so the automated path holds the minimum, and that minimum is written down as a file.
- The job asserts on the volume it expects, so a silently broken filter looks like a failure instead of a quiet week.
- The job reads its own previous output before producing the next one.
The generated digest is the least interesting artifact here. What made it worth keeping was writing down each way it broke, in the same repo as the thing itself.