Skip to main content
freshness-bot.log
$cat freshness-bot.log
#43 published posts, one real read a day
$freshness sweep --corpus posts
[INFO] sweep: 43 posts, all external URLs HEAD-checked
[ERROR] cli.github.com/apt -> 404
[ERROR] cli.github.com/packages -> 404
[INFO] deep review: adding-draft-posts-to-velite
[WARN] velite-remark: package not found on npm
[INFO] next: least-recently-reviewed first, again tomorrow
#report-only; the human fixes the rot
luke@terminal:/blog$ cat building-a-freshness-bot-for-my-blog.md

Building a freshness bot for my blog

› 2026-10-04⏱ 4 min read

Published posts make factual claims about a world that keeps moving. Versions get bumped, CLI flags get renamed, URLs 404, tools get renamed. My publisher agent guards the seam before a post ships. After that, nothing re-checks a post from months ago until the staleness surfaces by accident, and some reader hits a dead link or a version that no longer exists and finds out my "current" guide is wrong.

So I built a daily job to re-check the published archive. The freshness bot.

The interesting part of this isn't the plumbing. It's the design decisions that came out of grilling the spec.

Two layers

Each run does two independent things over the corpus (my published top-level posts; drafts and the archive folder are excluded):

  1. A mechanical link sweep. A Node script, no AI involved. It extracts every external URL from every published post, HEAD-checks each (with a GET retry for the servers that reject HEAD with a 405 or 403), and tiers the results. Whole corpus, every run, in seconds. Zero npm dependencies.
  2. A rotating deep review. The publisher agent's freshness job handles exactly one post per run. It's the same agent that reviews posts before they ship, extended with a new job. The job reads the post all the way through and checks verifiable claims against the live world (npm registry, public pages), surfaces internal linking opportunities, and notes anything the sweep can't see.

The rotation applies to the reading work only. The sweep doesn't get slower. It has no context problem to solve.

Why one post a day

The first pitch for this missed a capacity constraint: the corpus is too big to review in one pass. A single review that reads all 43 published posts floods the reviewer's context and degrades attention. The more it has to hold, the less carefully it reads each post, which is the opposite of what I want from a deep read.

So the deep review reads exactly one post per day, least-recently-reviewed first. At 43 posts, that means every post gets a real read roughly every six weeks. Links are caught within a day of breaking, because the sweep runs every time. Claims within about six weeks.

That latency trade is deliberate, not a compromise I landed on. A dead link is a hard failure a reader will hit, so it gets caught fast. A stale version claim is a soft failure a careful reader might still work around, so a six-week window is acceptable.

The rate is a knob. If six weeks proves too slow in practice, the rotation speed is the thing to turn, not the design.

Where the state lives

The bot has to remember where it left off: which posts it's reviewed, and when. That state lives in a committed JSON file in the repo, written back to master every day.

I made two choices about it. First, it holds current state only, never findings history. Each post gets a last-reviewed date, a findings count, a one-line summary, and a link to the report. A couple hundred bytes per post, bounded by the post count forever. The git diffs of this file double as the audit trail of the review schedule, and the full findings live in the report issue where they belong.

Second, it's committed, not ephemeral. The next run knows where it left off without trusting a runner's local state or a chat history that might not exist anymore. A missing or corrupt state file falls back to first-run semantics, and the loader fails soft, so a bad state file never crashes the day's run.

The rotation rule is deterministic. Two runs against an unchanged state pick the same post, and state commits only after a successful review. A failed review posts a failure note and commits nothing, so a manual re-run re-selects the same post rather than skipping it.

What the first run found

The bot ran for the first time and found real rot, which is the whole point of building it.

The sweep flagged four broken links. Two were in my own gh issue view post: cli.github.com/apt and cli.github.com/packages, both 404 as written. In that post the /apt URL is the mistake; /packages is the corrected path it settles on.

The first deep review was the more interesting one. It read adding-draft-posts-to-velite and found a code block that imports MDXPlugin from a package called velite-remark. That package doesn't exist on npm. It 404s in the registry. A reader copying that snippet would fail at install.

That's the thing the link sweep can't see. It isn't a URL. It's a broken code sample in a post I shipped months ago, written against a version of the API that has since changed, and nobody caught it because the publisher agent only guards the seam before a post ships. The deep review caught it because it actually read the post and checked the claim against the live registry.

That's the payoff that justifies the six-week window.

What it won't do

The bot is report-only, permanently. It never edits a post, frontmatter, or the tag registry. Findings are fixed by human-directed work, which is me, looking at the report and deciding. It doesn't decide for me.

It's not on GitHub Actions. Hermes cron owns the scheduling, for fleet consistency with the other daily jobs on the minipc. The accepted trade is that if the minipc dies, so does the freshness coverage. That's the same trade every other cron in the fleet makes.

No findings analytics, not yet. The state file holds current state only by design, so findings history has no query surface. That's also the trigger I named for a later JSON-to-SQLite migration, if and when I want trends.

The bot runs daily. It sweeps the whole corpus for dead links and deep-reads one post at a time. The state file tracks the posts it has read, and every deep read so far turned up findings.

The six-week claim window is a bet I don't have an answer for yet. Whether it's the right latency, I'll know in a few rotations. If the findings stop being worth reading, the rate knob is there.

luke@terminal:/blog$ ls previous_post.sh