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 @ignored 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(oneLABEL: texttemplate 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.tsfails 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;--applycreates a missing row;--apply --rewritereplaces 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: externaleverywhere, neveroffornot_set_up. It takes a setup (for previews) and keeps its key; its next step stops atmake_preview. - An extra, a publish and
onare refused on it with 409show_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;
onthen 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.tswrites every item the app covered as oneShowEpisode { kind: external }keyedexternal:<itemKey>, with its claim:publishedwith its release when it is in the feed,droppedotherwise. 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.tsbanks 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.tsrecords 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 sitsqueued.
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.
shouldRerollreturns 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: withoutuserConfirmedit 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
userConfirmedover 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 (findmust be in the script exactly once; otherwise the closest lines come back). Two calls: withoutuserConfirmedit 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
showsworkflow group (shared/src/mcp-onboarding.ts). Itsnextis seven steps, in order: 1. project and Bible (create_project { medium: audio, recurring: true }; the project'sshowSeriesIdis its show); 2. cast; 3. sources, schedule and length (set_up_showreads each new source and refuses one that reads nothing;check_show_sourcesfor a quiet show); 4. caps (get_show'ssuggestedCaps, agreed); 5. preview (quote_make_show_episode,make_show_episode,get_show_episode); 6. on (set_show_state); 7. after (get_show'snextStep,list_show_runs, publish, drop, refinish). - The
AGENT_PUBLISHING_GUIDANCEsnippet (file-backed inscripts/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 toRETIRED_SCHEDULESin the same change. - A provider fallback chain whose tail is unhealthy parks every StoryJob in
provider_backoffforever. 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-jobsminted a new never-started job per attempt (FAILEDinsufficient_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 throughcancelStoryJob— a FAILED job that never started is cancelable for exactly this, and a cancelled job then readsretryable: 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 explicitDATABASE_URL(prod through the kubectl tunnel) and prints the target database first.--applyneeds--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--jsonreport (--approved-report): it cancels only jobs that report lists AND that are still junk. The script header has the tunnel and wrangler commands.