# Nauracast

> A two-host podcast generator on Cloudflare. A seven-stage Workflow turns a list of sources into a twelve-minute briefing for about a quarter an episode, cheap enough for an audience of one.

- Source: https://nabkey.com/tinkerings/nauracast
- Author: Chris Nabkey
- Published: 2026-08-09
- Built: 2026
- Status: wip
- Stack: Cloudflare Workflows, Workers, D1, R2, Containers, Gemini, TypeScript
- Project: https://nauracast.com

Every podcast had to be worth somebody's time to make, so shows get made about
topics broad enough to pay back the hours. The NFL gets forty podcasts, and
nobody makes one about the four open-source projects you actually depend on. The
topics I care about are too narrow to be anybody's job.

An episode of Nauracast costs about twenty-five cents to produce. At that price
an audience of one works.

The name was a guess. I picked it thinking *aura* meant sound; it actually means
a breeze, from the Greek for a breath of moving air. Close enough, since sound
is moving air. The word for ear is *auris*, where *aural* comes from, and the
two aren't related at all. Names are hard.

## The proof of concept

I started with a town, because everything a local news show says can be checked
against somebody's published reporting. [Grand Rapids
Daily](https://nauracast.com/grand-rapids/) has published 58 episodes:

- Nine to sixteen minutes against a fifteen-minute target, median a bit over
  twelve.
- Monday, Wednesday and Friday at 6:06am Eastern. The first week it went out
  daily.
- Recent titles include *DASH Bus Expansion, West Nile Warnings, and Rapid
  Daylight Loss* and *Severe Storms, Casino Milestones, and the Tart Cherry
  Crisis*. *GRPS Cell Phone Ban*, a local school district story, would never
  clear the bar for a national feed.

The length doesn't consume a whole commute, and I can listen in quick while I'm
going to grab something from the grocery store, or picking a kid up from an
activity.

I do listen to it. Not instead of the shows I follow; when those run out, this
is what's left in the queue. The episodes that earn it are about something I'd
half-heard about and was never going to chase down. On April 23rd it spent a
full segment on [replacing the Sixth Street
Dam](https://nauracast.com/grand-rapids/episodes/20260423-080647-severe-weather-alerts-the-sixth-street-dam-repla.mp3)
with an adjustable sea lamprey barrier, part of the Grand River restoration. I
now know why the lamprey matter without reading six articles.

## Architecture

<figure class="diagram">
  *Diagram: Nauracast architecture: configured sources such as news RSS, a subreddit, GitHub activity, and a weather API feed a seven-stage Cloudflare Workflow (fetch, rank, enrich, plan, tts, assemble, publish); D1 is the system of record for every stage transition; finished mp3s and the Apple-compatible feed land in a public R2 bucket served from nauracast.com.* ([view it on nabkey.com](https://nabkey.com/tinkerings/nauracast))
  <figcaption>Seven stages, one Cloudflare Workflow instance per run. A transient TTS failure retries TTS and everything downstream without re-paying for the planner.</figcaption>
</figure>

- Twelve pnpm workspace packages, ten deployed as Workers, on seven Cloudflare
  primitives: Workers, Workflows, D1, R2, Durable Objects, Containers and cron
  triggers.
- The scheduler ticks every fifteen minutes and asks each active feed whether
  its cron expression matches in that feed's own IANA timezone, so a Detroit
  show fires on Detroit time with no daylight saving math.
- Each stage is a durable `step.do()` with three retries and exponential
  backoff. Transitions write to a `run_stages` table, which gives the admin UI a
  live timeline.
- Workflow step outputs have to be JSON-serializable, which I'd underestimated.
  It makes D1 the only real source of truth, so every stage re-queries what it
  needs instead of trusting the previous step.
- A show is a row in a YAML file. A slug, a category, source kinds, topic
  strings, a cron expression and two voice names. A new show costs one config
  entry and a quarter per episode.

## Editorial rules

The planner prompt decides whether this is a show or slop with a microphone.

- **Never invent a story.** Every item has to map to a real fetched candidate by
  exact title and URL. A drifted URL fails validation and the plan is retried. A
  salvage pass repairs it when the title still matches something real and drops
  it when it doesn't. The prompt says a short, honest episode beats a fabricated
  one.
- **Routine weather isn't news.** Local outlets publish forecast stories
  constantly, so a popularity ranker turns a local news show into a weather
  show. A weather story now earns a roundup slot only with something concrete to
  report, like damage, a broken record, closures or injuries. Live forecast data still gets a
  few sentences of cold-open banter. Severe weather outranks everything and has
  to name the time window, the hazard, and what to do.
- **Don't recap a recap.** If a story ran last episode and nothing new surfaced,
  drop it. Revisit a previewed event after it happens.
- **Mind the gap.** The planner knows the publishing cadence and flags anything
  worth planning around before the next episode. A council meeting on Thursday
  is worth thirty seconds on Wednesday.

## Everything was massive

Every renovation was massive, every figure staggering, every result incredible.
A May 4th episode called the 22 million dollar redo of the Stafford's
Hospitality portfolio massive. Two segments later something else was massive.

The planner prompt now caps superlatives at roughly one an episode and says a
number from the research brief beats an adjective ("up 40%" over "a massive
jump"). Models comply for a while and drift back, so `audio-slop.ts` runs on
every accepted script, retries and salvaged plans included, and enforces a
budget rather than a ban:

> A host saying "massive" once is fine. Saying it in three consecutive segments
> is the thing you notice and cannot un-notice.

- `massive` gets one use per episode, `crazy` and `wild` two, `really` three.
  `utilize`, `leverage`, `delve` and `at the end of the day` get zero.
- The counter is shared across the whole script, not per turn. Longer phrases
  match first, so `at the end of the day` is handled before anything inside it.
- Excess uses cycle through replacements, so a capped word doesn't become four
  copies of "big". Pure amplifiers like `very` and `absolutely` are deleted, and
  a tidy pass cleans up the double spaces and orphaned commas.
- Every run logs what it caught, as `massive×3, very×2`.

Very little of this overlaps with the checklist for this site. Em-dashes and
paragraph rhythm are invisible to somebody driving; repetition is what grates in
audio. Where they agree is structure. The prompt tells the hosts to vary
sentence length and sometimes leave a loose end, a question the other host can't
answer or a disagreement nobody resolves. This site's linter measures the same
two things.

## Costs and small cruelties

About twenty-five cents an episode. TTS is roughly seventy percent of it,
billed per audio output token, which prompt tuning can't shrink. The planner
call is six and a half cents. Fetching and ranking are free.

Things that cost me an evening each:

- Google's TTS API caps a request at 4000 bytes of markup, so segments chunk at
  3500 to leave room for per-turn overhead. Oversized single turns split on
  sentence boundaries, decode to PCM, concatenate, and encode to MP3 once per
  segment.
- Cloud TTS rejects speaker ids that aren't alphanumeric, so `host_a` and
  `host_b` become `A` and `B` on the wire and nowhere else.
- Chapter timings are recorded as an anchor index *before* the segment that
  starts the chapter is pushed onto the list. The other order shifts every
  chapter by one segment.
- Google shut down two of the Gemini model ids this launched on a few months
  later. `pnpm check:models` exists so I don't find out from a silent 404 at
  6am.

## Ideas I want next

- **A podcast about the projects I starred.** The `github` source reads my
  received-events feed (activity from accounts I follow, filtered by Gemini
  Flash Lite down to real features rather than dependency bumps and CI tweaks).
  Stars are a different list, one I curated on purpose, and a weekly rundown of
  what shipped across them is a show nobody will make for me.
- **A podcast that only publishes when there's news.** Every feed runs on a cron
  and produces an episode whether or not anything happened. For a company or
  project I follow, it should fetch on a schedule and publish only when the
  candidates clear some bar. That's a deep change, since publish is currently
  the last step of a run rather than a decision the run makes.

## Loose ends

**It doesn't credit anybody.** The planner carries a `source_url` for every item
and the database stores it per segment, but it never reaches the show notes or
the RSS feed. The episode description is just a summary, so the outlet that did
the reporting gets nothing, and the byline never gets said. Should the fix be a
source list in the show notes, or the hosts naming the outlet out loud? Probably
both, and I haven't done either. The planner only sees that a story's source
kind was `news`, not which outlet, so it couldn't credit anyone by name yet.

Forward-looking coverage only knows what retrospective news copy happens to
mention. Structured context feeds (a civic calendar for Grand Rapids, game
schedules for the sports show) are sketched in the roadmap, and none of it is
built.

And I have no idea whether anyone besides me has ever subscribed.
