podcasting

Five daily podcasts, no humans: the pipeline

Five AI-generated podcasts publish every weekday with nobody in the loop. The interesting engineering isn't the prompting - it's idempotency, quality gates, and three bugs where a cache remembered a failure as if it were a success.

Every weekday at 06:00 UTC, a cron job on a small container writes, voices, mixes, and publishes five podcasts. Nobody records anything. No one approves a script. The episodes go straight to Apple and Spotify.

Everything below is fully AI-generated — the story selection, the dialogue, the voices. I want that stated in the first paragraph rather than discovered in the third, because it's the whole subject. Whether that's a good idea is a fair argument to have; this post is about how the machine is built.

46 episodes have shipped so far. Here's what the system actually looks like.

The thing that surprised me

I expected this to be an AI project. It isn't, really. The prompting was a weekend; the other several weeks were idempotency, gates, and failure isolation.

A daily pipeline is a **build system** that happens to call an LLM. Once you see it that way the design questions become familiar ones: what are the targets, what are their inputs, when can you skip work, and what happens when step four of seven dies at 06:53 on a Tuesday.

Seven stages

story-picker → scriptwriter → script-critic → script-polish → voice → mixer → publish

Each stage writes a numbered artifact into `episodes///`:

00-input.json           story pool (site posts + scout candidates + seen history)
01-stories.json         the 3 picked stories
02-script.json          first draft
02-script.review.json   critic verdict
02-script.polished.json final script
03-voices/turn-*.mp3    one file per dialogue turn
04-episode.mp3          mixed episode
05-published.json       what went where
episode.state.json      per-step input hashes

Numbered files on disk, not rows in a database. You can `cat` any stage of any episode from any day and see exactly what the machine was thinking. When something sounds wrong, `02-script.polished.json` tells you whether the writer or the voice stage is at fault. That debuggability has paid for itself many times over.

The step contract

Every stage implements the same three-key interface:

{
  name: 'script-critic',
  inputs(ctx) { /* the strings whose hash decides staleness */ },
  async run(ctx) { /* do the work, write the artifact */ },
}

`inputs()` returns the things that, if changed, mean the work must be redone — usually the upstream artifact's contents. The runner hashes them:

export async function runStep(step, ctx, { force = false } = {}) {
  const inputs = step.inputs(ctx);
  const hash = hashInputs(inputs);
  const prior = readState(ctx.date, ctx.root)[step.name];
  if (!force && prior && prior.inputHash === hash) {
    return { ran: false, reason: `unchanged (${step.name})` };
  }
  const result = await step.run(ctx);
  // …record the hash…
}

That's make(1) with SHA-256 instead of mtimes. It buys the property that matters most at 06:00: **re-running is free and safe**. If the mixer fails, you re-run the whole show and only the mixer executes. Everything upstream sees a matching hash and returns `unchanged`. No flags, no manual resume, no cleanup.

This is why the whole pipeline is one idempotent command. `pod run all ` is correct whether it's the first attempt or the fifth.

The critic gate

The hardest problem is not generating dialogue. It's that LLM dialogue defaults to two monologues taped together — each host delivering a tidy paragraph, then politely yielding. It is instantly recognizable and unlistenable.

So there's a separate critic stage that grades the draft and can reject it:

const system = `You grade a two-host podcast script written to a fixed
"The Best One Yet"-style show structure. Reject two-monologues-taped-together.
Require banter, factual fidelity to the given stories, and human cadence.
Output only JSON.`;

It returns structured JSON — `{pass, score, notes}` — against a rubric that checks section order, story fidelity, turn length, and handoff frequency. Today's episode of the newest show scored 0.92 with the note: *"Turns are consistently short (1-3 sentences) with frequent speaker handoffs; no monologue dumps."*

There's also a cheap deterministic check alongside the LLM, because some things don't need a model:

