# 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.
**Source:** Broke It (https://brokeit.dev/posts/five-daily-podcasts-no-humans-the-pipeline/)  
**Published:** 2026-07-30
**Original source:** null
**Topics:** podcasting, automation, llm, pipeline, build-systems

---

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/<show>/<date>/`:

```
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:

```js
{
  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:

```js
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
<show> <date>` 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:

```js
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:

```js
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:

```js
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:

```sh
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:

```js
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:

```js
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):

| | |
|---|---|
| Episodes | 46 |
| TTS characters | 302,703 |
| Episode length | ~6.5–7.5 min |
| Podcast module | ~1,800 lines |
| Tests | 534, across 72 files |
| Runner | 28 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 `<show>:<month>`, 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.
