# Shows

A **show** is a `PublishingSeries`. There is no other show model, and that is
the point of this document.

Until recently there were two: `PublishingSeries` — project-bound, bible-backed,
slugged, with releases going out to Apple and Spotify — and a parallel "Sleeper
Apps" system that had its own manifest, its own episodes, its own runtime and
its own public pages. The apps were deleted and their good ideas moved onto the
series, which already held the history and the releases.

If you are about to add a second place where cast, a schedule or episode
kinds live, that is the mistake this consolidation exists to undo.

## One shape, one name

A show has one shape and one name on every layer (hosted-shows S3, 2026-10-19):
the Story API (`/publishing-series/{seriesId}/show/…`), MCP (`get_show`,
`set_up_show`, …, and the ChatGPT profile), the CLI (`sleeperhit show …`),
Slug (the same tool names), the Show tab, project create, and the runner. The
vocabulary is `@sleeperhit/shared` `show.ts`; the cores are
`server/show-api.ts`. No surface accepts an older shape, and there are no
aliases: `scripts/show-name-gate.sh` greps the tree for every removed name and
fails on any it finds outside its allowed list (the no-alias test's names, and
the schema's column maps).

| Thing | Name everywhere |
|---|---|
| The setup document | `showSetup` |
| The schedule | `showSchedule`: entries `{ id, every, at, weekday?, dayOfMonth?, covers, episodes }` |
| The creator's switch | `showOn`; the show's `state` is `not_set_up \| off \| on \| paused \| external` |
| The caps | `episodeCap`, `monthlyCap` |
| Hold each episode for review | `holdForReview` |
| A run of a schedule entry | `ShowRun` |
| The operator's stop | `AIFeatureConfig.showsRunning` (`set_shows_running`, `sleeperhit admin shows-running`) |

The database columns and the run table keep their earlier names through `@map`
and `@@map` (`prisma/schema.prisma`), so the deploy's `db push` renames and drops
nothing. The retired publishing-policy column is `@ignore`d until S4 drops it.

## Where things live

| Thing | Home | Why |
|---|---|---|
| Cast, tone, format, world, episode history | **Series Bible** | The whole platform already reads it: coverage scores it, the season gate checks it, the planner seeds from it. It has a 50k budget. |
| Sources, length, planner fields, theme, sounds, memory | **`showSetup`** (`lib/shows/show-setup.ts`) | Typed things the runtime branches on, that a prose Bible cannot express. |
| When it runs | **`showSchedule`** | Entries with stable ids: a run is claimed per entry id and local date. |
| Banked theme | **`runtimeState`** | Per-series, written once, replayed forever. |
| Pinned voices | **`cast_canon` document** | That document already pins character identity — avatar, body figure, sheet. Voice is its missing audio half. |
| What an episode spent of the show's memory | **`ShowEpisode.memory`** | Recorded on the episode when it is made, so held episodes and a run's episodes remember each other. |
| The brief's wording (what the writer is told) | **Snippet `SHOW_EPISODE_BRIEF`** | Rule 10: prompt text lives in DB rows, editable without a deploy. Source: `scripts/seed-snippets/SHOW_EPISODE_BRIEF.md`. |
| An episode's one-sentence description | **Snippet `SHOW_EPISODE_DESCRIPTION`** | The direction the release's description is written from (`server/show-release.ts`). Source: `scripts/seed-snippets/SHOW_EPISODE_DESCRIPTION.md`. |

**The brief.** `lib/shows/brief.ts` is assembly only.
- **How it reads the row.** It reads `SHOW_EPISODE_BRIEF` (one `LABEL: text`
  template per line; `#` lines are operator notes) and picks the templates an
  episode needs. It fills their `{PLACEHOLDERS}` from the Bible and the
  production plan.
- **When it refuses the row.** A label missing, a label nothing reads, a
  placeholder dropped, or one nothing fills. Then the episode fails before
  anything is spent, with the row named.
- **The guard.** `tests/unit/the-show-brief-lives-in-its-row.test.ts` fails if
  any sentence of the row reappears in the web source.
- **Seeding.** The seed upserts the row from the file with a real `update:`.
- **Getting a rewrite into the shared database.** The prod deploy's bootstrap
  seed creates a missing row but never rewrites one, and the stage deploy never
  seeds. So use `scripts/rollout-show-brief-snippet.cjs` (and, for the rows this
  change rewrote, `scripts/rollout-show-contract-snippets.cjs`): dry run by
  default; `--apply` creates a missing row; `--apply --rewrite` replaces a
  differing one.

`showSetupDocumentSchema` is `.strict()` and **refuses `cast`, `tone`, `format`
and `language` outright**, by name. That is deliberate, and there is a test for
it. A stored setup that does not parse is never read loosely: it reads as not
set up, and `scripts/show-clear-old-setups.ts` (dry run by default) clears it.

## Production modes

```
manual      one episode at a time, by hand           (the default)
seasonal    a planned season run                     (advanceStorySeasonRun)
recurring   a show: its runs are made on its schedule (show-cadence-tick)
```

A series is `manual` unless it is made a show: the first `set_up_show` on an
audio series, or project create with `recurring: true` (`ensureShow`, the same
core). A season run is never a show.

## Who makes a show

**Key first.** A show an outside app makes holds its standing approval on that
app's API key (HNRadio's `hackernewsradio`): its producer is `external`, whatever
else the series says. Otherwise a `recurring` series is `hosted`: Sleeper's
runner makes it. One producer per show:

