Skip to content

Sleeper Hit makes produced episodes: the script, the voices and the mix.

Sleeper Hit Docs

Shows

A show is a publishing series: its one shape on every layer, its setup, schedule, caps and sources, the episodes it makes on its own, and what it publishes.

View as Markdown
On this page (23 sections)

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).

ThingName everywhere
The setup documentshowSetup
The scheduleshowSchedule: entries { id, every, at, weekday?, dayOfMonth?, covers, episodes }
The creator's switchshowOn; the show's state is not_set_up | off | on | paused | external
The capsepisodeCap, monthlyCap
Hold each episode for reviewholdForReview
A run of a schedule entryShowRun
The operator's stopAIFeatureConfig.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

ThingHomeWhy
Cast, tone, format, world, episode historySeries BibleThe 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, memoryshowSetup (lib/shows/show-setup.ts)Typed things the runtime branches on, that a prose Bible cannot express.
When it runsshowScheduleEntries with stable ids: a run is claimed per entry id and local date.
Banked themeruntimeStatePer-series, written once, replayed forever.
Pinned voicescast_canon documentThat document already pins character identity — avatar, body figure, sheet. Voice is its missing audio half.
What an episode spent of the show's memoryShowEpisode.memoryRecorded 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_BRIEFRule 10: prompt text lives in DB rows, editable without a deploy. Source: scripts/seed-snippets/SHOW_EPISODE_BRIEF.md.
An episode's one-sentence descriptionSnippet SHOW_EPISODE_DESCRIPTIONThe 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.

BoundValueWhat it stops
MIN_SPOKEN_WORDS_PER_PAGE85A thin take shipping as a half-episode
MAX_JOB_ATTEMPTS3Rerolling forever on a bad writer day
MAX_PLAN_ROUNDS3Re-planning forever on output-budget failures
SHOW_EPISODES_PER_RUN_MAX5A hot feed stampeding production
MAX_EPISODES_PER_SERIES_PER_DAY10Several 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 coversIt never covers
POST /story-plans/{planId}/approve for a table-read plan in the show's projecta 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 itan 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.

FieldShape
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
episodePages4–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)
episodesAtOncehow 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, hardLineshow the show swears, and the words it never says
commenterNameshandles | pseudonyms (made-up names, the default for a new show; their key is SHOW_PSEUDONYM_KEY)
planner fieldsplanningNotes, castNotes, writingStyle, objective, outcome, musicStyle, sfxPolicy: each replaces the platform's default for its plan field
episodeNotes, themePrompt, sounds, voiceEffects, memorythe writer's notes (up to 8,000), the theme, the show's sound list, voice effects and memory
soundReusereuse | 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
episodeMediaaudio | 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, holdForReviewthe 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:

SurfaceWhere
APIGET /show-source-suggestions?topic=
MCPsuggest_show_sources (optional topic)
CLIsleeperhit show suggest-sources [--topic]
Slugsuggest_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:

StepWhen
set_upno sources or no schedule (never for a show an outside app makes)
get_readythe readiness read fails; the sentence names the blocker
set_capsno caps
make_previewno preview made since the setup last changed (for a show an outside app makes too)
turn_onthe show is off
review_heldan episode is held for review
resumethe show is paused
nonenothing 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:

FieldMeaning
episodeCapthe most one episode may spend; at least one episode's quote (and its hard-line refine, when it has hard lines)
monthlyCapthe 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

OpAPIMCPCLI
Read the sources now (free; saved ones or sources to try)POST …/show/check-sourcescheck_show_sourcesshow check-sources
Make one episode now: a preview, or an extra on a show that is onPOST …/show/episodes { kind, itemId? }quote_make_show_episode, make_show_episodeshow make-episode [--extra] [--item]
One episodeGET …/show/episodes/{episodeId}get_show_episodeshow episode
The runs, and the episodes asked forGET …/show/runslist_show_runsshow runs
Publish a held or preview episodePOST …/episodes/{episodeId}/publishpublish_show_episodeshow publish --confirm
Never air an episodePOST …/episodes/{episodeId}/dropdrop_show_episodeshow drop --confirm
Fix one line (re-mixed by itself after)POST …/episodes/{episodeId}/fix-linequote_fix_show_episode_line, fix_show_episode_lineshow fix-line
Re-mix after another editPOST …/episodes/{episodeId}/refinishquote_refinish_show_episode, refinish_show_episodeshow 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:

EndingNoticeCounts 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 onshow_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 rowshow_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 waitsnone per episode; show_waiting once, after three waiting ticks in a row (a preview or extra says show_episode_failed)never
a cap reachedshow_cap_reached, once a monthno
held, or a preview madeshow_episode_heldresets it
publishednoneresets it
a quiet day, a show the creator switched offnoneno

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.

EnvWhat
SHOW_PSEUDONYM_KEYthe 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_ENABLEDon (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.

AddressRouteShows
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 × visibilitylandingarchivesharefeedindexable
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):

SurfaceWhere
APIGET|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
MCPthe 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
CLIsleeperhit 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]
UIPublishing 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
Slugthe 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:

SurfaceGrant / revokeUse
APIPATCH /publishing-series/{id} { standingApprovalKeyId, userConfirmed: true }approve / publish without userConfirmed, as the bound key
MCPupdate_publishing_series standingApprovalKeyId + userConfirmedapprove_plan / publish_publishing_release with standingApproval: true
CLIpublishing series update --standing-approval-key <id|self|null> --confirmplans approve --standing-approval, publishing releases publish --standing-approval
UIPublishing 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.