export function hasBanterSignals(script) {
  const speakers = new Set((script.turns || []).map(t => t.speaker));
  const interruption = (script.turns || []).some(
    t => /—\s*$|—"/.test(t.text) || t.text.includes('—'));
  return speakers.size >= 2 && interruption;
}

Em-dashes as a proxy for interruption is crude. It also catches the exact failure mode where the model writes two clean speeches, because clean speeches don't have interruptions in them.

The general lesson: **use an LLM to judge the LLM, but keep a dumb deterministic check next to it.** They fail differently, which is the point.

Refusing to publish

The most important line in the story-picker isn't the picking, it's the refusal:

const MIN_STORIES = 2;
if (!Array.isArray(stories) || stories.length < MIN_STORIES) {
  throw new Error(`story-picker: HOLD — only ${stories?.length || 0} distinct
    stories found (need ${MIN_STORIES}); skipping this episode.`);
}

Without this, a bad scout day produces an episode *about having no stories*. The model will cheerfully write "Missing Stories, Nothing to Report" and the pipeline will cheerfully voice it, mix it, and push it to subscribers.

An automated publisher needs an explicit "publish nothing today" path, and it has to be a hard failure rather than a fallback. Skipping a day is invisible. Publishing an episode about having nothing to say is not.

The same principle governs multi-show runs — every show is isolated:

for showcfg in config/shows/*.json; do
  if pod_out=$(node bin/pod.js run all "$showId" "$TODAY" 2>&1); then
    echo "podcast[$showId]: $TODAY published"
  else
    echo "⚠️ podcast[$showId]: NOT PUBLISHED — $(…)"
  fi
done

One show failing must never block the next. A show is a JSON config file plus two audio assets; adding the fifth show required no code changes at all, because the loop globs the config directory.

Three bugs that shaped the design

**Story memory that was read but never written.** Each show keeps a 7-day history so it doesn't repeat itself. The read path worked. The write path was never called. Every episode saw an empty history and re-covered the same stories. Nothing crashed — it just quietly got worse. The fix was three lines; finding it took an afternoon of "why does it keep talking about the same model release."

**An empty artifact that made an outage permanent.** A network outage killed a run before story selection. The failed run still wrote `00-input.json` with empty story pools. Every subsequent run then found that file, treated it as a valid hand-edited override, and skipped rebuilding — so it held again. A transient outage became a permanent one. The guard is now:

function hasUsablePool(input) {
  return Boolean(input?.sitePosts?.length || input?.scoutCandidates?.length);
}

Cached artifacts need a validity check, not just an existence check. "The file is there" is not the same as "the file is usable," and the gap between those two is where sticky failures live.

**A quota failure that stranded turns.** The TTS provider ran out of credits mid-render. The voice stage had rendered 40 of 59 turns. Recording a matching input hash would mark it done, so the next run would skip it and the mixer would fail on missing files — forever. The runner now records a sentinel:

const incomplete = !!(result?.incomplete);
recordStep(ctx.date, step.name, {
  inputHash: incomplete ? `incomplete:${hash}` : hash,
  chars,
}, ctx.root);

A poisoned hash that can never match, so the step re-runs and resumes from its per-turn files. Partial spend is still recorded, because you were still billed for it.

All three are the same bug wearing different clothes: **a cache that remembers a failure as if it were a success.**

What it costs

July, across four shows (the fifth launched at the end of the month):

Episodes46
TTS characters302,703
Episode length~6.5–7.5 min
Podcast module~1,800 lines
Tests534, across 72 files
Runner28 lines

Text-to-speech dominates the bill; LLM calls are a rounding error next to it. Each show has its own monthly character cap enforced through a shared ledger keyed `:`, so a runaway loop in one show can't spend another's budget. Voice synthesis takes about three minutes per episode, which is what determines how many shows fit in a morning window.

What I'd do differently

**The run-log tracks the wrong things.** It records site publishes but not podcast outcomes, so when two shows failed on a quota error, the log said `ok: true` while two episodes were silently missing. Failure reporting should cover everything the run produces, not the parts that were easiest to instrument.

**Quota exhaustion should be a first-class state.** It presents as a generic API error, several stages deep, and looks identical to a network failure. It deserves its own detection and its own alert, because the remedy is completely different.

**The critic and the writer share a model.** They should probably not. A judge that shares its author's blind spots is a weaker judge than one that doesn't.

The part I'm still unsure about

The engineering here is sound: it's idempotent, gated, isolated, and tested. I can defend every design decision above.

Whether the world needs five more automated daily podcasts is a separate question, and I don't think building the pipeline answers it. The honest position is that this is a well-built machine whose value depends entirely on whether the episodes are good — which is a judgment about the output, not the architecture. The critic gate raises the floor. It doesn't make the ceiling.

If you want to hear what comes out, the shows are on Apple and Spotify. If you just wanted the architecture, that's above, and it works whether or not you think the output is worth making.