- A show an outside app makes reads `state: external` everywhere, never `off`
  or `not_set_up`. It takes a setup (for previews) and keeps its key; its next
  step stops at `make_preview`.
- An extra, a publish and `on` are refused on it with 409 `show_made_outside`,
  naming the producer (`server/show-api.ts`, so every surface refuses the same).
- Binding a key to a show Sleeper was making turns that show off. Revoking the
  approval makes the show Sleeper's, and off; `on` then grants Sleeper's own.

### Moving a show onto Sleeper

Before Sleeper makes an episode of a show an outside app has been making, the
show takes on that app's history (`server/show-import.ts`; operator scripts,
dry run first, `--apply` to write):

- `scripts/show-import-history.ts` writes every item the app covered as one
  `ShowEpisode { kind: external }` keyed `external:<itemKey>`, with its claim:
  `published` with its release when it is in the feed, `dropped` otherwise.
  It is dated when the app made it and carries no spend, so it never counts
  against the hosted caps. A second run adds only what is new; an item a
  Sleeper-made episode claimed is reported, never touched. HNR's mapping (D1
  rows, releases matched by plan, artifact or title, the key's sources) covers
  every thread that got as far as a plan.
- `scripts/show-store-theme.ts` banks the app's theme clips through the
  runtime store, so the first hosted episode sounds like the last external one.
  A show with a different theme is refused, never overwritten.
- `scripts/show-seed-memory.ts` records on the last N published external
  episodes the memory the show's setup records from their scripts, so the first
  hosted episode knows what recent episodes spent.

## How a show runs

```
show-cadence-tick  (hourly)
  └─ showsRunning?  (AIFeatureConfig: off = fire nothing, claim nothing)
       └─ series LIVE_SHOW_WHERE: recurring, showOn, active, no key, not deleted (nor its project)
            └─ schedulesFiring(showSchedule, now, series.timezone)    DST-proof, local wall clock
                 └─ claim ShowRun (seriesId, scheduleId, dateKey)     ← unique; losing is normal
                      └─ each lane's first job: { runId, lane }, keyed show-run:<runId>:<lane>
            └─ every open or waiting run of the show: { runId } again (stately: one queued per run at most)

show-episode-produce  (stately; heartbeat 120 s)
  { runId, lane } keyed show-run:<runId>:<lane>: that lane's next episode (server/show-runs.ts continueShowRun)
    └─ open? live? shows running? → resume the run's episode in flight, or: target / ceilings → readiness and
       the month's cap (refused: the run waits) → the next ranked item, claimed atomically → made in this job,
       as below → when it ends, the run's next job (until its target)
  { episodeId }  keyed show-episode:<episodeId>: a preview, an extra, a refinish
  └─ (every episode, a run's included) the episode's row and its show → re-checked (on, audio, shows running) → readiness → caps
       → material (covered items left out) → StorySource (producer show-<seriesId>) → plan → take
       → post-production → made: preview | held (review, a failed step) | published under Sleeper's
         standing approval (the release, and its episode number, are created now)
```

The run itself (its target, what it made, its state) is a show run: `list_runs`
reads every one, with its lane and its state.

### Each episode, and what it covers

A `ShowEpisode` is one episode the show set out to make: a run's roundup or
item, an extra, or a preview (`kind: scheduled | extra | preview | external`).
It is opened when it is queued, so the run shows a queued episode rather than a
gap, and it carries how it stands for its whole life: `queued`, `making`,
`published`, `held` (with its `heldReason`: review, a hard line, an off-cast
line, a voice, a voice effect, the sounds or the theme failed, the show turned
off, shows not running), `preview`, `dropped`, `skipped` (no material, not
runnable, not ready, a cap, already covered) or `failed` with the Story API code
and sentence that ended it. A typed refusal (a 4xx: a gate, a cast, credits)
ends the episode as an outcome; anything else is rethrown for the queue to
retry, which resumes through the recorded step.

**No episode number until it airs.** An episode takes its release only when it
is published (`server/show-release.ts`): held, preview, dropped and failed
episodes take none, so the feed's numbers never skip.

A `ShowEpisodeItem` is one upstream item an episode covers.
`@@unique([seriesId, itemKey])` is the dedupe: an item belongs to one episode of
a show, so a thread still on the front page tomorrow, or a weekly window that
overlaps last week's, never becomes a second episode. Who claims, and when:
- a scheduled or extra episode claims its item when it starts; **a roundup
  claims every item it covers**; a skipped or failed one lets its items go;
- a dropped scheduled or extra episode keeps its items: they are never made
  again;
- **a preview claims nothing**, and dropping it claims nothing. Publishing a
  preview claims its items then, all or nothing: an item the show already
  covered refuses the publish with 409 `show_item_already_aired`. The claim
  lives as long as the publish: a publish that fails before its release aired
  lets the items go, one whose release aired keeps them and is marked published
  whatever failed after, and a claim stranded by a crash between the two is
  settled (`settleStrandedPreviewClaims`, after 15 minutes) before the sweep,
  an extra or another preview's publish reads what the show covered;
- an item run's episode that loses its item between the pick and the claim (an
  extra, or an item two sources both offer) ends `skipped` (`already_covered`)
  and is reported to its run; it never sits `queued`.

The episode's material is stored as one source on the creator's project with
`producer: "show-<seriesId>"` and `externalId` = the episode key: a replayed job
gets back the source it already made, Bible generation never reads a show's
daily material as canon, and `originatedAt` is the earliest item's date (which
files the episode in its month on a monthly-season project, #1311).

**Credits.** Every episode's show notes (the release description, which the
feed carries) list each item it covers after its one-sentence description:
title, author or public handle where the source names one, link. The material
belongs to its authors; the episode, its script, audio and feed belong to the
creator.

**The title names the story, not the source (BL-5097).** A source's label names
the source — a watched catalog row, a watched page, a dataset — and it used to
name its episode too, so the feed, the Show tab and the writer's dashboard
printed "Vanguard 1 element set (CelesTrak), and 2 more". A made episode is
named from its own script instead, once, before any surface is told a title
(`server/show-episode-title.ts`), by the wording in the `SHOW_EPISODE_TITLE`
Snippet — its committed source is `scripts/seed-snippets/SHOW_EPISODE_TITLE.md`,
and `scripts/rollout-show-contract-snippets.cjs --apply` puts it in the shared
database. The episode's script takes the same name (the writer's dashboard lists
the project's episodes by it), and the material's label stays the fallback and
stays in the credits. The row is optional: without it — never seeded, inactive —
the episode keeps its label, exactly as every episode was named before.

**Every send is keyed.** Every send to `show-episode-produce` goes through the
one send in `server/show-cadence.ts`. An episode the creator asked for
(`queueShowEpisode`) is keyed `show-episode:<episodeId>`: a send pg-boss answers
with null was not queued, the episode ends `failed` (`not_queued`) with its
claims let go, and the call answers 409 `show_episode_not_queued` (a refinish
that is not queued leaves the made episode as it is). A run's lane job
(`queueShowRun`) is keyed `show-run:<runId>:<lane>`: a null send means that lane already
has a job queued, and it is left as it is with nothing claimed (docs/show-runs.md).

**One episode per request, for its setup.** `make_show_episode` names its
episode by the request's key and the setup's version (`showSetupChangedAt`):
the Story API's `Idempotency-Key` (MCP and the CLI send a new one per call), or
Slug's request for that kind and item (the Show tab's "Make a preview" goes
through Slug). A request repeated while its episode is still `queued` or
`making` is answered with that episode ("already being made"), so a model that
repeats itself makes one preview. A request after the setup changed is a new
episode, and an episode that already ended never answers a request, so asking
again after a preview was made or failed makes another.

### Readiness

`server/show-readiness.ts` asks whether the project can make an episode: the
workspace gate judged *as a recurring show* (premise coverage opens it), a cast
canon covering the roster (a read performs a cast, never makes one), the Bible's
tone (120), audience (300) and genre (160) within what the plan takes, the made-up-names key
when the show names commenters with pseudonyms (`SHOW_PSEUDONYM_KEY`; until it
is set such a show reads `get_ready: "Commenter names can't be made up yet"`),
and the generation providers' health. The show read names the first blocker as
its next step (`get_ready`); making an episode and turning the show on are
refused with the same 409 the plan or the job would have given. The worker asks
again before it fetches anything.

### The bounds

This is the only code in the product that spends money with nobody watching.
The bounds are in `lib/shows/production-rules.ts` and `lib/shows/show-budget.ts`,
pure and individually tested.

| Bound | Value | What it stops |
|---|---|---|
| `MIN_SPOKEN_WORDS_PER_PAGE` | 85 | A thin take shipping as a half-episode |
| `MAX_JOB_ATTEMPTS` | 3 | Rerolling forever on a bad writer day |
| `MAX_PLAN_ROUNDS` | 3 | Re-planning forever on output-budget failures |
| `SHOW_EPISODES_PER_RUN_MAX` | 5 | A hot feed stampeding production |
| `MAX_EPISODES_PER_SERIES_PER_DAY` | 10 | Several entries stacking across one day |

Two subtleties that are easy to get backwards:

- **An uncountable script is not a bad take.** `shouldReroll` returns false when
  the word count cannot be read. Treating a failed probe as evidence burns every
  attempt on a script that may be fine.
- **The job idempotency key bumps only on *deliberate* abandonment.** A poll
  failure reuses it, so the retry resumes the job already paid for. Backwards,
  this double-spends on every transient blip.

### Standing approval

Plan approval and publishing are human boundaries: a caller asserts
`userConfirmed: true` only after the person agreed to that exact plan or
release. An unattended producer has nobody to ask on every episode, so the owner
agrees **once, on the show** (owner decision 2026-09-29). The grant lives on the
series — `standingApprovalGrantedAt`, `standingApprovalGrantedBy`,
`standingApprovalKeyId` — and `server/standing-approval.ts` is the one place it
is read. It is held either by **one API key** (an outside app's) or, with no
key, by **Sleeper's runner** while the show is on (`runnerGrant`, written when
the show is turned on; cleared when it is turned off).

| It covers | It never covers |
|---|---|
| `POST /story-plans/{planId}/approve` for a **table-read** plan in the show's project | a plan or release created **before** the grant (a re-grant restarts the clock) |
| `POST /publishing-releases/{releaseId}/publish` for one of the show's **episodes**: its audio release, and its video release (the theater MP4 of a read from a table-read plan the grant covers) | season runs, which keep their own per-step confirmations |
| the caller holding **the one bound key** — or Sleeper's runner, for its own show while it is on and no key holds it | an OAuth connection (its token rotates; the grant would silently lapse) |
| | a show that is `paused` or `archived` — paused has to actually pause |
| | a **job** asking for anything but `table_read` from a plan the grant approved |

Every other caller still needs `userConfirmed`. `createStoryPlan` still refuses
`autoApprove` for `table_read` plans; the runner approves through the grant, not
a forged confirmation. A preview's or an extra's plan is approved on the
creator's confirmation of the episode (`createShowPipeline({ approval:
'creator_confirmed' })`).

**A job spends only what its plan was approved for.** `POST /story-jobs` may
narrow or refine the plan's requests, but a type the approved plan does not
contain is refused (`details.reason: artifact_type_not_in_plan`); a
standing-approved plan runs `table_read` only
(`standing_approval_table_read_only`).

**Every use is recorded**, on the row it let through: `StoryPlan.approvedVia`
(`user_confirmed | standing_approval`), `approvedUnderSeriesId`,
`approvedByKeyId`; `PublishingRelease.publishedVia`
(`user_confirmed | standing_approval | owner | season_run`) and
`publishedByKeyId`.

**A refusal says why.** No grant covering a call is a 400 `validation_failed`
whose `details.reason` is one of `season_run`, `not_table_read`, `not_audio`,
`no_standing_approval`, `not_bound_key`, `series_not_exercisable`,
`created_before_grant` — none of which clears on retry, so a producer stops and
alerts instead of looping. `not_audio` is a video release that is not a show
episode's own video (a trailer, a production video, or a read from any other plan). A release the caller does not own is a 404.

**The grant keeps its date** when the key that already holds it is bound again,
and when a show that is on is turned on again; the clock restarts only for a new
holder. Revoking an API key (dashboard → API) gives up every grant it held
(`releaseStandingApprovalsForKey`). Granting is itself a human boundary: over
the Story API, binding a key (`PATCH /publishing-series/{id}`
`{ standingApprovalKeyId, userConfirmed: true }`) and turning a show on both
need `userConfirmed: true`. `standingApprovalKeyId: "self"` binds the calling
key; `null` revokes. A grant holds only on an audio series (the show's feed;
its video episodes are covered as above), and a series holding one (or a show
that is on) is refused a move off audio.

### The setup contract

The setup is typed on every surface from one vocabulary, `@sleeperhit/shared`
`show.ts`: the web validates with zod built from it (`lib/shows/show-setup.ts`),
the MCP tools advertise zod built from it (`mcp-server/src/show-setup-schema.ts`),
the OpenAPI publishes `ShowSetup`, `ShowSetupUpdateRequest` and
`ShowScheduleEntry` from it, and the CLI help prints it. A write
(`PATCH /publishing-series/{id}/show`) changes only the fields it sends; a text
over its cap is refused with the cap named, never clipped.

| Field | Shape |
|---|---|
| `sources[]` | `{ adapter, params, label }`; params are per adapter and strict: `rss { feedUrl, maxItems, lookbackDays, readArticles, order }`, `web-search { query, topic, site, maxItems, lookbackDays }`, `hackernews { count, minComments, itemId, skipObituaries }`, `github { repo, lookbackDays }`, `url-watch { url, label }`, `upload { text, label }` |
| `schedule[]` | `every` `day`\|`week`\|`month`, `at` `"HH:MM"` in the series' timezone (the show's one clock), `weekday` (`monday`…, a weekly entry only), `dayOfMonth` 1–28 (a monthly entry only), `covers` `item` (one episode per item, the default) \| `roundup` (one episode from everything new; its run makes 1), `episodes` 1–5. Entries are matched by `id` on every write: a new one has none and gets one; `{ id, remove: true }` removes one |
| `episodePages` | 4–20 script pages: THE length number (a cast runs ~1.3 min a page, so ~9 pages ≈ 12 min). Unset, the length follows the material (5–10) |
| `episodesAtOnce` | how many episodes of a run are made at once, 1–3 (default 2); they publish in the order opened, and a show's first episode is made alone |
| `profanity`, `punchUp`, `hardLines` | how the show swears, and the words it never says |
| `commenterNames` | `handles` \| `pseudonyms` (made-up names, the default for a new show; their key is `SHOW_PSEUDONYM_KEY`) |
| planner fields | `planningNotes`, `castNotes`, `writingStyle`, `objective`, `outcome`, `musicStyle`, `sfxPolicy`: each replaces the platform's default for its plan field |
| `episodeNotes`, `themePrompt`, `sounds`, `voiceEffects`, `memory` | the writer's notes (up to 8,000), the theme, the show's sound list, voice effects and memory |
| `soundReuse` | `reuse` \| `generate` (owner rule 2026-10-26). `reuse`: the read's own pass never generates (`deferSfx`), and post-production lays every kept cue from what the show already has: a show sound's banked recording, or, with no sound list, the project's closest banked effect by category, description and length (`lib/shows/sound-library.ts`). Only a cue nothing fits is generated. Each cue records `soundSource` (`reused`/`generated`, on `list_sfx`). Unset: a show with a sound list reuses (HNR), any other generates |
| `episodeMedia` | `audio` \| `video` \| `both` (owner, 2026-11-02). Video is the episode's theater, rendered as an MP4 after the mix (free; `renderTheaterVideo`, asked twice, then held `video_failed`); it needs a headshot for every host (`show_not_ready` `host_portraits`). `both` publishes one episode as one unit: the audio release on the episode's key (the feed) and a video release on `<episodeKey>:video` (the show site, and the series' video destinations such as YouTube); a video-only episode's one release is its video, on the episode's key. The show site lists the episode once, by its audio release. Unset: a product project's show makes both, a story's makes audio |
| `episodeCap`, `monthlyCap`, `holdForReview` | the caps (setting one takes `userConfirmed: true`) and whether each episode waits for the creator |

A param the adapter does not read is refused (an RSS source saved with `url`
instead of `feedUrl` used to be accepted and then read nothing at every run).
`github` takes no token — a credential in the setup would be returned by every
show read.

**A show that is on keeps a setup it can run on**: a change that would leave it
without a source, a schedule or caps that can let an episode through is refused,
naming why.

**Sources (owner, 2026-10-09).** RSS first, a topic in the news
(`web-search { query }`, searched each run on the platform's own search service,
bounded at `SHOW_WEB_SEARCHES_PER_DAY` per account), and an archive as a backlog
(`rss { order: "oldest" }`). `suggest_show_sources` proposes from them and
`check_show_sources` proves the one you pick.

**Suggestions.** `SHOW_SOURCE_SUGGESTIONS` (`@sleeperhit/shared` `show.ts`) is a
short list of interesting sources an agent can suggest (NASA, arXiv, Wikipedia's
daily features, the Library of Congress, The Public Domain Review, the Federal
Register, USGS, a topic in the news), each saved as written, with the schedule
entry it suits (all weekly roundups), how much text an item carries, and a
`rights` note. It is served on every surface:

| Surface | Where |
|---|---|
| API | `GET /show-source-suggestions?topic=` |
| MCP | `suggest_show_sources` (optional `topic`) |
| CLI | `sleeperhit show suggest-sources [--topic]` |
| Slug | `suggest_show_sources` |

**Whose material it is.** The creator owns the show, its scripts, audio and
feed. The sources' material belongs to its authors and publishers: every
episode credits each item in its show notes. For legal review, the third-party
content shows touch is: RSS and Atom feeds; web-search articles; Hacker News
stories and comments (by handle, or by made-up name); public GitHub
repositories; watched pages; text a writer pastes.

**Look-back.** A run reads back the gap since the last one — 1 day for an entry
every day, 7 every week, 31 every month — unless a source names its own
`lookbackDays` (at most 90).

### Turning it on

`POST /publishing-series/{id}/show/state` `{ state: "on" | "paused" | "off",
userConfirmed }` (`setShowState`). On is refused unless the show has a source, a
schedule, both caps (that can let an episode through), notes that fit a job, a
project that can make an episode, and a **preview made since the setup last
changed** (`showSetupChangedAt`); over the Story API it takes
`userConfirmed: true`. It is refused on a show an outside app makes. On writes
Sleeper's standing approval and ends a draft (`status: active`); `paused` holds
it; `off` clears Sleeper's grant explicitly, and an episode being made stops at
its next step and is held (`show_turned_off`).

The rules that turned it on run again at fire time: by the sweep before it
claims a run, and by the worker before it spends (a job waits in the queue, and
a show turned off, paused, given to an outside app or moved off audio in between
is skipped, not made).

### The next step

Every show read carries one `nextStep` (`lib/shows/show-next-step.ts`), the same
on every surface, derived from stored state:

| Step | When |
|---|---|
| `set_up` | no sources or no schedule (never for a show an outside app makes) |
| `get_ready` | the readiness read fails; the sentence names the blocker |
| `set_caps` | no caps |
| `make_preview` | no preview made since the setup last changed (for a show an outside app makes too) |
| `turn_on` | the show is off |
| `review_held` | an episode is held for review |
| `resume` | the show is paused |
| `none` | nothing else applies; for a show an outside app makes, once a fresh preview exists or while it has no setup |

### Caps, quote and pay as you go

Owner, 2026-10-09: a show is pay as you go through the SAME Studio Credit pipes
as everything else. An episode is quoted by the one pricing library (`quote()`;
a show's episode is one short read, `TABLE_READ_SHORT` — the MP3 is free),
charged through the same reserve/settle/refund ledger, and a failed step refunds
through that same path. Nothing in `lib/shows` or `server/show-*` writes the
ledger itself (`tests/unit/show-budget.test.ts` guards it).

No show runs without two caps, agreed with the creator:

| Field | Meaning |
|---|---|
| `episodeCap` | the most one episode may spend; at least one episode's quote (and its hard-line refine, when it has hard lines) |
| `monthlyCap` | the most a calendar month's episodes may spend, in the series' timezone; at least the episode cap |

The show read gives one episode's price and the caps suggested from it
(`suggestedCaps`): an episode at its quote + 25%, a month at the episodes the
schedule makes in its busiest month (an entry every day 31 runs, every week 5,
every month 1; a roundup 1 episode a run, an item run its `episodes`) × that.

`server/show-budget.ts` holds the caps to account: before each paid step (keyed,
so a retry is not counted twice), before an episode starts (the month must pay
for one, or the episode is skipped `cap_reached` with nothing fetched), and when
an episode ends (its real cost read back from the ledger).

**Only the credits the writer already holds** (owner, 2026-10-11). A show never
buys anything. When the balance cannot cover an episode, its reservation refuses
it with `insufficient_credits` (402) before any audio; the episode ends `failed`
with nothing charged, the creator is told, and the third refusal in a row pauses
the show. A make quote says what it would leave (`balanceAfter`, `sufficient`),
and a confirmed one on a short balance is refused before anything is queued.

### Episodes asked for, and what a made one becomes

| Op | API | MCP | CLI |
|---|---|---|---|
| Read the sources now (free; saved ones or `sources` to try) | `POST …/show/check-sources` | `check_show_sources` | `show check-sources` |
| Make one episode now: a preview, or an extra on a show that is on | `POST …/show/episodes` `{ kind, itemId? }` | `quote_make_show_episode`, `make_show_episode` | `show make-episode [--extra] [--item]` |
| One episode | `GET …/show/episodes/{episodeId}` | `get_show_episode` | `show episode` |
| The runs, and the episodes asked for | `GET …/show/runs` | `list_show_runs` | `show runs` |
| Publish a held or preview episode | `POST …/episodes/{episodeId}/publish` | `publish_show_episode` | `show publish --confirm` |
| Never air an episode | `POST …/episodes/{episodeId}/drop` | `drop_show_episode` | `show drop --confirm` |
| Fix one line (re-mixed by itself after) | `POST …/episodes/{episodeId}/fix-line` | `quote_fix_show_episode_line`, `fix_show_episode_line` | `show fix-line` |
| Re-mix after another edit | `POST …/episodes/{episodeId}/refinish` | `quote_refinish_show_episode`, `refinish_show_episode` | `show refinish` |

- **A preview** is made like any episode (the same worker, readiness and caps),
  held for the creator, never published unless they publish it, and claims
  nothing; it may name any item (`itemId`), even one already aired. Two calls:
  without `userConfirmed` it quotes; with it, it queues (keyed by the
  Idempotency-Key, so a retried call names the same episode).
- **An extra**, on a show that is on, goes out like any other episode and claims
  its item at make (an aired one refuses with `show_item_already_aired`).
- **Publish and drop** take `userConfirmed` over the Story API, MCP and Slug
  (the owner in the app is the confirmation). A preview's publish claims its
  items first, all or nothing.
- **Fix a line** (`fix_show_episode_line`: `POST …/show/episodes/{episodeId}/fix-line`,
  MCP, `sleeperhit show fix-line`, Slug) replaces one line of a made episode
  exactly (`find` must be in the script exactly once; otherwise the closest
  lines come back). Two calls: without `userConfirmed` it returns the refine's
  price and the line before and after; with it, the line is re-voiced and the
  episode's refinish is queued behind it, so the episode is re-mixed by itself
  once the re-voice lands (a failed re-voice leaves the audio as it was, and the
  episode says so). The Show tab's "Fix a line" opens the show's conversation
  on the episode; the turn's context names it, never the writer's message.
- **Refinish** re-applies the show's post-production to the current script and
  mixes the episode once; a published episode's feed audio is replaced. A fixed
  line runs it by itself; call it after any other edit. Free today.
- **Source reads** — the check, the sweep, the worker — go through the guarded
  fetcher (`server/show-source-fetch.ts`): private and link-local addresses
  refused, the connection pinned to the address checked, at most three
  redirects, 10 MB.

### How a chatting agent is steered

Owner, 2026-10-09: "chats with Sleeper's MCP can reliably build out recurrent
podcast ideas, with a source and a cadence". What steers an agent:

- **`composeServerInstructions`** (`mcp-server/src/mcp-metadata.ts`). The
  backend owns the intro. The code-owned rules and the workflow router always
  follow it, on the stdio and the remote connector alike.
- **The `shows` workflow group** (`shared/src/mcp-onboarding.ts`). Its `next` is
  seven steps, in order: 1. project and Bible (`create_project { medium: audio,
  recurring: true }`; the project's `showSeriesId` is its show); 2. cast;
  3. sources, schedule and length (`set_up_show` reads each new source and refuses
  one that reads nothing; `check_show_sources` for a quiet show);
  4. caps (`get_show`'s `suggestedCaps`, agreed); 5. preview
  (`quote_make_show_episode`, `make_show_episode`, `get_show_episode`); 6. on
  (`set_show_state`); 7. after (`get_show`'s `nextStep`, `list_show_runs`,
  publish, drop, refinish).
- **The `AGENT_PUBLISHING_GUIDANCE` snippet** (file-backed in
  `scripts/seed-snippets/`), with the same steps and the source advice. A
  changed existing snippet reaches production only through a content rollout,
  not the bootstrap seed.

**The reliability eval** is
`tests/integration/an-agent-sets-up-a-show-from-the-mcp-tools.test.ts`. A
deterministic script plays the agent for each of the trial's four requests,
taking its steps from the instructions and its arguments from the tool schemas.
Each call is validated against the tool's real input schema, then served by the
real show cores on the real database. Every request ends on, after its caps and
its preview, with nothing published; turning it on too soon is refused, naming
the missing step; the trial's guesses are refused; a dead feed is found at setup;
and a show an outside app makes takes previews only.

### Notices and the pause

`holdForReview: true` (the default for a new show) makes each episode and holds
it for the creator to hear and publish; `false` publishes each episode under
Sleeper's standing approval. A show created before this default keeps the value
it was given.

`server/show-notices.ts` tells the creator (in-app bell, plus the account email
when email is configured), with the notice's id as the dedupe:

| Ending | Notice | Counts toward the pause |
|---|---|---|
| failed for the item's own reason (no material, too thin, a paywall), held because a required step failed, or a setup the show cannot run on | `show_episode_failed` with why, and what it cost (`Nothing was charged for it.` or what the ledger says it used) | yes |
| three of those in a row | `show_paused`: the show is set `paused` with `pausedReason` until the creator resumes it (`state: on`, which resets the count) | — |
| a systemic failure (credits out, a provider down, the project or cast not ready, the pseudonym key missing): a scheduled episode's run waits | none per episode; `show_waiting` once, after three waiting ticks in a row (a preview or extra says `show_episode_failed`) | never |
| a cap reached | `show_cap_reached`, once a month | no |
| held, or a preview made | `show_episode_held` | resets it |
| published | none | resets it |
| a quiet day, a show the creator switched off | none | no |

A failed DAY is one row in the bell, not one per attempt: the id is the show and the episode's day, so a retry of
that same day rewrites that row to the newest attempt (and opens the episode that just failed) while each attempt
still counts toward the pause, because a day retried three times and failed three times is a show that is not
making its episodes. Every one of these notices names the show the way the rest of the app does — by its project's
title — never by a second, editorial name.

An unexpected failure the queue will retry tells nobody until no retry is left.

### Out of credits

When credits **run out** — a refused spend (the story-job preflight, a
reservation, a consumption) on a balance below `CREDIT_OUTAGE_FLOOR`, one show
episode (short table read + MP3 = 26) — the owner hears **once per billing
cycle** (`server/credit-outage-notice.ts`): one in-app notification
(`kind: credits_exhausted`) plus the account email when Resend is configured.

A refusal is not an outage: the owner with 300 credits refused a 400-credit
render still has a table read's worth, so that stays a plain 402 with no notice.
The dedupe is the notification's **primary key**
(`credits_exhausted:<userId>:<cycle>`, inserted with `skipDuplicates`): no new
column or unique index, because the deploy's `prisma db push` (no
`--accept-data-loss`) refuses a new unique constraint on a non-empty table.

### Shows running, and the flags

`AIFeatureConfig.showsRunning` (`server/shows-running.ts`) is the operator's
platform-wide stop for unattended show spend, off by default. Off: the sweep
fires nothing and claims nothing, a scheduled episode being made stops at its
next step and is held (`shows_not_running`), and a creator's previews and extras
still run. Only an admin changes it, read fresh from the database; every change
is audited (`AIAuditLog` feature `shows_running`). Surfaces: `GET|PUT
/admin/shows-running`, MCP `set_shows_running`, `sleeperhit admin shows-running
[on|off]`, Admin → Settings → Shows running.

| Env | What |
|---|---|
| `SHOW_PSEUDONYM_KEY` | the key made-up commenter names are derived from (Secret `show-pseudonym-key`); unset, a show with made-up names is not ready |
| `SEASON_MUSIC_AUTOREPAIR_ENABLED` | on (unset) |

Stage sets `PG_BOSS_SCHEDULE_ENABLED=0` because it shares application data with
production, so `show-cadence-tick` never fires there on its own.

## Memory

`seriesContext` used to be built from where an episode was *going* (its own
summary). Nothing fed a **finished** script back into the next plan, so a
recurring show could not tell it had used the same running gag four days
running.

Recording is deterministic — literal word-boundary matching against declared
values, no model call. A model asked "what did this episode use?" invents
callbacks to episodes that never aired, and a continuity note built on one is
worse than no memory at all.

The note names what is still **unused** alongside what was spent: "do not reuse
these" leaves the writer guessing; "these three are still free" does not.

## Public surface

Every public show has a site at **`https://sleeper.show/{slug}`** (the series
slug, the one that names its feed), or at its own domain.

| Address | Route | Shows |
|---|---|---|
| `sleeper.show/{slug}` (or `{domain}/`) | `/s/{slug}` | The show and its episodes, newest first, each playable; search (`?q=`) once it has 12+ |
| `sleeper.show/{slug}/{n}` (or `{domain}/{n}`) | `/s/{slug}/{episode}` | One episode by number (or by release id): player, notes, sources, share, `og:audio` |
| `sleeperhit.studio/s/{slug}/…` | — | 301 to the show's one address |

`lib/shows/visibility.ts` is the single place that decides exposure, so the site,
the routes and the feed cannot form three opinions.

| status × visibility | landing | archive | share | feed | indexable |
|---|---|---|---|---|---|
| active/paused + public | ✅ | ✅ | ✅ | ✅ | ✅ |
| active/paused + unlisted | — | — | ✅ | ✅ | — |
| draft + public | — | — | — | ✅ | ✅ |
| draft + unlisted | — | — | — | ✅ | — |
| private (any status) / archived / deleted | — | — | — | — | — |

*feed* is whether `/api/v1/publishing-feeds/{slug}.xml` (`showFeedPath`) answers
at all; `renderPublishingRssFeed` reads the same `showExposure`, and the landing
page links it. The owner decided the feed rule on 2026-10-09:

- A **private** show's feed is a 404, with the same body as a slug that does not
  exist, like any page it hides.
- An **unlisted** show keeps its feed: it is reachable by link, and podcast apps
  need it.
- A **draft** keeps its feed, because a series is created as a draft and can
  publish before anyone marks it active.

*indexable* is whether search engines and podcast directories may list what is
served (legal gap G4). An unlisted show's episode pages carry `noindex`. Its feed
is answered with `X-Robots-Tag: noindex, nofollow, noarchive`, and its XML carries
`<itunes:block>Yes</itunes:block>` and `<podcast:block>yes</podcast:block>`, so
Apple Podcasts and podcastindex.org directories leave it out. A public feed's XML
carries neither.

Visibility never stops an episode from publishing. An unlisted show's episodes
publish as a public show's do. A private show's RSS target is still marked
published, so making the show public or unlisted again brings the same episodes
back under their own GUIDs.

`paused` stays readable on purpose: a show between seasons still has an archive,
and hiding it would break every link ever shared.

The share grant is the unguessable release id, not a token, so a forwarded link
keeps working without handing over the rest of the show.

`server/show-public.ts` takes **no `userId`**. There is no code path in it that
could serve a private draft because a session happened to be present.

A show whose **project is deleted** is hidden too, whatever its own status says.
Deleting a project marks only the project row, so every page served without a
session checks the project: the show, its archive and episodes, the feed, deck
and trailer links, table-read shares, the script hub, the writer profile,
discovery, shared chats and the recording link. The rule is defined once, in
`lib/public-live-project.ts`.

## Surfaces

A show is on all five (four surfaces plus Slug), through the same cores
(`server/show-api.ts`) and the same request builders (`@sleeperhit/shared`
`show.ts`):

| Surface | Where |
|---|---|
| API | `GET\|PATCH /publishing-series/{id}/show`, `POST …/show/check-sources`, `POST …/show/episodes`, `GET …/show/episodes/{episodeId}`, `POST …/episodes/{episodeId}/publish\|drop\|refinish`, `GET …/show/runs`, `POST …/show/state`, `GET /show-source-suggestions`, `GET\|PUT /admin/shows-running` |
| MCP | the `shows` workflow group: `get_show`, `set_up_show`, `suggest_show_sources`, `check_show_sources`, `quote_make_show_episode`, `make_show_episode`, `get_show_episode`, `list_show_runs`, `publish_show_episode`, `drop_show_episode`, `quote_fix_show_episode_line`, `fix_show_episode_line`, `quote_refinish_show_episode`, `refinish_show_episode`, `set_show_state`; `set_shows_running` (admins). The ChatGPT profile carries the reads, the setup, the preview and on/off |
| CLI | `sleeperhit show get\|setup\|suggest-sources\|check-sources\|make-episode\|episode\|runs\|publish\|drop\|fix-line\|refinish\|on\|pause\|resume\|off`; `sleeperhit admin shows-running [on\|off]` |
| UI | Publishing tab → **Show** (`components/writing-lab/show-panel.tsx`, server fns in `server/show-ui.ts`): the show read (status, next step, setup lines), its episodes with Publish, Drop and Fix a line, on/pause/off, and who makes it with the standing approval. Admin → Settings → Shows running |
| Slug | the project's own chat: the same tool names as MCP (`lib/ai/show-chat-tools.ts`) |

The Show tab is a read-out beside the Slug conversation that sets the show up
(memory slug-split-pane-not-forms): it writes only the direct acts (turning it
on, publishing, dropping), each confirmed. Slug's spending and airing steps take
`confirmed: true`, sent only after the writer said yes; a refusal comes back as a
value the reply can explain (`tests/integration/a-writer-sets-up-a-show-in-the-project-chat.test.ts`).
On a show an outside app makes, the tab shows who makes it and what it did, its
previews with Drop only, and never "Turn the show on".

The standing approval is on all four too:

| Surface | Grant / revoke | Use |
|---|---|---|
| API | `PATCH /publishing-series/{id}` `{ standingApprovalKeyId, userConfirmed: true }` | approve / publish without `userConfirmed`, as the bound key |
| MCP | `update_publishing_series` `standingApprovalKeyId` + `userConfirmed` | `approve_plan` / `publish_publishing_release` with `standingApproval: true` |
| CLI | `publishing series update --standing-approval-key <id\|self\|null> --confirm` | `plans approve --standing-approval`, `publishing releases publish --standing-approval` |
| UI | Publishing tab → Show, its one home: Standing approval (grant, move, revoke) on a show Sleeper makes; on a show an outside app makes, the producer block's Stop the app publishing and Move to another key (offered at once when the app's key died). Distribution only links to it | — (a person in the app is the confirmation) |

`publishingSeriesFieldsFromBody` is the one mapper both series routes use, and
`tests/unit/publishing-series-fields.test.ts` asserts create and update accept
exactly the same set. None of a show's setup is a series field.

## Things that will bite you

- **pg-boss keeps schedules in its own table.** Deleting the code that called
  `boss.schedule(name, ...)` does not unschedule it; the row keeps firing into a
  queue with no worker. Add the name to `RETIRED_SCHEDULES` in the same change.
- **A provider fallback chain whose tail is unhealthy** parks every StoryJob in
  `provider_backoff` forever. An unattended show will happily keep firing into
  that hole every hour.
- **Series Bible coverage below 7.0 blocks script coverage outright.** This is
  why per-episode coverage is off for recurring shows by default: a
  below-threshold Bible would silently park an unattended show forever.
- **An external producer's retries leave residue.** Before #852 a retried
  `POST /story-jobs` minted a new never-started job per attempt (FAILED
  `insufficient_credits`, PENDING): ~1,644 on prod, each still resumable into a
  duplicate episode. `scripts/cleanup-recurring-producer-residue.ts` (dry run
  by default) cancels them through `cancelStoryJob` — a FAILED job that never
  started is cancelable for exactly this, and a cancelled job then reads
  `retryable: false` — keeping one live or resumable attempt per plan,
  skipping anything a release, season, plan revision, scaffold, orchestration
  key or HNR's D1 still points at, and only LISTING stray series. It runs from
  a laptop against an explicit `DATABASE_URL` (prod through the kubectl
  tunnel) and prints the target database first. `--apply` needs `--user`, the
  owner's sign-off, the HNR D1 export (`--hnr-d1-ids`, which must name one of
  the account's series) and the dry run's `--json` report
  (`--approved-report`): it cancels only jobs that report lists AND that are
  still junk. The script header has the tunnel and wrangler commands.
