Skip to content

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

Sleeper Hit Docs

Server functions

Every server function the app calls, by file, with the method it answers, who may call it and what it returns.

View as Markdown
On this page (98 sections)

All server functions use createServerFn from @tanstack/react-start with .inputValidator() for Zod validation (NOT .validator() -- this is a v1.133.5 API).

marketing-analytics.ts

FunctionMethodAuthDescription
getMarketingShareAnalyticsGETRequiredReturns share-link analytics for one owned script version.
getProfileShareAnalyticsGETRequiredReturns user-wide writer-profile, Table Read, and Pitch Deck share analytics for Profile → Public Profile → Analytics.

performance-booth-fns.ts

Internal pipeline naming remains performance-booth; the user-facing Table Read stage is Audio Acting.

FunctionMethodAuthDescription
getPerformanceBoothSessionGETRequiredLoads or creates the current Table Read Audio Acting recording link, projected screenplay lines, and completed line indexes.
getPerformanceBoothByTokenGETTokenLoads the microphone-only phone recorder session for a signed recording link.
getPerformanceBoothStatusByTokenGETTokenPolls completed Audio Acting line conversions without requiring a dashboard session.

account-deletion-fns.ts

Deleting one's own account (legal gap G2). The core is account-deletion.ts (request, cancel, status, the admin closure, the daily sweep) and account-deletion-purge.ts (the purge, step by step); /api/v1/account/deletion reaches the same core for the Story API, CLI, MCP and the mobile app.

FunctionMethodAuthDescription
getMyAccountDeletionFnGETRequired (any signed-in account)Where the deletion stands, the credit balance with any top-up still inside its 14-day refund window, what deleting removes and keeps.
requestMyAccountDeletionFnPOSTRequiredSchedules the deletion 7 days out; confirmEmail must be the account's email. Refused for an admin, a workshop host or an organization owner.
cancelMyAccountDeletionFnPOSTRequiredCancels a scheduled deletion until the purge starts.

account-export-fns.ts

"Download my data" (legal gap G3). The core is account-export.ts (request, status with a fresh signed link, the daily sweep) and account-export-archive.ts (the zip, built on the workers); /api/v1/account/export reaches the same core for the Story API, CLI and MCP.

FunctionMethodAuthDescription
getMyAccountExportFnGETRequired (any signed-in account)Where the latest archive stands; when ready, a signed link that works for an hour.
requestMyAccountExportFnPOSTRequiredStarts building one (or returns the one building).

project-deletion-fns.ts

Deleted projects (legal gap G2). The core is project-deletion.ts (delete, list, restore, the daily sweep) and project-deletion-purge.ts (the purge after the 30-day window). Deleting takes the project's episodes with it: during the window they stay linked and private (Script.privateAt), a restore puts back exactly what that delete changed, and at the purge they are deleted with everything under the project (episodesAtPurge).

FunctionMethodAuthDescription
listMyDeletedProjectsFnGETWriting Lab accessDeleted projects still inside their restore window, with restorableUntil and episodesKept.
restoreMyProjectFnPOSTWriting Lab accessRestores one within its window; episodes deleted or moved since stay where they are.

courses.ts

FunctionMethodAuthDescription
getCoursesGETOptionalLists published courses with lesson counts and per-user completion. Applies admin preview override.
getAllCoursesWithLessonsGETOptionalFull course + lesson tree with progress data and sequential unlock state. Used by dashboard teaser and courses page.
getCourseWithLessonsGETOptionalSingle course with lessons. Input: {slug}. Returns hasAccess, isAdmin, isAuthenticated.
updateLessonProgressPOSTRequiredUpserts LessonProgress. Input: {lessonId, watchedSeconds, completed}.
getSignedPlaybackUrlGETRequiredRefuses an unpublished lesson or course except to its mentor or an admin, then validates plan access (the catalog's freemium rule) + sequential lesson order, returns {playbackId, token}. Input: {lessonId}.

Admin Preview Override

All course functions read the sh_preview_tier cookie for ADMIN users to override tier/role for testing. Implemented via applyPreviewOverride().

dashboard.ts

FunctionMethodAuthDescription
getDashboardDataGETRequiredReturns {user, subscription, profile, progressStats}. Applies admin preview override to planTier/planStatus.
updateProfilePOSTRequiredUpdates User.name and upserts UserProfile. Input: {name?, location?, referralSource?, publicProfileEnabled?, publicSlug?, publicHeadline?, discoverySummary? (About you), …discovery fields}.
getRandomQuoteGETRequiredReturns a random active RotatingQuote with locked boolean based on sequential lesson order for the user.

customer-api-keys.ts

Customer-facing Story API keys are separate from provider/admin EncryptedAPIKey records.

FunctionMethodAuthDescription
getCustomerApiDashboardGETRequiredReturns the current user's customer API keys, allowed scopes, and Studio Credit balance for the /dashboard/developers Developers page API Keys tab.
createCustomerApiKeyPOSTRequiredCreates a production customer API key, stores only the hashed secret, and returns the plaintext token once. Input: {name, scopes, allowedIps?, expiresAt?}.
revokeCustomerApiKeyPOSTRequiredRevokes a customer API key owned by the current user. Input: {keyId}.

customer-api-auth.ts

These are route helpers for /api/v1, not createServerFn exports.

HelperDescription
authenticateCustomerApiRequestParses Authorization: Bearer, verifies the customer API key hash, checks revocation/expiration/IP allowlist/scopes, and updates lastUsedAt.
handleCustomerApiRouteWraps authenticated API routes with request IDs, keyed rate limiting, safe JSON errors, rate-limit headers, and request logging.
runCustomerApiIdempotentJsonProvides reusable idempotency-key handling for future expensive JSON POST operations.
getCustomerApiCreditSummaryReturns a Studio Credit balance summary for authenticated API routes.

story-api-route.ts

Story API-specific wrappers layered on top of handleCustomerApiRoute.

HelperDescription
handleStoryApiRouteGeneric route wrapper. Catches StoryApiError and emits { error: { code, message, requestId } } at the configured status.
handleStoryApiIdempotentRoutePOST wrapper. Replays prior responses when an Idempotency-Key header is supplied; otherwise runs the handler once.
readJsonBodySafe JSON body reader. Throws validation_failed on malformed input.
extractProjectId / extractSourceId / publishing extract helpersPull path params from the URL — works under both nested route files and catch-all flows.

story-projects.ts

Business logic for story projects. Exposed via /api/v1/story-projects routes; not createServerFn.

FunctionDescription
createStoryProjectValidates name/description/metadata and inserts the row. Returns the serialized envelope.
listStoryProjectsCursor-paginated tenant-isolated list, newest first.
getStoryProjectSingle read; throws project_not_found for cross-tenant or deleted rows. Returns workspaceGate and tableReadReadiness (the canon-cast preflight from table-read-cast.ts); both are pure reads.
updateStoryProjectPartial patch (name, description, metadata, archived, seasonCadence). seasonCadence ("monthly" or null) is merged into metadata by setProjectSeasonCadence (episode-season.ts) so the seasons list survives; it is refused beside a whole-metadata write. Serialized projects carry seasonCadence.
deleteStoryProjectSoft delete via deletedAt.

episode-season.ts

Which season a new episode Script goes into. Not createServerFn; called by the plan's table-read scaffold (story-plan-table-read-adapter.ts, so Story API jobs, season runs and recurring producers) and startPlannedEpisode.

FunctionDescription
fileNewEpisodeInSeasonA project with metadata.seasonCadence: "monthly": the season named for the month (UTC) the episode's source originated (readEpisodeOriginAt: the first plan source's metadata.originatedAt, else the first source's createdAt, else the Script's own creation time), found by name or added after the last season under the project row's lock (resolveMonthlySeason in lib/writing-lab-seasons.ts; existing seasons are never renumbered). Any other project: the plan's intended season (readPlanIntendedSeason) registered on the project, else Season 1. Takes the caller's transaction when the Script is created in one.
readPlanIntendedSeasonThe season a plan intends: its season run's, else its Series Bible entry's stored seasonNumber. Null otherwise (labels are never parsed).
setEpisodeSeasonForUserTHE ONE SEASON MOVE (seasons design §2.1). A planned episode (episodeId, or a scriptId the plan links) moves through the Bible save (saveSeriesBibleForProject), whose transaction moves its script too; a script no Bible entry plans moves alone under the project row lock. Adds the season to metadata.seasons. Called by updateScriptForUser (PATCH /scripts/{id} seasonNumber) and updateWritingLabEpisodeSeason.
mirrorBibleEpisodeSeasonsInside every Bible save's transaction: the scripts of an entry whose seasonNumber changed move with it, and a season the Bible now plans in joins metadata.seasons. Nothing is read or written when nothing moved.
readArtifactEpisodeDefaultsThe season and the description of the Script a publishable artifact performs (by its script version, script, or plan); the defaults for a release created without either.
setProjectSeasonCadenceSets or clears seasonCadence, keeping every other metadata key. Shared by updateStoryProject (PATCH, MCP, CLI and Slug's set_project_format); the Seasons tab reads it with the arc (getProjectArc's seasonCadence), read again when Slug finishes set_project_format.

table-read-cast.ts

The table-read cast precondition — canon-cast (owner decision 2026-09-29). See docs/table-read.md, "Headless table reads: canon cast (owner decision 2026-09-29)".

FunctionDescription
assertTableReadCastReadyCalled by createStoryJob / resume / refine scheduling before the row, quote and reserve. Checks, in order: the job's own screenplay (a resumed job), an adopted scriptUploadId, the read a refine revises, else the project cast canon (+ request voiceMap, + narrator for a narrated read). Throws 409 cast_precondition_failed with details.reason / details.missing.
pinTableReadCastWorker half, after the scaffold: refuses an uncast speaker (the job fails, releasing credits) or writes every member's voice, under every name, onto the read's voiceMap.
adoptTableReadScriptCreates the job-owned TableReadJob for an adopted finalized screenplay with its cast pinned; idempotent per story job.
readProjectTableReadReadinessThe canon half as project.tableReadReadiness.
getProjectTableReadReadinesscreateServerFn (GET) for the project Cast page's Table Read cast line; owner-scoped.

story-project-documents.ts (readers)

Every script is born into a project (New Project, an upload, a plan), so there is no "create a project around this script" core left: reading a script never creates a project, and the only write that wraps one is findOrCreateStoryProjectForScript (row below). The rows here are the read side.

FunctionMethodAuthDescription
getScriptSeriesBible / getSeriesBibleForScript (story-project-documents.ts)GETRequiredRead-only. Finds the project with findLiveStoryProjectIdForScript. For a script in no live project it returns { storyProjectId: null, document: null, generation: null, generationAvailable: false, coverageAvailable: false } and writes nothing (ScriptSeriesBibleResult is a union on storyProjectId). The script's Overview then shows "This script isn't in a project yet" with the Create a project button, the only UI path that creates one.
findLiveStoryProjectIdForScript (story-project-documents.ts)helperOwnerThe live project a script is in, or null when it is in none or only in a soft-deleted one. Never creates a project and never re-pins the script.
findOrCreateStoryProjectForScript (story-project-documents.ts)helperOwnerA write: re-pins the script to the live project keyed by its implicitScriptId, or creates one. Returns { storyProjectId, created }; created is true only when this call made the project. Besides this core, only paths that already write still wrap a script this way: table-read finalize (findOrCreateImplicitStoryContext), a trailer render (startTrailerRenderForUser) and a dialogue storyboard render.

getScriptByRouteIds (scripts.ts) treats a soft-deleted project as no project, and still finds a script addressed by its own route id in the project slot after it has joined a project. The Writers Lab detail loader then redirects that stale URL to the project's URL, so the Back button and sidebar links built before the script joined keep working.

script-overrides.ts

The five episode overrides (scripts.overrides.get / scripts.overrides.update): avatar (table_read_avatar_style), theater (table_read_theater_style), soundscape (table_read_soundscape_style), pitchDeck (pitch_deck_visual_direction) and locations (storyboard_visual_direction) — ScriptSidecar rows on one script version, layered over the Series Bible's visual canon (the soundscape over the studio default). The list, the keys, the 8,000-character cap and the refusal sentences live once in @sleeperhit/shared's script-overrides.ts (SCRIPT_OVERRIDES, parseScriptOverridesPatch). One core for all four surfaces: the Writers Lab editors (Cast: avatar; Table Read theater settings: theater + soundscape; Videos tab Series assets panel: pitch deck + locations), GET/PATCH /api/v1/scripts/{scriptUploadId}/overrides, the get_script_overrides / update_script_overrides MCP tools, and sleeperhit script overrides get|update. Free; saving renders nothing.

Video editing scope (conversations.videoEditingScope.set): setVideoEditingScopeForUser verifies the existing conversation belongs to the named trailer/deck and changes only its selected segment/chapter. UI Discuss controls, Slug set_video_editing_scope, PATCH /api/v1/conversations/{conversationId}/video-editing-scope, MCP set_video_editing_scope, and sleeperhit conversations scope set <conversationId> --surface trailer|pitch-deck --job <id> (--scene <sceneIndex>|--whole) share it. sceneIndex: null selects the whole cut. The response includes previous/current focus and current order; structural edits change positions, so read that order before selecting again. The write merges current metadata in a serializable transaction. Free, naturally idempotent, no new conversation, render or change to edit/spending consent. Story API scope: story:write.

On shared product-project decks, the author's bound thread and each currently authorized member's own memberPitchDeckJobId thread use the same focus core. A viewer may change their own focus but cannot change the plan. Add, remove and reorder clear positional focus on those governing threads in the same plan transaction, including member threads before the author has opened one; revoked, orphan and display-only editsPitchDeckJobId threads retain their unrelated metadata.

FunctionMethodAuthDescription
readScriptOverridesForUser—Owner; or an admin when isAdmin is passedAll five keys (null when unset) plus soundscapeDefault. Someone else's or a missing script throws ScriptOverridesNotFoundError (the API's 404 script_not_found). Only the Writers Lab passes isAdmin (from its session), so an admin may edit any script there; the Story API route passes none, so it is owner-only like every other Story API route — an admin's API key included.
updateScriptOverridesForUser—Owner; or an admin when isAdmin is passedValidates the partial change first (ScriptOverridesValidationError, the API's 400 validation_failed): text sets (trimmed), null or blank text deletes the row, unknown keys and non-text or over-long values are refused. Applies every write in one $transaction, creates rows at the script's versionNumber, and answers the fresh read.
getScriptSidecars / saveScriptSidecar / deleteScriptSidecar (table-read.ts)GET / POST / POSTWriters Lab accessThe editors' server fns over the same core, keyed by sidecar name. All three answer the read above.

The writer's own episodes (scripts.ts, project-episodes.ts, writing-lab-projects.ts, script-export.ts)

What the Writers Lab does with a script the writer made — list a project's episodes, upload a screenplay or a new version, read one script and change its fields and season, delete one version, export it, name a season — reached the Story API, MCP and the CLI only through a plan-made artifact, so an episode made in the app was invisible headlessly (parity audit 2026-10-09). Each is now one core that the Writers Lab's server fns and a Story API route both call; MCP and the CLI reach the route through the shared builders in @sleeperhit/shared's writer-episodes.ts, which also holds the limits and the one parser per input (parseScriptMetadataPatch, parseScriptUploadBody, parseScriptVersionUploadBody, parseScriptExportOptions, parseProjectSeasonName). A script id may be the script's own, the id in its Writers Lab URL, or any version's id (findOwnedScriptId, owner-only, calibration scripts excluded). All free.

CoreSurfacesNotes
listProjectEpisodesForUser (project-episodes.ts)Overview Seasons tab + Videos page; GET /api/v1/story-projects/{projectId}/episodes; MCP list_project_episodes; sleeperhit projects episodesReads through getWritingLabProjectForUser (with withCharacters: false) and getProjectVideoLibraryForUser, so it lists exactly what the page lists, earlier attempts marked supersededBy. season narrows the episodes.
createProjectSeasonForUser (writing-lab-projects.ts)"Add season" (createWritingLabProjectSeason); POST /api/v1/story-projects/{projectId}/seasons; MCP create_project_season; sleeperhit projects seasons add409 project_precondition_failed until canPlan. Written under the project row lock (readProjectMetadataForUpdate, project-metadata.ts).
uploadScriptForUser (scripts.ts)Writers Lab upload (uploadScript); POST /api/v1/scripts; MCP upload_script; sleeperhit script uploadA script in a project (named with projectId, gated; else one of its own). A new screenplay can come from a file (sourceId/fileUrl, needs projectId): still being read answers waiting, one source makes its script once. The writer's upload cannot set isCalibration; the admin gold-standard upload calls createInitialScriptUpload itself. 403 plan_limit_reached past the page allowance.
uploadScriptVersionForUser (scripts.ts)"Upload a new version" (uploadScriptVersion); POST /api/v1/scripts/{scriptId}/versions; MCP upload_script_version; sleeperhit script versions addCopies the previous version's sidecars forward. 403 plan_limit_reached on the free plan or past the allowance.
getScriptForUser (scripts.ts)GET /api/v1/scripts/{scriptId}; MCP get_script; sleeperhit script showThe WriterScript every write answers with (lib/writer-scripts.ts).
updateScriptForUser (scripts.ts)Edit dialog (updateScriptMetadata), season picker (updateWritingLabEpisodeSeason), Slug set_script_metadata; PATCH /api/v1/scripts/{scriptId}; MCP update_script; sleeperhit script updateTitle (and the latest version's), logline, genre, seasonNumber — no slug (an episode's address is its route id) and no mediaType (its shape is the project's, asked once at New Project; BL-4063). The logline is Script.description everywhere (D17; coverage keeps its own); genre is free text up to 80 characters with the shared suggestions. A season move goes through setEpisodeSeasonForUser (episode-season.ts): a planned episode's Bible entry moves with its script.
readProjectArcForUser (project-arc.ts)getProjectArc (project-arc-fns.ts, requireWritingLabWorkspaceAccess); GET /api/v1/story-projects/{projectId}/seasons; MCP and Slug list_project_seasons; sleeperhit projects seasons listThe full arc: every season (name, arc, summary — the one plain line every surface shows, projectArcSeasonSummary: a story's arc; a product's arc only when already a short plain line, else read from its features, with summaryNote for what comes after its main path — production offer and run), every episode in it (the Bible's plan and every written script it does not plan, source: script; a product's features with availability, footage line and feature videos by TrailerJob.bibleEpisodeId), its state, and next, the one next step (projectArcNextStep in shared/project-arc.ts; a product's setup step comes from readProductVideoPlanningReadiness). A recurring show reads as its newest six months (PROJECT_ARC_LOG_WINDOW); months reads its newest N (the Seasons tab's earlier months, "Show 6 more", ?months=, --months). season narrows the seasons. getProjectArc adds each script's route id and the project's seasonCadence. Read-only, free.
updateWritingLabEpisodeSeason (writing-lab-projects.ts)The season picker{ scriptId, seasonNumber } for a written episode, or { projectId, episodeId, seasonNumber } for a planned one (a Bible write); both through setEpisodeSeasonForUser.
deleteScriptVersionForUser (scripts.ts)"Only version N" (deleteScriptVersion); DELETE /api/v1/scripts/{scriptId}/versions/{scriptUploadId}; MCP delete_script_version; sleeperhit script versions delete409 script_in_use from findScriptVersionDeleteBlockers (D8: a live release whose read performs the version, a running job, a draft, a season-run slot, a deck or trailer anchored to it); 409 script_state_invalid for the only version.
exportScriptForUser (script-export.ts)Export menu (getFountainContent / getFinalDraftContent / exportScriptPdf); GET /api/v1/scripts/{scriptUploadId}/export; MCP export_script; sleeperhit script exportFountain / fdx as text, PDF base64. A script id exports its latest version. Owner-only; the Writers Lab passes isAdmin from its session. 409 script_state_invalid for a version not parsed yet.

| startPlannedEpisodeForUser (writing-lab-projects.ts) | "Start" on the project and Series Bible pages (startPlannedEpisode); POST /api/v1/story-projects/{projectId}/episodes (startPlannedEpisodeEnvelopeForUser, project-episodes.ts); MCP start_planned_episode; sleeperhit projects episodes start | The Bible episode's empty draft, or the script that already covers it. 409 project_precondition_failed until canStartEpisode. Its season is filed by fileNewEpisodeInSeason (episode-season.ts). | | writeScriptContentForUser (draft-scripts.ts) | Editor saves (saveDraftContent with mode: 'draft', saveVersionContent with mode: 'version'); PATCH /api/v1/scripts/{scriptUploadId}/content; MCP write_script_content; sleeperhit script write | Headless callers leave mode out and the version's status decides. expectedContent is a compare-and-swap (409 script_state_invalid); season-run versions are refused. Only the newest version is written: an older one is refused (409 script_state_invalid, naming the newest version's id) — copy it into a draft to change its words. | | finishDraftForUser (draft-scripts.ts) | Finalize (upgradeToVersion); POST /api/v1/scripts/{scriptUploadId}/finish; MCP finish_draft; sleeperhit script finish | Without coverage, judgeCoverageCarryover's recommendation; adopt / fresh lock the draft as the dialog does. | | listCoverageReportVersionsForUser (coverage.ts) | Coverage tab history (getCoverageReportVersions); GET /api/v1/artifacts/{id}/coverage/versions (listArtifactCoverageVersions); MCP list_coverage_versions; sleeperhit coverage versions | Every report across the lineage, newest first. | | getBlackListReportForUser (blacklist.ts) | Coverage tab; GET /api/v1/artifacts/{id}/coverage/blacklist (getArtifactBlackListAnalysis); MCP get_blacklist_analysis; sleeperhit coverage blacklist | A report's analysis, default the latest complete report. |

The coverage and script reads under /api/v1/artifacts/{artifactId}/ (getArtifactScriptContent, requestArtifactCoverage, getArtifactCoverage and the two above) resolve with allowWriterScript (story-jobs.ts): with no artifact of that id, the id may name a writer's own episode — a version, or a script by its id or Writers Lab URL id (its latest version). Music, SFX and performance stay artifact-only.

Drift fixed alongside: the project PATCH merges metadata and keeps metadata.seasons (mergeProjectMetadata, lib/project-metadata.ts — D7). D6, a plan's episode landing in the season it was written for, is fileNewEpisodeInSeason / readPlanIntendedSeason (episode-season.ts).

table-read-prepare.ts

Preparing a cast episode's table read (scripts.tableRead.prepare, owner decision 2026-10-04): the TABLE_READ charge — priced by the screenplay's pages, TABLE_READ_SHORT / TABLE_READ_LONG via resolveTableReadCreditQuote, the price a Story API job adopting the screenplay reserves — and the media it buys, queued through dispatchTableReadMediaStages (table-read-media-stages.ts, the call the Story API's own read makes). One core for all four surfaces: Cast's Continue to Table Read, the setup pane's POST /api/table-read/finalize, POST /api/v1/scripts/{scriptUploadId}/table-read/prepare, the prepare_table_read MCP tool and sleeperhit script table-read prepare.

FunctionMethodAuthDescription
prepareTableReadForScript—Owner (the caller's userId)Without confirmed: refusals (404 script_not_found, 409 cast_precondition_failed with script_not_finalized / screenplay_coverage_before_cast / script_not_cast_ready, the project gate, voice_retired), then the quote (status: quoted, credits, balanceAfter, sufficient, note); writes nothing. With confirmed: true: in one transaction under the version's ScriptUpload row lock it re-reads the read, fills the cast it lacks (creating a read for a canon-only episode), reserves the line under table-read:prepare:{scriptUploadId} (feature TABLE_READ) and marks the read media_generating; then queues the media and settles to CONSUME_PROVISION (status: queued). A version charged on any door answers prepared, a read whose media is in flight preparing — both free. Insufficient credits are a 402 that rolls the step back; a failed dispatch releases the line and restores the read.
prepareTableRead (table-read-prepare-actions.ts)POSTWriters Lab accessCast's Continue (trigger: 'cast_continue') and the Table Read tab's Prepare control (from: 'table_read', trigger: 'table_read_tab'): the core with surface: 'app'.
isTableReadVersionPaid / assertTableReadVersionPaid (table-read-paid.ts)—Server-onlyWhether a version's table read is paid for on any door (held or spent line for the version, a paid refine that wrote it, a live Story API job that wrote or performs it). The assert refuses with 409 table_read_not_prepared; it gates both Pipecat session starts, the bot's page feed, the durable audio render, the creative pass's media, the defined-clips baseline, and both doors' charge-once.
dispatchTableReadMediaStages (table-read-media-stages.ts)—Server-onlyQueues the sound effects, the defined-clip score and the adaptive soundtrack direction (deferMusic holds the music; keepFinishedSoundtrack keeps a complete soundtrack instead of composing a second). Returns soundscapeError rather than throwing: the Story API run logs it, the preparation releases its charge.

series-bible-interview.ts

The Writing Lab's New Project action opens a resumable, DB-sidecar-driven Series Bible interview. The model chooses one adaptive question and 2–5 project-specific options at a time; code validates structured turns, persists the full interview state and transcript, merges the Series Bible patch, and decides completion mechanically.

FunctionMethodAuthDescription
startSeriesBibleInterviewPOSTRequiredCreates a draft StoryProject and interview Conversation, invokes B2B_STORY_API / series_bible_adaptive_interview for the first structured question, and returns the conversation slug.
getSeriesBibleInterviewGETRequiredRestores the exact saved question, progress, completion state, and eventual script handoff for an owned interview.
listSeriesBibleInterviewDraftsGETRequiredLists the current user's resumable project-setup drafts for the Writing Lab library.
answerSeriesBibleInterviewPOSTRequiredValidates an option/custom response, persists the transcript and Series Bible patch, requests the next adaptive question, and creates the first Script only after every required Bible area is complete.

The same Series Bible document is exposed to API, CLI, and MCP clients. Its serialized response includes readiness.development and readiness.visuals; headless clients can fill the document directly and use those fields as the shared completion contract. In the Writing Lab, that document is presented as read-only project canon; browser-side changes are proposed and approved through the persistent Slug conversation, while foundational coverage remains a separate analysis action.

app-captures.ts / app-captures-fns.ts

App captures: real screen recordings of a web app, for a product trailer's beat to play. One core for the Story API (/story-projects/{id}/captures), the CLI (sleeperhit capture), MCP (import_app_capture, list_app_captures, delete_app_capture), Slug's list_app_captures and the project page's Captures panel (app-captures-fns.ts: listProjectCapturesFn, createProjectCaptureFn, finalizeProjectCaptureFn, deleteProjectCaptureFn, workspace access required). Free.

FunctionDescription
createAppCaptureForUserChecks the body field by field (unknown fields refused by name), creates the row in uploading, and answers presigned PUTs (presignR2ExactPut, size and type signed) for the MP4 and, when declared, the recorder's events.json and checkpoint stills.
finalizeAppCaptureForUserHEADs the objects (409 capture_not_uploaded while one is missing, until an hour past the URL's expiry, then rejected), streams the MP4 once to a temp file while hashing it, runs ffprobe twice (streams, then every packet's timestamp), and settles ready or rejected with lib/app-capture/conform.ts: H.264 yuv420p, constant 30 fps measured frame by frame, at most 1920×1080, bt709 tags, AAC when there is sound. A repeat answers the capture as it is.
listAppCapturesForUser / getAppCaptureForUserOwner- and project-scoped reads; a ready capture carries a presigned playbackUrl (one hour).
deleteAppCaptureForUserDeletes every object under the capture's prefix, then the row.

claims-ledger.ts / claims-ledger-fns.ts

The claims ledger (app-to-trailer round 2 S7b). One core (readClaimsLedgerForUser, decideClaimForUser) for the Story API (GET /story-projects/{id}/claims-ledger, story:read; POST /story-projects/{id}/claims-ledger/{claimId} with { decision: confirm|reject|reopen, note? }, story:write; 404 claim_not_found, 409 project_not_product), the CLI (sleeperhit app claims|claim), MCP (get_claims_ledger, decide_claim), Slug's tools of the same names (product projects' chats only) and the claims list in the project page's "Learn from your site" panel (claims-ledger-fns.ts: getProjectClaimsLedgerFn, decideProjectClaimFn; workspace access required). Free.

product-bible.ts / product-bible-evidence.ts

The product Bible writer (round 2 S7b). generateSeriesBibleForProject (story-project-documents.ts) writes a kind: product project's Bible with the story_api_product_bible sidecar from the feature inventory and the claims ledger, then saves it through the ordinary save; every surface that generates a Bible reaches it. product-bible-evidence.ts is pure: stable evidence keys (src-… from a page's address, claim-… from a claim's wording), the evidence the model is given, and holdProductBibleToEvidence (availabilityBound: every feature bounded by the inventory feature its id names or starts with, else the founder's record, else at most planned/unknown; stripRejectedClaims: a sentence stating a rejected claim is struck from every field but "do not claim"). The writer asks once more when a draft states a rejected claim, and the save holds the draft again under the claims ledger's lock (lockAndReadClaimsLedger, inside the save's transaction via contentFromBase(base, db)), recording claimsLedgerVersion (at save), claimsLedgerVersionAtWrite, rejectedClaimsAskedAgain and rejectedClaimsStruck in generatedFrom. Product coverage gets the same evidence index (productCoverageEvidenceBlock in series-bible-coverage-worker.ts).

feature-inventory.ts / feature-inventory-fns.ts

"Learn from your site" (app-to-trailer round 2 S7a). One core (startFeatureInventoryLearning, readFeatureInventoryForUser) for the Story API (GET|POST /story-projects/{id}/feature-inventory, reads source:read, starting source:write; 409 project_not_product for a story project), the CLI (sleeperhit app learn|inventory), MCP (learn_from_site, get_feature_inventory), Slug's tools of the same names (mounted on product projects' chats only) and the project page's "Learn from your site" panel (feature-inventory-fns.ts: getProjectFeatureInventoryFn, which answers isProduct: false for a story project, and learnProjectFromSiteFn; workspace access required). The work runs on the feature-inventory-learn queue (feature-inventory-worker.ts; render-owned). Free, at most 10 runs a day per account, counted under an account-level advisory lock; a story project is refused with the shared F3 refusal (project_not_product, details.work: 'learn_from_site').

connectionId is tri-state on UI, Story API, CLI, MCP and Slug: omitted chooses the project's verified app automatically, null reads public pages only, and a nonblank trimmed id (at most FEATURE_INVENTORY_LIMITS.connectionId, 128 characters) chooses that owned connection. An in-flight run is returned unchanged with its actual id/site/connection, without starting a second run. The exact existing CHAT app subject (metadata.source: app-connections, projectId) mounts the product-gated inventory/readiness tools, attach_source and only the existing update_series_bible write for feature-map confirmation/refinement. Shared reads provide the current Bible and sources. Saves retain their project permissions and relock confirmation. Slug's inventory digest supplies stable canonical src-* evidence ids plus human titles, without page addresses or private screenshot keys. The UI read separately projects projectFeatures from the saved product Bible, so a missing learned inventory does not erase saved features. Product app/Bible guidance is read live from PRODUCT_APP_FEATURES_COACHING, including when base behavior rows are customized.

app-connections.ts / app-connections-fns.ts

Stored app logins for hosted capture (H3). One core for the Story API (/story-projects/{id}/app-connections, reads source:read, writes app:connect), the CLI (sleeperhit app), MCP (list_app_connections, connect_app (a link, never a secret), create_app_connection and update_app_connection (settings only; a secret is never an argument — the writer types it through connect_app's link), test_app_login, delete_app_connection), Slug's connect_app (a link only) and the project page's "Apps to record" panel (app-connections-fns.ts: listProjectAppConnectionsFn, getProjectAppConnectionFn, createProjectAppConnectionFn, updateProjectAppConnectionFn, saveProjectAppConnectionSecretFn, testProjectAppConnectionLoginFn, deleteProjectAppConnectionFn, workspace access required). Every write is audited (AppConnectionEvent). Free.

FunctionDescription
createAppConnectionForUserMetadata only (a secret field is refused by name): an https public origin, extra origins, the sign-in page on one of them, method, header kind, preconditions, blur list, retention. At most 20 per project (409 app_connection_limit_reached).
updateAppConnectionForUserLocks the owned connection before reading its current fields and applying the patch. A change that adds an origin deletes every saved secret; a new method or header deletes the secrets the old one used. Each removal is audited. Concurrent label edits cannot restore a removed origin or carry newly saved credentials onto it.
saveAppConnectionSecretForUserChecks one secret for its kind, seals it (lib/app-connections/seal.ts; 503 app_connection_sealing_unavailable without a key) and stores it as the connection's one row of that kind. Answers metadata.
requestAppConnectionTestLoginForUserReadiness (every required secret saved, and a workspace when the kept sign-in steps ask for one) and an audit row, then the queued test. findSignIn: true ("Find it again"; API body { findSignIn }, MCP test_app_login.findSignIn, CLI app test --find-sign-in, Slug test_app_login.findSignIn) finds the sign-in steps afresh first.
deleteAppConnectionForUserDeletes the sealed secrets, then the connection, and writes the deleted audit row.
listAppConnectionsForUser / getAppConnectionForUserMetadata, which secret kinds are saved, connectUrl; one connection adds its newest 20 audit rows.

Sign-in steps (BL-2623, app-sign-in-recipe.ts + lib/sign-in-recipe/). A form sign-in follows a recipe: the sign-in page, the ONE form each step names, each field's role and label, the submit, reveal clicks, a cookie button. The test worker reads the newest kept version (AppConnectionSignInRecipe, last five kept, never on the connection row so the pass's updatedAt CAS holds); with none (or findSignIn), the scout surveys the SIGNED-OUT app on a runner (/v1/sign-in-surveys: a preview's headers are the only secret, opened headersOnly), and the app_sign_in_scout row's model (GPT-6.1 Sol, strict structured output, booked under PRODUCT, never counted) answers a flat draft that toSignInRecipe maps and checkSignInRecipe checks. signInRecipeNeeds checks it against the saved secret kinds before the login opens: a workspace field (needs_field, steps kept so every read asks for workspace), a field Sleeper cannot fill, single sign-on only (sso_only), a CAPTCHA or an emailed link stop with one sentence. A step the runner could not follow answers recipe_failed with the masked page; the app_sign_in_repair row mends the steps once, the login is opened again (audited) and the test dialled once more; a pass keeps the mended version (source: repair). Recordings and the signed-in walk call ensureSignInRecipe (scout first when none) and keep found steps once they signed in. Every read answers connection.signInRecipe (summary, steps' roles and labels, never a locator). Runners older than the steps (capabilities.signInRecipe missing) are sent nothing.

app-connection-retention.ts (runAppConnectionRetention, from the daily request-log-retention-tick): deletes audit rows past 90 days and connections past their own retentionDays.

The ChatGPT profile (/mcp/chatgpt) offers none of these yet: list_app_connections and connect_app (a link) join it in H5, with hosted capture's quote, record and status (decision 7). The two settings tools never join it: that profile is never granted app:connect.

app-connection-test-preview.ts

getAppConnectionTestPreviewForUser({ userId, projectId, connectionId, testId }) reads safe metadata for one exact current Test sign-in, without capturing a picture or starting another test. The Story API GET is /story-projects/{projectId}/app-connections/{connectionId}/test-login/{testId}/preview (source:read, project owner only, including owner OAuth). The CLI sleeperhit app preview <connectionId> <testId> --project <projectId>, MCP and Slug get_app_login_test_preview use the same shared metadata allowlist: step, state, sequence, freshness, picture availability and the canonical owner viewUrl. They never return an image, image URL, code or runner address; a replacement test is refused. CLI preview is metadata only and has no image-saving option.

The separate /preview/frame GET is for an owner session or personal API key; OAuth is refused. It returns a private no-store JPEG or 204 when no current safe frame exists. The open, visible Test sign-in dialog uses this channel and the existing exact-test secure code field. MCP and Slug do not expose the picture route. An arbitrary use of the owner's personal key cannot be identified as human or model use.

tour-drafts.ts / tour-drafts-fns.ts

Tour drafts (app-to-tour round 2 S8) — one drafting run per product feature, written from the feature's Bible entry, its evidence pages and the app connection's address, checked by the recorder's own rule and saved as an editable P-095 tour marked drafted. The app is never opened and no frame is recorded. One core (startTourDraftForUser, listTourDraftsForUser, getTourDraftForUser in tour-drafts.ts) for the Story API (GET|POST /story-projects/{id}/tours/drafts, GET …/tours/drafts/{runId}; the static drafts segment sits beside the static check), the CLI (sleeperhit tour draft|drafts|draft-status), MCP (draft_tour, list_tour_drafts, get_tour_draft), Slug's tools of the same names (the shared reads on every writer chat) and the project page's Tours panel (tour-drafts-fns.ts, workspace access required). The work runs on the tour-draft queue (tour-draft-worker.ts); free, at most 50 runs a day per account. An expected refusal (a story project, a feature the Bible does not list, a planned feature, no app connection or an ambiguous one, the daily cap, an unknown runId) is answered as data by these fns and as a value by Slug's tools — project_not_product, validation_failed, usage_limit_reached, tour_draft_not_found — never as a thrown request failure.

FunctionMethodAuthDescription
startProjectTourDraftFnPOSTWriting Lab accessStarts one draft run for a feature (featureId, optional connectionId) and enqueues its drafting job; answers { started, refusal }, where a refusal carries its code, sentence, and the features or connections to repair against.
listProjectTourDraftsFnGETWriting Lab accessThe project's newest 50 draft runs, or one feature's (featureId), each with its status, its check errors and the tour it staged.
getProjectTourDraftFnGETWriting Lab accessOne run (runId) with its staged tour once ready (tour_draft_not_found for an unknown run).

Bible reference reads

getSeriesBibleForProject returns a read-only references index beside canonical content, shared by the Writing Lab, Story API, CLI, MCP and Slug's get_series_bible. After project access is checked, it reads only that project's source labels and safe web addresses, inventory page names, claim statements and feature names. The index maps existing keys to human labels and links; available means the target resolves, not that a claim is confirmed or a feature is shipped. Deleted or missing evidence reads “Source unavailable”. No foreign row is looked up to resolve a cited key.

The Bible reader resolves exact known citation tokens in prose and keeps evidence names in closed disclosures. Unknown text, literal code and addresses are retained. Neither the read nor its presentation changes content, feature IDs, evidenceIds, the claims ledger or the saved Bible version. The index is not part of PATCH content. Product writers keep human names in prose and canonical keys in structured fields; the locked save still enforces availability and rejected claims.

series-bible-generation.ts and series-bible-generate-worker.ts

Writing a project's Series Bible from its sources runs in the background (#1509 L5): ChatGPT drops a tool call after about 60 s, and a Bible takes a minute or two (110–140 s measured). The contract every surface shares lives in @sleeperhit/shared series-bible-generation.ts (generateSeriesBibleRequest, getSeriesBibleRequest, SERIES_BIBLE_GENERATION_SENTENCE).

FunctionDescription
startSeriesBibleGenerationRecords a SeriesBibleGeneration run and sends it to the series-bible-generate queue (stately, keyed by the run, so a re-send never doubles it), answering at once { status: 'generating', projectId, generation, alreadyRunning }. Under a transaction-scoped advisory lock on the project, a second ask while a run is generating returns that run (alreadyRunning: true), never a second; a run past its stale mark (45 min) does not block a new one. A failed send marks the run failed with a reason for the writer. Called by POST /api/v1/story-projects/{projectId}/series-bible, the generateProjectSeriesBible server fn and Slug's generate_series_bible.
readSeriesBibleGenerationThe project's newest run as every Bible read reports it (generation: generating, ready with its gateChange, or failed with the writer-facing error), or null. A run past the stale mark reads as failed ("interrupted"). Read-only.
getProjectSeriesBibleGeneration (story-project-documents.ts)The Bible page's poll while a Bible is being written: { document, generation }.
processSeriesBibleGenerateJobThe worker: generateSeriesBibleForProject (one generateObject on story_api_series_bible, httpTimeoutMs 600 s, maxRetries: 0, booked on the ledger as SERIES_BIBLE; held to the content guard when the run's contentGuard says so, which the ChatGPT connector's header sets; an installment written without a status is planned) saved through saveSeriesBibleForProject (the same gate and episode-map rule as every save), then the run marked ready with the save's gateChange and version. A setup draft's saved Bible is then scored, as after every Slug save (tryCompleteSetupAfterSave, server/series-bible-setup-completion.ts). A failure with a retry left keeps the run generating and throws for pg-boss; the last attempt (or a refusal, never retried) marks it failed with a writer-facing reason, the provider's words kept in failureDetail. Every write is conditional on the run still generating; every state change is published on the live channel (series_bible). Budget: series-bible-generate-budget.ts (expiry 900 s, retryLimit 1).

render-quote-preparation.ts and render-quote-prepare-worker.ts

A deck's or trailer's render price is prepared in the background (#1509 L5). The plan approval compiles and voices every chapter or beat before it can price (measured 70 s to 7 minutes; the shot-list compiler alone 18 s a call at the median, 54 s at worst). priceOrApprovePitchDeckPlanForUser / priceOrApproveTrailerPlanForUser — what the Story API, the deck and trailer pages and Slug call — price only from a stored compile (approve*PlanForUser with reuseOnly, which throws RenderCompileUnprepared before anything is written); otherwise they start a preparation and answer { quotedOnly: true, status: 'preparing', progress, checkAgainSeconds, note }, reserving nothing, confirmed or not. The same call asked again answers at once while it runs (before the gate and the screen run again), then with the price.

FunctionDescription
startRenderQuotePreparationOne RenderQuotePreparation row per job, under an advisory lock: the live preparation of this plan (planKey, a hash of what the compile reads plus RENDER_COMPILE_VERSION) is returned; a changed plan or one silent past the stale mark (30 min since its last heartbeat) is replaced, and the job sent to render-quote-prepare.
processRenderQuotePrepareJobThe worker: preparePitchDeckRenderQuote / prepareTrailerRenderQuote compile and store every unit's pending compile (the inline quote's own compile), writing progress as the row's heartbeat, then record compiled, blocked (the compile's block) or refused. A newer preparation supersedes a run: every write carries the run's startedAt. Budget: render-quote-prepare-budget.ts (expiry 9,000 s, retryLimit 1).
unreportedRenderQuotePreparation / markRenderQuotePreparationReportedA finished block, refusal or failure of this plan is answered once, as the inline quote answered it; the ask after it prepares again (as a re-asked quote always re-rolled a blocked chapter).

story-sources.ts

Business logic for story sources. Inline TEXT and MARKDOWN extract synchronously; URL and PDF enqueue a pg-boss extract job after synchronous SSRF + URL validation.

FunctionDescription
createStorySourceValidates type/content/uri/label/retention. Inline types hash (SHA-256), estimate tokens, and store preview (≤4000 chars). URL/PDF persist as PENDING and enqueue extraction. Honors METADATA_ONLY retention by skipping preview persistence. Best-effort enqueue of the digest pass after inline READY. Returns { source, deduplicated }: with producer + externalId (both or neither; integration: reserved for curation), a live source already holding the item is returned with deduplicated: true before anything is stored, and a concurrent insert that loses on the [projectId, producer, externalId] unique index re-reads the winner. Dedupe never lands on a deleted or FAILED row: when the conflict has nothing alive behind it (the winner went FAILED, or a dead row kept the identity), the identity is freed and the insert retried once. A full-context producer item is stored with digestStatus: SKIPPED ("Used in full by its episode plan.") and never enqueued for a digest. metadata.sourceProducer is refused. originatedAt (ISO 8601, when the material was made; at most a day ahead) is stored as metadata.originatedAt and serialized as originatedAt.
listStorySourcesCursor-paginated tenant + project-scoped list.
getStorySourceSingle read; tenant + project + soft-delete checks.
deleteStorySourceSoft delete; clears extractedTextPreview on the way out.

story-source-digest-worker.ts resolves story_api_source_digest through the shared provider circuit. When every configured route is capacity- or policy-blocked, it returns the digest to PENDING/provider_capacity_backoff and schedules a bounded delayed retry, preserving transient METADATA_ONLY preview text in the queue payload. Only retry exhaustion becomes FAILED/provider_capacity_blocked.

A digest gets at most one immediate re-ask, for one of two reasons: the reply does not fit the schema, or its summary/thesis adds "AI", "artificial intelligence", "LLM", "language model" or "machine learning" wording that the source (label, URL and text) never uses (addedMachineWording). The second re-ask is told which words, and its answer replaces the first only when it is clean. A failed re-ask keeps the first answer. The row says the same thing in words (Rule 9: the summary is shown in the project's Sources panel). Existing digests keep their text until the source is digested again.

story-plans.ts

Business logic for the StoryPlan abstraction.

FunctionDescription
createStoryPlanValidates target + artifactRequests + sourceIds + autoApprove, rejecting auto-approval for every plan containing a table read. Computes a preliminary quote from the requested artifacts and source count. Persists as PENDING and enqueues the planner. Marks FAILED/internal_error if the queue is unreachable.
listStoryPlansCursor-paginated tenant + project-scoped list.
getStoryPlanSingle read; tenant + soft-delete checks; throws story_plan_not_found.
resumeStoryPlanReplay-safe recovery under the same plan id. Repairs ordinary missing dispatch for PENDING, requeues stale RUNNING, and resets a pre-output FAILED plan to PENDING. A PENDING/provider_capacity_backoff plan already has a delayed core-owned retry and remains a no-op; completed/reviewable states are also no-ops and rejected/superseded plans stay terminal.
approveStoryPlanTransitions REQUIRES_APPROVAL or READY → APPROVED. No-op on already-APPROVED. Throws story_plan_state_invalid for other states.
rejectStoryPlanTransitions REQUIRES_APPROVAL or READY → REJECTED. Accepts an optional reason ≤2000 chars.

story-plan-worker.ts

Async planning worker — registered as story-plan-generate.

FunctionDescription
processStoryPlanGenerateJobLoads the plan + source context (digest + preview by default; complete hash-verified retained text for sources with metadata.sourceContextMode="full"). Resolves DB sidecar story_api_plan + provider. Calls generateObject with the storyPlanGenerationSchema (the envelope minus projectId). Stamps projectId server-side and deterministically rescales generated table-read scene page budgets onto the page target (the brief's, else the blueprint's) before full-envelope validation: whole pages when every scene fits one, quarter pages when the outline has more scenes than pages. Only an outline with more scenes than quarter pages still fails closed. Computes a plan-aware quote and writes the plan plus a StoryPlanRevision row atomically. Eligible non-table plans transition directly to APPROVED when autoApprove is true; table reads and ordinary plans transition to REQUIRES_APPROVAL. Provider policy/capacity blocks traverse configured fallbacks, then park as nonterminal PENDING/provider_capacity_backoff with a bounded delayed retry; only exhaustion becomes FAILED/provider_capacity_blocked.
enqueueStoryPlanGenerateResets an ordinary plan to PENDING and sends a singleton queue job. Provider recovery can preserve PENDING state, carry an attempt number, and set startAfter for a unique delayed wake-up.

Quote balance (lib/credits-quote-balance.ts, story-plan-quote.ts)

Every priced quote says what it leaves. Beside its price it carries balanceAfter (the balance available now minus the quote; negative means the writer is that many credits short) and sufficient (the balance covers the whole quote, so a confirmed call can reserve it). The shape is QuoteBalance in @sleeperhit/shared, so the web app, the CLI and the MCP server read one type. The balance is getCreditBalance(userId).balance, floored at zero: the number Billing shows and GET /api/v1/credits returns. The line-item reserve refuses on the same number, so sufficient: false is the refusal a confirmed call would hit. Quoting reserves nothing, so the balance can still move before the confirmed call.

HelperDescription
balanceAfterSpend(balance, credits)The arithmetic, with no I/O. A free quote (0 credits) is always sufficient.
readQuoteBalance(userId, credits) / readQuoteBalancer(userId)One balance read. The balancer prices any number of quotes against that one reading.
withQuoteBalance(userId, quote)Attaches the balance to a line-item quote (totalCredits). Used by quotePitchDeckStageForUser (behind quotePitchDeckBuild, quotePitchDeckDraftQuote and quotePitchDeckFinalQuote), the per-scene deck and trailer render quotes (quotePitchDeckFrameRegenForUser, quotePitchDeckSceneClipForUser, quotePitchDeckSceneTextForUser, quoteTrailerFrameRegenForUser, quoteTrailerSceneClipForUser, quoteTrailerSceneTextForUser) and the mood-board frame batch (priceOrRenderMoodBoardFrames).
CREDIT_TOP_UP_PATH / creditTopUpUrl()Where a writer who is short tops up: Billing (WRITING_LAB_UPGRADE_URL, declared once). creditTopUpUrl() is the absolute link for MCP and CLI callers.
affordabilityClause / priceSentence / quotedSpend / quotedSpendForHeadlessCaller (lib/ai/spend-confirmation.ts)The wording, said once: "42 credits — you'd have 4,955 left", or "42 credits, but you have 12 — 30 short; top up at <Billing> first." The chat (quotedSpend) and API/MCP/CLI (quotedSpendForHeadlessCaller) forms differ only in how the writer says go.
quoteEpisodeCast (cast-jobs.ts)The episode cast quote, with the balance and the price sentence ahead of its note. The project cast (generateCastForProject) is free; its quote still carries the balance, for one shape.
withStoryPlanQuoteBalances(reader, plans) / withStoryPlanQuoteBalance(reader, plan)reader is {userId, scopes}. Attaches the balance, and the price sentence as the first of quote.notes, against one balance read, only to a plan that is still a price before a spend: READY or REQUIRES_APPROVAL, or APPROVED with no live StoryJob (one batched storyJob.findMany; FAILED and CANCELED jobs hold nothing). An APPROVED plan with a job is returned unchanged, because create_job already reserved its whole quote. PENDING and RUNNING plans (still preliminary, and polled) and REJECTED, SUPERSEDED and FAILED plans skip the balance read. A caller without credits:read gets the stored price only, since balanceAfter + total is the balance. Every plan route (GET item, GET list, POST create, approve, reject, resume) calls it with its request context.

A plan quote with no balanceAfter / sufficient means "not applicable" (the plan has a job, is not awaiting its spend, or the caller lacks credits:read), never "short". A job's own quote (StoryJob.creditQuoteJson) never carries the balance: it is already reserved.

publishing.ts

Business logic for project-owned Publishing. Publishing promotes finalized durable assets from existing artifact flows; it does not create MP3/MP4 media.

FunctionDescription
createPublishingSeries / listPublishingSeries / getPublishingSeries / updatePublishingSeriesManage project-owned series setup, cadence, feed metadata, prompt direction, and default RSS destination for audio series.
listPublishingDestinations / upsertPublishingDestinationManage per-series destinations. RSS is first-party and connected without OAuth; later providers can use encrypted config and setup status.
listPublishingProviderConfigs / upsertPublishingProviderConfigManage user-level provider config and encrypted credential references for future OAuth/API-key-backed destinations.
createPublishingReleasePromotes an already-finalized source StoryArtifact into a release, links back to StoryPlan/StoryJob/StoryArtifact, and binds the finalized MP3 into PublishingReleaseAsset. Without a seasonNumber or a description the release takes the season and notes of the episode Script the artifact performs (readArtifactEpisodeDefaults), so a release made by the Story API, MCP or the CLI carries the same episode notes in the feed as one made by the app; an explicit one wins.
updatePublishingRelease / reconcilePublishingReleaseReadinessUpdate metadata and mark assets/targets stale when the source artifact fingerprint changes after promotion.
schedulePublishingRelease / publishPublishingRelease / cancelPublishingReleaseRequire a ready durable asset for schedule/publish, enqueue scheduled or immediate publish work, and let the user cancel an unpublished release.
completePublishingReleasePublishWorker entry point that marks RSS targets published, records unsupported provider targets as skipped/needs setup, and updates release/series status.
renderPublishingRssFeedBuilds the public podcast RSS XML for published RSS releases and ready MP3 assets.

Synthetic-media disclosures (legal gap G8a). Every series has a syntheticDisclosure line (default "Voices and music in this show are synthetic.", @sleeperhit/shared DEFAULT_SYNTHETIC_DISCLOSURE). The writer can reword it on every surface (feed settings, Story API, CLI --synthetic-disclosure, MCP) but never empty it: normalizeSyntheticDisclosure refuses empty, blank or null with a 400. It is added where the words are shown, never stored into them, so nothing in a release payload can remove it: the end of the feed's description and of every episode's notes (withDisclosure), the feed's <podcast:txt purpose="synthetic-media">, the public show and episode pages, and the end of the YouTube description. YouTube uploads carry status.containsSyntheticMedia: true and TikTok direct posts post_info.is_aigc: true. The Slug composer always says "You're talking with an automated assistant."; the table-read toolbar says "The voices in this read are synthetic." until the read is live, and so do the public table-read share pages (theater, fullscreen and audio, playing or still rendering). HNR publishes through the same API, so its shows carry the default line with no change on its side (every-feed-and-episode-says-the-show-is-synthetic). A deck or trailer whose plan shows a long-dead historical figure records it (historicalFigures on PitchDeckJob / TrailerJob: set when the plan is approved, from what screenLikenessOf let through, and added to when a chapter or beat is rendered again), and the deck's shared page, the in-app deck video and the trailer review say "This is a synthetic depiction of a historical figure, not an archival record."

Create and update also hold the cadence rules (recurring series are audio-only; cadenceArmProblem on create, update and at fire time) and write the show's standing approval (standingApprovalKeyId; arming a cadence grants, revoking disarms). Over the Story API granting takes userConfirmed: true; the routes pass caller: seriesWriteCallerFromApi(ctx, body).

standing-approval.ts

The one place a per-call userConfirmed may be stood in for (owner decision 2026-09-29).

FunctionDescription
standingApprovalCoversPlan / standingApprovalCoversReleaseThe use ({ seriesId, keyId }) of the show's grant that covers this caller (the bound API key, or the built-in runner for its own armed series) for a table-read plan / audio release created after the grant; never season runs, paused/archived shows, or OAuth connections.
requireStandingApprovalForPlanThe plan-approval boundary without userConfirmed: the covering use, or a 400 with a typed details.reason. approveStoryPlan records the result on the plan (approvedVia, approvedUnderSeriesId, approvedByKeyId).
assertReleasePublishAuthorizedThe publish gate used by POST /publishing-releases/{id}/publish and the cadence worker: 404 for a release the caller does not own; userConfirmed: true or the release's own show's standing approval; a typed details.reason otherwise. Returns the PublishCrossing that publishPublishingRelease (whose crossing is required) records as publishedVia / publishedByKeyId.
resolveStandingApprovalKey / planStandingApprovalWriteValidate a key to bind ("self" = the calling key) and decide what a series write does to the grant — a live grant keeps its date on re-arm or re-bind; a schedule-only grant ends with the schedule.
seriesWriteCallerFromApiThe Story API writer of a series ({ keyId, userConfirmed }), for the grant checks.
releaseStandingApprovalsForKeyCalled by revokeCustomerApiKey after the ownership-checked revoke: ends the key's grants on unarmed shows, unbinds it (date intact) on armed ones.

credit-topup-sheet.ts

The Billing page's top-up sheet, save-card sheet and spending card. Buying credits and saving a card are app-only by design (the API, MCP, the CLI and Slug hand out billing.topupUrl instead); the cap and auto top-up are the same setter every surface uses (updateCreditBillingSettings, PATCH /api/v1/credits/billing). The charging core is lib/stripe/topup-payments.ts; credits are granted only by the payment_intent.succeeded webhook. See docs/credit-billing.md and docs/stripe.md, "Studio credit top-ups". The expected refusals come back as values, never thrown.

FunctionMethodAuthDescription
getTopupSheetConfigGETRequired{ publishableKey, email, billing, allowsTopup, topupsHeld }: the browser's Stripe key (null when the environment has none), the account's email (prefilled for Stripe Link), and the billing read (saved card, this month's spend against the cap, auto top-up). The Billing loader reads it.
startTopupPaymentPOSTRequired{ packKey, confirmationTokenId, raiseCapToUsd?, resumeAutoTopup? }, after the browser's elements.submit() and createConfirmationToken. Reads the token from Stripe and takes only a card (Link's bank option: refused, card_only), writes the attempt under the cap and creates a card PaymentIntent with setup_future_usage: 'off_session', which the browser confirms with the same token. Answers { status: 'requires_confirmation', topupPaymentId, clientSecret }, or spend_cap_reached with cap: { capUsd, spentUsd, packUsd, resetsAt, raiseToUsd }, or refused (topups_held, plan_without_topups, card_only, invalid), or card_error. TEST_MODE settles it at once (succeeded).
payTopupWithSavedCardPOSTRequired{ packKey, raiseCapToUsd?, resumeAutoTopup? }: the saved card, confirmed on-session now; a succeeded answer is granted from it at once. The cap raise in raiseCapToUsd is written only when the payment is granted; resumeAutoTopup (sent only when the sheet showed the consent line) lets a granted payment turn a stopped auto top-up back on. requires_action carries the client secret for stripe.handleNextAction; a decline answers card_error with a writer's sentence; no saved card answers refused / no_card.
cancelTopupPaymentPOSTRequired{ topupPaymentId }: the sheet closed (or a card failed) before paying; cancels the attempt's PaymentIntent so it stops counting against the cap. The caller's own attempts only.
getTopupPaymentStatusGETRequired{ topupPaymentId } → { status, granted, credits, failureCode } or null. While the attempt is unsettled it asks Stripe (at most every 3 s) and grants a paid one from Stripe's own record, so no webhook is needed; the browser's word is never taken. The sheet polls it every second for up to 30 seconds.
removeTopupCardPOSTRequiredTurns auto top-up off as the writer's choice (no reason, so no later top-up resumes it without the consent line), then detaches the saved card in Stripe and forgets it; answers { removed, billing }.
saveTopupCardPOSTRequired{ confirmationTokenId }, after the save-card sheet's elements.submit() and createConfirmationToken (Payment Element in setup mode). Saves the top-up card for $0 (lib/stripe/topup-card-setup.ts): reads the token from Stripe and takes only a card, then creates and confirms a SetupIntent (usage: 'off_session', cards only). Answers { status: 'saved', billing }, requires_action (setupIntentId, clientSecret for stripe.handleNextAction), processing, refused (topups_held, plan_without_topups, card_only) or card_error. Never charges. TEST_MODE saves a stand-in Visa •4242.
finishTopupCardSavePOSTRequired{ setupIntentId }: after the bank's check in the sheet, reads the SetupIntent from Stripe (the caller's own only) and saves the card if it succeeded. Same answers as saveTopupCard.
saveCreditBillingSettingsPOSTRequired{ autoTopup?: { enabled, thresholdCredits?, packKey? }, spendCapUsd?: number | null }: Billing's Save, confirmed in one call. { ok: true, billing, note }, or { ok: false, problem, message, details } for a setting the account cannot honor (credit_billing_invalid).

credit-outage-notice.ts

noteCreditsExhausted — one credits_exhausted notification (bell + email) the first time credits run out in a billing cycle, deduped by its deterministic primary key (credits_exhausted:<userId>:<cycle>). Called, never awaited, through lib/credit-outage-report.ts from every refusal site — and only when the balance is below CREDIT_OUTAGE_FLOOR (one recurring audio episode), so a big one-off refusal from a funded account stays a plain 402.

payment-failed-notice.ts

notePaymentFailed — one payment_failed notification (bell + email) per failed Stripe invoice, deduped by its deterministic primary key (payment_failed:<invoiceId>), so Stripe's retries of the same invoice and webhook redeliveries send nothing more. Called (awaited) by the invoice.payment_failed webhook handler in lib/stripe/webhooks.ts; a write failure throws so Stripe redelivers. The bell opens /dashboard/billing. The words are in code (paymentFailedNoticeText).

coverage domains

Screenplay (coverage.ts), Series Bible (series-bible-coverage.ts), and brand (brand-coverage.ts) operations persist through the same CoverageReport lifecycle. Their public UI, API, CLI, and MCP entry points remain rubric-specific because their inputs, payload schemas, and workflow gates differ. gold-standard.ts exposes the shared curated exemplar corpus to the admin calibration surface; CoverageReportExemplar records the ordered exemplars used for each generated report.

Video coverage (video-coverage.ts, worker video-coverage-worker.ts on video-coverage-generate) watches a deck's or trailer's stitched master and gates the paid finish. One watch is too noisy to gate on near the bar, so the rules in lib/video-coverage-judging.ts apply: a first watch within 1.5 of the bar (its composed overall or a veto dimension) is followed by a second, two watches on opposite sides of the bar on any gating score by a third, and the bar reads the MEDIAN of each score. Each watch is its own queue execution (recorded on the generating row under reportJson.watching, compare-and-set on its count, then the next job is sent), so the expiry still nests one watch and a retry never pays for a recorded watch. Watches beyond each report's first share a $0.50 cap per master (a model with no price on file counts $0.25 a watch). An automatic report first fingerprints the master (video-coverage-fingerprint.ts: MD5 of every fifth decoded frame plus what each part was asked; SHA-256 of the decoded sound on that picture) and carries the picture's five scores from the newest earlier report on a frame-identical picture, judging only lip sync and audio sync again — nothing at all when the sound is identical too. report.judging records every watch, each score's distribution and median, what was carried from which report and why it stopped; every surface shows it.

Script coverage (coverage-worker.ts on coverage-generate) gets the same variance fix. One judgment is noisy near the bar (an HNRadio episode scored 8, then 7, on identical pages), so the rules in lib/script-coverage-judging.ts apply: a first judgment within nearGateBand (1) of the report's bar (gateThreshold: the coverage gate's target, a season's minimum, else 8) is judged again, up to maxJudgments (3), stopping once a majority sit on one side of the bar; the score is the MEDIAN (an even count reads the lower middle, so it is always a score one judgment gave, and the report shown is that judgment's). A clear pass or fail stays one judgment. The numbers are data: SiteSetting script_coverage_judging (nearGateBand, maxJudgments, extraSpendCapUsd $0.50 per script, unpricedJudgmentUsd $0.25). Each judgment is its own queue execution (recorded under reportJson.judgingInProgress, compare-and-set on its count), ledgered like the first; one beyond the first carries step: coverage_extra_judgment, which the monthly report count leaves out. Before its first judgment a report fingerprints what it would ask (server/script-coverage-judging.ts: the composed rubric, the script, the Series Bible and plan, any focus, the schema and the model). A report an orchestrator queued (subjectMetadata.trigger: auto: the story-job gate, its first-pass report, a season run) starts from the newest earlier report with that fingerprint, so the gate's report and the first-pass report on the same pages share one set of judgments; a writer's request (the app, the Story API, MCP, the CLI) is judged afresh, as video coverage re-watches a manual request. reportJson.judging records every judgment, the median, which judgment gave it and why, and why judging stopped; each judgment's whole report is kept under subjectMetadata.coverageJudgments. The Story API read (overallScore, gateThreshold, gatePassed, judging, summary), MCP get_coverage, the CLI, the Coverage tab and Slug's get_coverage all say "the median of N judgments"; the story-job gate's progress and coverageDrafts carry it too. Near the bar a script is never scored on one judgment (the owner's rule, 2026-10-04). A further judgment that fails for good is queued again as a fresh job (judgingInProgress.requeues, at most SCRIPT_COVERAGE_FAILED_JUDGMENT_REQUEUES (2), within the extra-judgment spend cap). A script, rubric or model that changed between judgments near the bar can't be judged further. When the median still cannot be completed, the report lands failed with subjectMetadata.judgingIncomplete (judgments, scores, maxJudgments, passMark): neither a pass nor a fail. Every surface says "Coverage couldn't finish judging — Score again": the read's judgingIncomplete and summary, MCP get_coverage, the CLI, the app's toast and Slug's failureReason. The story-job gate scores such a draft again, once (orchestration key …:rescore), before reading it as a null score. A null score sends the draft to a paid rewrite, and a good draft must not be rewritten because judging could not finish.

Plan coverage (storyboard-coverage-worker.ts) is never decided on one judgment near the bar (the owner's rule, 2026-10-04). A FURTHER judgment that fails for good is queued again as a fresh job (judgingInProgress.requeues, at most STORYBOARD_COVERAGE_FAILED_JUDGMENT_REQUEUES (2), within the revision's extra-judgment spend cap). If it still cannot run, the report lands failed with subjectMetadata.judgingIncomplete and judging.stoppedBecause: judging_incomplete: neither a pass nor a veto. Every surface says "Plan coverage couldn't finish judging — Score again" (judgingIncomplete on the read). A judgment's blind first-viewer read is kept on judgingInProgress.blindRead (bound to that judgment and to the blind rubric's hash), so a queue retry of the judgment never asks for it again. Its shape failures land on subjectMetadata.viewerTakeawayFailures beside the judge's structuredOutputFailure. Karamazov v2's report cmuvkcruq (2026-10-05) showed both: claude-sonnet-5 failed the blind read's shape on 14 of 22 calls, those failures spent judgment 2's attempts, and a Central-question veto was decided on one judgment.

publishing-ui.ts

CreateServerFn wrappers for the Writers Lab Publishing tab.

FunctionMethodAuthDescription
getPublishingWorkspaceGETRequiredLoads series, destinations, releases, source artifact readiness, feed URL, and the owner's API keys a standing approval can be granted to (standingApprovalKeys, never the secret) for the active project. feed is the Releases tab's "In your feed" list: audio releases, newest first, with total (the whole feed, not the page), matching, query and nextCursor. Pass feedQuery / feedRows to reload it at the filter and depth the writer left it.
listProjectPublishingFeedGETRequiredReads the "In your feed" list from a cursor (Show older episodes, 100 at a time) or for a query (a title fragment, or an episode number as 13, #13, ep 13). Pages through listPublishingReleases, the same cursor the Story API, CLI and MCP use.
createProjectPublishingSeries / updateProjectPublishingSeriesPOSTRequiredCreate or update a publishing series from Writers Lab; update also grants (standingApprovalKeyId) or revokes (null) the show's standing approval.
upsertProjectPublishingDestinationPOSTRequiredConnect or update a destination for the active series.
promotePublishingArtifactPOSTRequiredPromotes the finalized Table Read source artifact into a Publishing release.
scheduleProjectPublishingRelease / publishProjectPublishingRelease / cancelProjectPublishingReleasePOSTRequiredSchedule, publish, or cancel a release.

show-ui.ts

CreateServerFn wrappers for the Publishing tab's Show tab (components/writing-lab/show-panel.tsx): a read-out of a show that is set up by talking to Slug (BL-1083), plus its direct acts. Each calls the same core as the Story API, MCP, the CLI and Slug (server/show-api.ts); the signed-in owner pressing the button is the confirmation (no caller).

FunctionMethodAuthDescription
getProjectShowGETRequiredThe show read (getShow): state, producer, setup lines, readiness, price and suggested caps, next run, last run, preview and the one next step.
listProjectShowRunsGETRequiredThe show's recent runs with their episodes, and the previews and extras asked for (listShowRuns).
setProjectShowStatePOSTRequiredOn (also resumes), paused or off (setShowState).
publishProjectShowEpisode / dropProjectShowEpisodePOSTRequiredPublish a held or preview episode, or never air one (publishShowEpisode, dropShowEpisode).

shows-running-fns.ts

FunctionMethodAuthDescription
getShowsRunningFn / setShowsRunningFnGET / POSTAdminAdmin → Settings' "Shows running" row: the platform-wide stop for unattended show spend (server/shows-running.ts).

story-season-runs-ui.ts

CreateServerFn wrappers for a season run in the app: the Overview's Seasons tab, where a season is produced and its run reviewed inline (components/writing-lab/seasons/project-seasons-tab.tsx, season-production-control.tsx, season-run-rail.tsx, season-run-next-step.tsx, season-episode-brief.tsx), and a product season's videos on the Publishing page (publishing-tab.tsx). Each calls the core the Story API's season-run routes call (story-season-runs.ts); the signed-in writer pressing the button is the userConfirmed: true an agent collects. Slug's create_season_run and map_season_episode (lib/ai/season-run-chat-tools.ts, the Series Bible chat, paid workspaces) call the same plain functions.

FunctionMethodAuthDescription
produceSeasonPOSTRequired"Produce this season" (owner D2, 2026-10-11) through produceSeasonForUser: a season already in production answers with its run (created: false); a recurring show is refused; the project must be ready for episodes (canStartEpisode); then createStorySeasonRun with the INTERNAL audience. Free: each episode's plan is still approved, priced, before anything is made.
listProjectStorySeasonRunsGETRequiredThe project's season runs in full, read by a season's inline review on the Seasons tab and by a product's Publishing page, each with the priced episode plan it waits on or the price of a revision. It shows every season in production.
approveSeasonMap / reopenSeasonMapPOSTRequired"Approve season map" (confirms the open briefs on screen, then approves that map version) and "Ask for changes".
decideSeasonEpisodePlan / advanceSeasonRunPOSTRequiredApprove or reject the episode plan the run waits on; continue, plan again, retry coverage, revise or publish.

A run maps only its season's episodes: the Series Bible entries the season map files under that season (joinPlannedEpisodes: a written episode in its script's season, an unwritten one in the season its label names, else 1). A season the Bible maps nothing to is refused, naming the seasons it maps. The plan reads each must-know line as its own mustKnowBeforeWriting item (seasonEpisodeMustKnow).

story-sources-ui.ts

CreateServerFn wrappers for a project's Sources in the app: the Sources panel on the project page (components/writing-lab/project-sources-panel.tsx, mounted by routes/dashboard/projects.$projectId.tsx). Attaching a source was API, CLI and MCP only (dogfood gap, 2026-10-11). Each calls the core the Story API's source routes call (story-sources.ts: createStorySource, listStorySources, deleteStorySource), so a link is fetched by the one fetch path (fetchUrlSource, with its address checks) in the extract worker. Slug's attach_source (lib/ai/project-source-chat-tools.ts, the Series Bible and project-setup chats) calls attachProjectSourceForUser; the shared read list_sources (lib/ai/shared-read-tools.ts) calls listStorySources / getStorySource. The writer's view of a source (kind, state, failure reason in their words, summary) is lib/project-sources.ts.

FunctionMethodAuthDescription
listProjectSourcesGETRequiredOne page (50, newest first, cursor for the next) of the project's sources as the panel shows them (readProjectSourcesForUser): processing while a link is fetched or a summary made, ready, or failed with the reason.
attachProjectSourcePOSTRequired"Add a source" through attachProjectSourceForUser: a link (url; pdf: true, or a link ending in .pdf, is sent as a PDF) or a note (note, a text source), with an optional title. One at a time; never sends the integrations' producer / externalId. PDFs are attached by link: the API has no file upload. Free.
removeProjectSourcePOSTRequiredRemove a source through deleteStorySource (soft delete that clears its text, summary, address and label).

publishing-worker.ts

Async publishing worker registered as publishing-release-publish.

FunctionDescription
enqueuePublishingReleasePublishEnqueues an idempotent publish job, optionally delayed until scheduledAt.
registerPublishingWorkersRegisters the worker with pg-boss.

/api/v1 routes

RouteMethodAuthDescription
/api/v1/capabilitiesGETCustomer API key, story:readReturns current Story API capabilities, live source/artifact support, planned follow-ons, and docs discovery links.
/api/v1/creditsGETCustomer API key, credits:readReturns the API key owner's Studio Credit balance summary, plus costs: the same price list the Billing page shows (listStudioCreditCosts).
/api/v1/account/exportGET / POSTaccount:read (POST idempotent)"Download my data": status with a fresh signed link, or start building the archive (account-export.ts).
/api/v1/account/deletionGET / POST / DELETEaccount:read / account:write (POST idempotent)Where the account's deletion stands; schedule it (needs confirmEmail); cancel it (account-deletion.ts).
/api/v1/openapi.jsonGETPublicOpenAPI 3.1 spec for the live endpoints.
/api/v1/examplesGETPublicMachine-readable cURL + Node recipes.
/api/v1/story-projectsGET / POSTstory:read / story:writeList or create story project workspaces.
/api/v1/story-projects/from-scriptPOSTstory:write, idempotentPut a script that belongs to no project into a new one (script-project.ts). Body: exactly one of scriptId / scriptUploadId. A script already in a project returns that project with created: false. Free.
/api/v1/story-projects/{projectId}GET / PATCH / DELETEstory:read / story:writeRead, update, delete a single project. PATCH merges metadata (null removes a key; metadata.seasons is kept and refused). DELETE hides it, keeps it restorable for 30 days, then purges it with its episodes (project-deletion.ts).
/api/v1/story-projects/deletedGETstory:readDeleted projects that can still be restored.
/api/v1/story-projects/{projectId}/restorePOSTstory:write, idempotentRestore a deleted project within its window (409 project_not_restorable after).
/api/v1/story-projects/{projectId}/episodesGETstory:readA project's seasons, episodes (made in the app included), versions, planned episodes and videos (listProjectEpisodesForUser). ?season=N. Free.
/api/v1/story-projects/{projectId}/seasonsPOSTstory:write, idempotentName a new season (createProjectSeasonForUser). Free.
/api/v1/scriptsPOSTstory:write, idempotentUpload a screenplay as a new script in no project (uploadScriptForUser). Free.
/api/v1/scripts/{scriptUploadId}GET / PATCH / DELETEstory:read / story:writeRead one script; change its title, logline, genre or season (updateScriptForUser; its shape is the project's, so no mediaType); delete the whole script (deleteScriptForUser, confirmed: true). Free.
/api/v1/scripts/{scriptUploadId}/versionsPOSTstory:write, idempotentUpload the next version (uploadScriptVersionForUser). Free.
/api/v1/scripts/{scriptUploadId}/versions/{versionId}DELETEstory:writeDelete one version (deleteScriptVersionForUser, confirmed: true). Free.
/api/v1/scripts/{scriptUploadId}/exportGETstory:readExport Fountain, Final Draft or PDF (exportScriptForUser). Free.
/api/v1/scripts/{scriptUploadId}/overridesGET / PATCHstory:read / story:writeRead or partially change the five episode overrides on a script version (script-overrides.ts); null or blank text clears one. Free, no Idempotency-Key.
/api/v1/scripts/{scriptUploadId}/table-read/preparePOSTartifact:publish, idempotentPrepare a cast episode's table read (table-read-prepare.ts): without confirmed the price; with confirmed: true the TABLE_READ charge, once per screenplay version, and its sound effects and music. prepared / preparing cost nothing.
/api/v1/story-projects/{projectId}/season-runsGET / POSTstory:read / story:write + publishing:writeList runs or create a persistent conversational season map from the current Series Bible.
/api/v1/season-runs/{runId}GETstory:readResume a run with its episode states, coverage audit, progress, and next action.
/api/v1/season-runs/{runId}/episodes/{episodeId}PATCHstory:writeSave one discussed episode brief while the map remains unlocked.
/api/v1/season-runs/{runId}/approvePOSTstory:writeHuman approval that locks the exact fully confirmed expectedMapVersion; stale reviewed maps are rejected.
/api/v1/season-runs/{runId}/advancePOSTstory:write + artifact:publish + publishing:write + publishing:publishAdvance one durable step; stops for plan approval, replacement planning approval, same-report failed coverage retry approval, coverage revision approval, and RSS publication approval.
/api/v1/story-projects/{projectId}/sourcesGET / POSTsource:read / source:writeList or attach text/markdown/url/pdf sources.
/api/v1/story-projects/{projectId}/sources/{sourceId}GET / DELETEsource:read / source:writeRead or soft-delete a single source.
/api/v1/story-projects/{projectId}/story-plansGET / POSTstory:read / story:writeList or create StoryPlans.
/api/v1/story-plans/{planId}GETstory:readRead a single plan. Its quote carries balanceAfter / sufficient only while the plan awaits its spend and the caller also holds credits:read; the same applies to every plan route (see "Quote balance").
/api/v1/story-plans/{planId}/resumePOSTstory:writeReplay-safe same-plan recovery for missing dispatch, stale workers, or failures before generated output exists.
/api/v1/story-plans/{planId}/approvePOSTstory:writeTransition REQUIRES_APPROVAL or READY → APPROVED; requires { userConfirmed: true } after explicit user review — except for the one API key holding a show's standing approval, for a table-read plan in that show's project created after the grant (server/standing-approval.ts).
/api/v1/story-plans/{planId}/rejectPOSTstory:writeTransition REQUIRES_APPROVAL or READY → REJECTED.
/api/v1/story-jobs/{jobId}/resumePOSTstory:writeResume terminal legacy/pre-worker generation failures or retry failed artifact finalization from the last durable boundary. Active provider backoff is already scheduled by the worker and is a no-op; other unsafe started failures remain fail-closed.
/api/v1/artifacts/{artifactId}/finalize/retryPOSTartifact:publishRetry a failed table-read audio finalize without regenerating the performance.
/api/v1/story-projects/{projectId}/publishing-seriesGET / POSTpublishing:read / publishing:writeList or create project-owned publishing series.
/api/v1/publishing-series/{seriesId}GET / PATCHpublishing:read / publishing:writeRead or update series setup and cadence.
/api/v1/publishing-series/{seriesId}/destinationsGET / POSTpublishing:read / publishing:connectList or upsert series destinations.
/api/v1/publishing-provider-configsGET / POSTpublishing:connectList or upsert provider config and encrypted credential references.
/api/v1/publishing-series/{seriesId}/releasesGET / POSTpublishing:read / publishing:writeList releases or promote a finalized source artifact into a release.
/api/v1/publishing-releases/{releaseId}GET / PATCHpublishing:read / publishing:writeRead or update release metadata.
/api/v1/publishing-releases/{releaseId}/schedulePOSTpublishing:publishSchedule a ready release.
/api/v1/publishing-releases/{releaseId}/publishPOSTpublishing:publishQueue immediate publishing for a ready release.
/api/v1/publishing-releases/{releaseId}/cancelPOSTpublishing:publishCancel an unpublished release.
/api/v1/publishing-releases/{releaseId}/targetsGETpublishing:readRead provider target status.
/api/v1/publishing-series/{seriesId}/analyticsGETpublishing:analyticsRead normalized publishing analytics snapshots.
/api/v1/publishing-feeds/{seriesSlug}.xmlGETPublicPublic first-party podcast RSS feed.
/llms.txt and /llms-full.txtGETPublicAgent-readable doc surfaces.

writer-profile.ts

FunctionMethodAuthDescription
getWriterProfileEditorDataGETRequiredLists the current user’s scripts and latest published pitch-deck metadata for public-profile curation.
getPublicWriterProfileBySlugGETPublicLoads a published writer profile with featured work and public pitch-deck links for a root-level slug.

contact.ts

FunctionMethodAuthDescription
submitContactFormPOSTPublicSaves to ContactSubmission (name = "First Last"; a subject is the message's bracketed first line, e.g. [Studio Project]), emails CONTACT_NOTIFY_EMAIL via Resend with first and last name. Input: {firstName, lastName, email, message, subject?: 'studio-project'}.
submitSupportRequestPOSTPublicSaves to ContactSubmission, emails help@sleeperhit.studio via Resend, and prefixes the stored message with [Support Request]. Input: {name, email, message}.

newsletter.ts

FunctionMethodAuthDescription
subscribeNewsletterPOSTPublicCreates NewsletterSubscriber. Silently ignores duplicate emails (catches Prisma P2002).

comments.ts

FunctionMethodAuthDescription
getCommentsGETPublicReturns all comments for a course with user info. Input: {courseId}.
createCommentPOSTRequiredCreates a comment. Input: {courseId, content} (max 2000 chars).

settings.ts

Public Functions

FunctionDescription
getSocialLinksReturns social media URLs from SiteSetting (facebook, youtube, instagram, tiktok). Used in footer.

Admin-Only Functions

All require role === ADMIN (enforced by requireAdmin()).

FunctionDescription
getAdminSettingsReturns all SiteSetting records as key-value map.
updateAdminSettingsUpserts/deletes SiteSetting records.
getAdminUsersLists all users with role, planTier, planStatus, and paysThroughStripe (a subscription Stripe still bills).
updateUserRoleToggles user between MEMBER and ADMIN.
resetUserPasswordHashes and updates credential password.
createAdminUsermode: 'password' creates the user with a hashed password; mode: 'invite' creates it with no password and emails a link to set one, naming the admin (an invite that could not be sent makes no account and returns why). Optional planTier; the address is stored lower-case.
deleteAdminUserDeletes user (cannot delete own account).
getNewsletterSubscribersReturns all newsletter subscribers.
getContactSubmissionsReturns all contact/support submissions.
getAdminQuotesReturns all RotatingQuote records.
toggleQuoteActiveSets RotatingQuote active to true/false. Input: {id, active}.
getStripeCouponsLists all Stripe promotion codes (via Stripe API).
createStripeCouponCreates Stripe coupon + promotion code.
deactivateStripeCouponDeactivates a Stripe promotion code.
savePricingOverridesSaves admin-managed Stripe price overrides. Plan capability copy is read from the shared pricing registry.
getBetaInvitesThe beta invites ({inviteCode, couponId, planSlug, emails, credits}) and grants: per invite code, each listed email's grant status (granted with credits and grantedAt, next-sign-in for an account not yet granted, no-account), read from the ledger by key beta-invite:<code>:<userId> (readBetaInviteGrantStatus, server/beta-invite-credits.ts).
saveBetaInvites{invites: [{inviteCode, couponId, planSlug, emails, credits (whole number 0–10,000, default 0)}]}: writes the stripe_beta_invites setting (deleted when no list has an email). credits is granted once per listed writer at sign-in; a raise never tops up a past grant.
setUserPlanSets or clears a writer's app-managed plan: {userId, tier | null} writes planTier with planStatus ACTIVE, or both null (appManagedPlanColumns). Refused with "This writer pays through Stripe; change the plan in Stripe." for a subscription Stripe still bills (ACTIVE, TRIALING, PAST_DUE, PAUSED). The next balance read mints the plan's bucket. Core: server/settings-plan-credits.ts.
getAdminUserCredits{userId}: the writer's plan in effect, whether Stripe bills them, whether a payment dispute holds their top-ups (topupHold: {since, reason} | null), their balance by bucket (getCreditBalance, which mints a due app-managed bucket) and their latest 50 ledger rows with the Billing page's labels (studioCreditTransactionLabel), an adjustment's reason and granting admin.
grantUserCredits{userId, amount (1–10,000), reason (required, ≤200), requestId (uuid)}: a never-expiring ADJUSTMENT through grantCredits with metadata {reason, actorUserId, requestId}, idempotent on requestId (ledger row key admin-grant:<requestId>, also the Stripe idempotency key). Mirrored to Stripe as promotional when the writer has a Stripe customer; otherwise ledger-only under admin-adjustment:<requestId>. Returns {alreadyGranted, amount, balance}. No debits.
clearUserTopupHold{userId}: lets a writer buy top-ups again after a payment dispute held them (User.creditTopupHoldAt/creditTopupHoldReason back to null, clearCreditTopupHold). Returns {cleared}: false when nothing was held. The clearing admin is logged.

admin-integrations.ts

Registry for the products an organization can connect. Admin-only, enforced by a local requireAdmin() copy (same shape as settings.ts / ai-config.ts).

FunctionMethodAuthDescription
listAdminIntegrationProvidersGETAdminEvery catalogue product (registered or not) plus any row added outside the catalogue: enablement, client id, masked client secret, endpoint/scope overrides and their defaults, connected-organization count, request count, the owner setup guide (ownerGuide), owner fields with their values, lastVerifiedAt, and the reasons a product is not yet connectable. Also returns redirectUri, both redirectUris and otherRequests. Never returns the client secret, its ciphertext, or credentialId.
beginIntegrationOwnerTestPOSTAdmin"Test this app": an owner-test state and the vendor URL for one OAuth round trip; the callback keeps nothing and stamps settings.lastVerifiedAt. Input: {slug}.
saveAdminIntegrationProviderPOSTAdminUpserts one IntegrationProvider by slug. Every field optional; omitted fields are unchanged, so it serves both the edit form and the enable/disable toggle. A blank clientSecret keeps the stored one; a non-blank one is encrypted into the row's EncryptedAPIKey (provider namespace integration_oauth:<slug>) and resets lastUsed. Blank clientId / authorizeUrl / tokenUrl / scopes clear the override back to the catalogue default. Enabling is refused while anything would block a connect. Input: {slug, label?, clientId?, clientSecret?, authorizeUrl?, tokenUrl?, scopes?, isActive?, sortOrder?, ownerFields?} (ownerFields keys must be the product's own).

ai-config.ts: operator controls

Admin-only (requireAdmin()). Operator controls, not writer surfaces, so they have no Story API, MCP or CLI counterpart.

FunctionMethodAuthDescription
listAIProvidersGETAdminEvery AIProvider row with its key (never the secret), trainsOnContent as the no-training rule reads it, retiresAt (ISO or null), and boundSidecars / boundSystemPrompts, the bindings a delete is refused over.
setAIProviderActivePOSTAdminSwitches one row on or off. Input: {id, isActive}. "Provider not found" for a missing row.
setAIProviderRetirementPOSTAdminThe voice retirement switch. Input: {id, retiresAt}, where retiresAt is an ISO instant with an offset, 'now' (the server's clock), or null (un-retire). Refused for a provider type outside TTS_PROVIDERS. Writes the instant to every row of the row's provider type (matched case-insensitively), since retirement is per type. Returns {providerType, providerLabel, retiresAt, rowsUpdated}.
updateMonthlyUsageCapsPOSTAdminWrites monthlyLimitPerUser (paid, 1 to 100,000), freeMonthlyLimitPerUser (free, 0 to 100,000), or both on the AIFeatureConfig singleton. Every problem is named in one readable error and nothing is written. Returns both caps as stored.

organizations.ts

Teams for the BUSINESS track. creatorContext === 'company' is the discriminator, not the workspace track — business and content creators both sit on STORYTELLER_TRACK, so hiddenForTracks: [STORYTELLER_TRACK] would hide these surfaces from the very users they are for.

Three exported assertions the rest of the feature is built on:

  • requireCompanyCreator() — requireUser() + a UserProfile.creatorContext read. Throws the same 'Organization workspace not found.' for "no profile", "self creator" and "professional writer", so nothing about another track leaks. Every organization- and integration-scoped server fn opens with it.
  • requireOrganizationMember(organizationId, userId, minimumRole?) — the ownership guard, shaped like requireOwnedInstall in all-hands.ts: re-reads the row and refuses with one sentence for both "does not exist" and "not yours".
  • requireOrganizationAccess({organizationId, minimumRole?}) — the one-call rail: composes the two above and returns capabilities from lib/organization-access.ts so a handler never re-derives a rule from a role string.

One organization per person (assertNoExistingMembership, enforced on create and accept): getMyOrganization is singular and an integration's candidates need one unambiguous workspace.

FunctionMethodAuthDescription
createOrganizationPOSTCompany creatorCreates the organization + an OWNER membership in one transaction, allocating a unique slug the way allocatePublishingSeriesSlug does. Input: {name}.
getMyOrganizationGETCompany creatorThe caller's organization: role, capability booleans, members, and — only for someone who canManageMembers — pending invitations. Invitation tokens are never included.
inviteMemberPOST+ ADMINUpserts an invitation on (organizationId, email), rotating the token and extending the 14-day window. Refuses granting above your own role and refuses an existing member. Returns the token once, to the inviter, the way a raw API key is shown once. Input: {organizationId, email, role}.

Organization.ownerId and the founder's OWNER membership are protected by the FK (onDelete: Restrict) and by there being no transfer surface. The last OWNER can never be demoted or removed.

integrations.ts

Connected products for the business track: plain *ForUser cores taking an explicit userId (the Story API, MCP and CLI reach the same ones), with .inputValidator() server fns that resolve the session. Cores throw StoryApiError (organization_required, integration_not_found, integration_forbidden, integration_unavailable). Nothing returned carries a client secret, a token or a credentialId. See docs/integrations.md.

FunctionMethodAccessDescription
listIntegrationCatalogGETCompany creatorAll ten products (always), each with its card state (READY, COMING_SOON, PENDING, CONNECTED, NEEDS_ATTENTION, RATE_LIMITED) and the organization's connection (account, chosen origins, last/next check, last run's items, health line). A creator with no organization yet still gets the catalogue.
getIntegrationWalkthroughGET+ org memberOne product's connect walkthrough (guide.connect), its state and connection, and the admin approval link (Microsoft adminconsent built from the client id).
beginIntegrationConnectPOST+ owner/adminArms the Integration row to PENDING and returns { integrationId, authorizeUrl, instructions }. Members may connect only a switched-on, readable product; a site admin may preview one that is set up but not switched on. Input: {organizationId?, slug, returnTo?}.
reconnectIntegrationPOST+ owner/adminRe-authorizes an existing connection (back to PENDING).
disconnectIntegrationPOST+ owner/adminCalls the product's revoke best effort, soft-deletes the row, hard-deletes the token EncryptedAPIKey, and answers vendorAccessRemoved.
listIntegrationOriginsGET+ owner/adminThe places the connection can read, live from the product (ready, readyHint, chosen); available: false without a fetcher; GitHub's installUrl.
setIntegrationOriginsPOST+ owner/adminWrites config.origins (+ labels) and queues the first check when readable.
requestIntegrationSyncPOST+ editorQueues one integration-sync job (singleton per connection).
recordIntegrationInterestPOST+ org member"Request another product" (upsert per member).
withdrawIntegrationInterestPOST+ org memberWithdraws this member's request (a checked "Notify me" chip tapped again).

integration-project-feed.ts

FunctionMethodAccessDescription
getProjectIntegrationFeedsGET+ org member, own projectThe products feeding the project's seasons, each connection's status line, live seasons reached and items held for the next season.
setProjectIntegrationFeedPOST+ owner/admin, own projectFeeds (feeding: true) or withdraws a project from a product in StoryProject.integrationFeeds; no season has to be live.

integration-season-feed.ts

FunctionMethodAccessDescription
getSeasonIntegrationFeedGET+ org member, own seasonThe season's Sources panel: connected products, their chosen origins, the season's rule for each, the NEW count and the review-queue link.
setSeasonIntegrationFeedPOST+ owner/admin, own seasonWrites ONE audienceOriginRuleSchema rule for a product into StorySeasonRun.audiencePolicy (the explicit per-season opt-in), re-validated by audiencePolicySchema.

integration-sync.ts (worker)

runIntegrationSyncTick (queue integration-sync-tick, hourly at :07) queues one integration-sync job per CONNECTED connection with a fetcher, chosen origins and a due nextSyncAt. runIntegrationSync reads only for opted-in seasons (a season's own rule, or its project's feed) and for fed projects with no live season (their items are held for the next season), through the product's paced fetch, files NEW / WITHHELD candidates per season (evaluateCandidate, createMany skipDuplicates), saves the cursor per origin after every page, records an IntegrationSyncRun, and maps errors to healthCode (a rate limit never flips the connection to reconnect).

integration-curation.ts

Source curation — the human half of the leak guard. Business track only: every function opens with requireOrganizationAccess (which composes requireCompanyCreator + membership + role floor). Acting also requires canCurateSources (EDITOR and above); reading is open to VIEWER.

acceptSeasonSourceCandidate re-runs evaluateCandidate from lib/integrations/audience-policy.ts against the season's current audienceIntent + audiencePolicy. The state a sync worker stored at pull time is a display hint, never authorization. A withhold is a returned outcome (with its reason), not a thrown error, so a reviewer sees why and a bulk review is not aborted by one refusal.

Season ownership is asserted alongside org membership: the StorySeasonRun must be the caller's own, because the whole story/publishing tree is keyed on userId and carries no organizationId.

FunctionMethodAuthDescription
listSeasonSourceCandidatesGETCompany creator + org memberCuration queue for one season: the season's audience intent, a plain-language summarisePolicy read-back, per-state counts, canCurate, and the candidates. Each candidate carries currentDecision — the policy's verdict recomputed on read, so a tightened policy shows an item as held back before anyone can keep it. Only the three audience signals are lifted out of metadata; the raw blob never leaves the server. Input: {organizationId, seasonRunId, state?, integrationId?, limit?, cursor?}.
acceptSeasonSourceCandidatePOST+ canCurateSourcesKeeps an item. Re-runs the audience policy; on withhold, persists WITHHELD + reason and returns {outcome:'withheld', reason, ruleCode} without writing a source. On allow, claims the row with a guarded updateMany, creates the StorySource on the season owner via createStorySource with producer: "integration:<integrationId>" + the candidate's externalId, and links sourceId. When another season of the project already kept the same upstream item, the keep links to that source and returns sharedSource: true. Compensating restore on failure so an ACCEPTED row can never exist without its source; a source is retired on a link failure only when this keep created it. Idempotent once linked.
rejectSeasonSourceCandidatePOST+ canCurateSourcesTurns an item down. The row is kept so the next sync does not re-surface it. Refuses an item that already became a source — removing that source is a separate, explicit act. When another season's candidate points at the same source (one upstream item is one source per project), the refusal says removing it removes it from both seasons.
bulkReviewSeasonSourceCandidatesPOST+ canCurateSourcesReviews up to 100 items through the same per-item functions, so a bulk keep re-runs the policy on every one. Not all-or-nothing: returns {accepted, rejected, withheld, failed, results[]}.

outline-map.ts (episode outlines)

An episode of a season map keeps its outline in ONE place: the project's outline_map document, keyed by the Series Bible's episode id. saveEpisodeOutline is the one writer, behind the episode's Outline tab (saveScriptOutline), Slug's save_episode_outline (Series Bible chat, general chat, and an episode's drafting chat), and PATCH /story-projects/{projectId}/outline-map (CLI outline save, MCP save_episode_outline). loadProjectOutlines is the one reader: the EFFECTIVE map, where an episode with nothing written takes its season-map note as its outline when that note lays the episode out act by act or scene by scene (seasonMapNoteIsOutline). Outline coverage (readiness, payload, staleness), the Outline tab, Slug's context and the season planner all read it. A written screenplay is not an outline. A script that covers no season-map episode keeps its outline on the script (creativeAnalysis.outline). Outline coverage staleness compares the scored episodes to the current ones by content, because a season-map note changes without the outline map's revision moving.

scripts.ts

FunctionMethodAuthDescription
getScriptOutlineGETRequiredThe Outline tab's outline from its one home (readScriptOutline): { outline, home }, where home is { kind: 'episode', projectId, episodeId, label, source } for a script covering a season-map episode, else { kind: 'script' }. Input: {scriptUploadId}.
saveScriptOutlinePOSTRequiredSaves the Outline tab: through saveEpisodeOutline for a script covering a season-map episode, on the script otherwise. Input: {scriptUploadId, outline}.
readScriptOutlineForUser—Caller's user idThe Story API, MCP and the CLI's read (BL-4048): readScriptOutlineForUser({ userId, scriptId }) lifts the script's own id, its route id or any version's id to the newest version (resolveScriptOutlineForUser) and reads through readScriptOutline. Returns the tab's { outline, home }; another account's script is 404 script_not_found.
saveScriptOutlineForUser—Caller's user idThe same writer, keyed by the script: { outline } is parsed (parseScriptOutlineBody, a section left out is emptied) and saved through saveOutlineForScript — the episode's outline map when the script covers a season-map episode, the script otherwise — and answered as { outline, home }.
uploadScriptPOSTRequiredCreates a new script and first version through uploadScriptForUser. Enforces the current user's page allowance before insert. Input: {title, content, format?} (the shared parser's fields; isCalibration is not one).
uploadScriptVersionPOSTRequiredAdds a new version through uploadScriptVersionForUser. Enforces page allowance by replacing the previous latest version's page usage with the incoming version. Input: {scriptId, content, format?}.
updateScriptMetadataPOSTRequiredThe edit dialog, through updateScriptForUser. Input: {scriptId, title?, logline?, genre?} (no slug, no mediaType: an episode's shape is its project's); the logline is written to Script.description.
deleteScriptVersionPOSTRequired"Only version N", through deleteScriptVersionForUser (refused while something depends on the version). Input: {scriptUploadId}.

pitch-deck.ts

FunctionMethodAuthDescription
listPitchDeckJobsGETRequiredLists pitch deck jobs for a script. Input: {scriptId}.
getPitchDeckJobByIdGETRequiredReturns one pitch deck job with journeyPlan, generated asset manifest, video/PDF render state, progress, and publishing state. Its videoModelPreflight is read-only (readOnly: true): a missing location plate is reported, never generated. Input: {jobId}.
quotePitchDeckBuildPOSTRequiredReturns an itemized Studio Credit quote for the pitch deck production plan, chapter stills, motion clips, and narration, excluding already reserved or settled asset keys. The quote carries balanceAfter / sufficient (see "Quote balance" below). Input: {jobId}.
approvePitchDeckPlanPOSTRequiredValidates a draft journeyPlan, reserves itemized pitch deck credit line items, clears legacy compatibility arrays, and enqueues pitch-deck-generate. The worker settles completed continuity stills, Seedance clips, and narration assets, then releases ungenerated assets. Input: {jobId}.
getPitchDeckQuoteProgressGETRequiredWhat the plan approval's quote is doing while it prices ("Preparing chapter 9 of 17; 8 of 17 done…"), polled by the deck page only while its Approve is pricing. The approval writes each chapter's start and finish on the draft deck's progressDetail (a compare-and-swap, prefixed Pricing the render: ) and puts the draft's own line back when the compile is over. Reads one column; { progress: null } when nothing is being prepared, the deck is not a draft, or it is not the caller's. Input: {jobId}. Core: readPitchDeckQuoteProgressForUser (pitch-deck-quote-progress.ts).
listPitchDeckConversationsGETRequiredLists pitch-deck conversations linked to a script. Input: {scriptUploadId}.
ensurePitchDeckConversation (pitch-deck-conversation.ts)POSTRequiredThe open deck's one conversation (its Slug pane opens it by itself, on the Edit and Video tabs): the deck's own conversation, bound to it (PitchDeckJob.conversationId) on first use and scoped like the deck (its upload, or its season/project with the upload its assets come from). A deck the Story API, MCP or the CLI made has none until then; once bound, Slug's deck tools find the deck through the link and edit its plan in place through the same cores the Story API calls. A deck that has its conversation gets it back. Input: {jobId}. Core: ensurePitchDeckConversationForUser.
deletePitchDeckJobPOSTRequiredDeletes a pitch deck job owned by the current user. Input: {jobId}.
duplicatePitchDeckJobPOSTRequiredA new draft deck carrying this deck's current plan (chapters, edits, intent, interludes) and its narrator, delivery class and strategic target; the source is untouched and nothing rendered is copied. Free. Input: {jobId, title?}. Core: duplicateVideoForUser (server/video-duplicate.ts, a trailer's duplicate too), shared with POST /api/v1/pitch-decks/{jobId}/duplicate, MCP duplicate_pitch_deck and CLI pitch-deck duplicate.
publishPitchDeckPOSTRequiredPublishes a completed pitch deck, returns/creates a shareToken, and queues master MP4/PDF artifacts when needed. Input: {jobId}.
requestPitchDeckVideoExportPOSTRequiredExports a completed pitch deck for where it is going (owner D7). target: 'site' (the default) queues the Remotion master re-render; 'youtube' / 'x' make a copy of the finished master levelled to -14 LUFS (true peak at or under -1 dBTP), the master unchanged and rebuilt first when out of date (server/video-platform-export.ts). Free. Input: {jobId, target?}. Core: requestPitchDeckVideoExportForUser; the trailer's requestTrailerVideoExport is the same with its own gate.
unpublishPitchDeckPOSTRequiredUnpublishes a pitch deck. Input: {jobId}.
getPitchDeckByShareTokenGETPublicLoads a published pitch deck for the public share route, including journeyPlan, asset manifest, public MP4 metadata, and PDF metadata. Returns the discriminated result, not a flat deck: { ok: true, deck } with the read deck in its deck member, or { ok: false, kind: 'not_found' } as a value for the links that must 404. Input: {shareToken}.

video-platform-export.ts (a deck's or trailer's copy for YouTube or X)

Owner decision D7 (2026-10-12): an export takes a target. site is the master re-stitch it always was; youtube and x make a COPY of the finished master levelled to -14 LUFS integrated with its true peak at or under -1 dBTP, by a final two-pass ffmpeg loudnorm on the copy only (lib/platform-loudness.ts): the master is measured with the EBU R128 meter (ebur128), loudnorm is fed those readings with linear=true and its widest range target (50 LU, so a wide range alone never forces its dynamic mode), the picture is copied (-c:v copy) and the sound delivered as every master is (48 kHz stereo AAC 192k). The copy is measured as delivered and made again, aimed by what it measured, until it lands within 0.2 LU (at most three passes, each from the master); one outside ±0.5 LU or over -1 dBTP is refused, never delivered. A master whose peaks sit at the site's -1.5 dBTP cap cannot reach -14 with one gain, so loudnorm's dynamic mode limits it (it builds on production's ffmpeg 5.1 because the delivery format is named after it). The copy is made from the FINISHED master the job plays NOW: an out-of-date, failed or missing master is rebuilt first and the copy waits (waiting). Waiting copies are settled (kickWaitingPlatformExports) by both master stitches the moment a master is published (before the version, bell check and chapter mix) and by any read that finds one waiting: queued from a finished master; failed with the plain reason when the published master is a 480p preview of a cut that went back to review (a deck's complete with no preview stamp, a trailer's trailerExportReadiness); left waiting while the cut is finished again or a stitch is queued, running or retrying; failed for good when its rebuild failed with nothing coming. A finished deck assembling a preview refuses a new copy (409 video_platform_export_not_finished). A loudness measurement that fails is never silence: only ffmpeg's "matches no streams" or a silent reading passes the master through; any other failure throws and the job retries. State lives in the job's platformExports column (one entry per target, written atomically per target, owned by its requestId), worked by the video-platform-export queue (stately, one key per surface, job, target and accepted request; a null send succeeds only when that exact request is already backed), and read on the deck's chapters read and the trailer's beats read (platformExports), Slug's status tools, and the export dialog (getVideoExportState in server/video-export-state.ts, components/writing-lab/render/video-export-dialog.tsx). Story API POST /pitch-decks/{jobId}/video-export / POST /trailers/{jobId}/video-export { target? }; MCP export_pitch_deck_video / export_trailer_video (target); CLI pitch-deck|trailer export <jobId> [--target site|youtube|x]; Slug's two export tools take target. Free.

Publication settlement reports a target as queued only after dispatch succeeds or its exact request has backing work. Dispatch failure records and returns the failed copy, including when the master publishes before the waiting export request answers; a failure from a replaced request cannot classify its successor as failed.

artifact-titles.ts (renaming a plan, a deck, a trailer or a result)

Owner, 2026-10-11: no surface could rename these. One core per kind answers its Story API PATCH, MCP (rename_plan, rename_pitch_deck, rename_trailer, rename_artifact), the CLI (plans rename, pitch-deck rename, trailer rename, artifacts rename) and Slug (the project chat's rename_plan, the deck and trailer chats' rename_pitch_deck / rename_trailer, the table-read chat's rename_table_read). A deck's and a trailer's name is a display name on its own title column (their plan, coverage and master never read it; null gives the automatic name back); a plan's is StoryPlan.title; a table read's is the title of the script it performs (updateScriptForUser). Free; nothing renders or goes out of date.

FunctionMethodAuthDescription
renamePitchDeckJobPOSTRequiredThe deck lists' inline Rename (Videos tab, deck tab). Input: {id, title} (title: null or empty: back to the plan's name). Core: renamePitchDeckForUser, shared with PATCH /api/v1/pitch-decks/{jobId}.
renameTrailerJobPOSTRequiredThe trailer list's inline Rename. Input: {id, title} (null or empty: back to the automatic name). Core: renameTrailerForUser, shared with PATCH /api/v1/trailers/{jobId}.

A waiting plan's Rename on the project page is renamePlanFromApp (story-workflow-ui.ts, input {planId, title}), on the core renameStoryPlanForUser that PATCH /api/v1/story-plans/{planId} uses. renameArtifactForUser (PATCH /api/v1/artifacts/{artifactId}) renames a table read through its script's title and a finished deck or trailer through its job; renameTableReadScriptForUser is the table-read chat's door, by the script version it holds. A table read's name is also changed in the script's Edit dialog in the Writers Lab library (updateScriptMetadata).

score-to-picture.ts

One score composed to a deck's or trailer's cut and laid under the whole master in place of the table read's scene beds (lib/marketing/score-to-picture.ts; the queued composer is score-to-picture-worker.ts on the score-to-picture queue; the cue sheet is planned by the score_to_picture_cue_sheet sidecar). The writer's own music is never replaced. No dead air (score-no-dead-air): after composing and conforming, the worker scans the score against the cut's length (100 ms RMS windows under −50 dBFS for longer than 1.5 s; a quiet head longer than 1 s unless the cue sheet enters on a hit later, scoreDeliberateEntryMs; a tail whose digital silence, under −60 dBFS, starts more than 0.5 s before the cut, unless it is a real fade-out falling over the last 3 s); a silent composition is composed again with the silent sections told to sound from their start (on the per-section path, only those sections), what stays silent is filled from the music beside it (a silent tail from its last audible bars, extended to the cut), and the state's silentGap (with its writer-visible note, also the end of message) says what happened on every surface. EARLY MUSIC (owner, 2026-10-05): a score can be asked for on a draft plan (and while the first pass renders) with its direction; it is priced on the PLANNED cut (scoreCutFromPitchDeck(..., { planned: true }) lays every chapter at its planned share; a trailer's cut already does), waits for the master (waiting_for_master), and a SKETCH to the planned cut is composed right away by the same planner, composer and dead-air guard (the worker's mode: 'sketch', stored as scoreToPicture.sketch; never banked, laid or charged). While a score is asked for (scoreToPictureRequested: waiting, queued, composing or laid) the deck assembly neither reads nor lays the table read's beds, and the chapters read's musicPlan projects none: it leads with the direction and score.sketchUrl. Video coverage measures the laid score too: an audioSync finding over dead air in it gets the remedy rescore, never a retake. Story API: GET|POST /{pitch-decks|trailers}/{jobId}/score-to-picture, POST …/score-to-picture/revert; MCP get_/score_/revert_*_score_to_picture; CLI <pitch-deck|trailer> score-to-picture <jobId> show|start|revert; Slug mounts the same three tools.

FunctionMethodAuthDescription
getScoreToPictureStateGETRequiredThe score's state: status, canScore / blocked (why not), runtimeMs, fitsCut, untouched, the cueSheet (sections by cut with their windows, intensity, hit points, where speech sits), direction, canRevert, plannedCut (no master yet: the planned cut), sketch (the sketch to the planned cut while the score waits for its master: status, url, fitsCut) and one sentence. Input: {surface: 'pitch_deck' | 'trailer', jobId}.
quoteScoreToPicturePOSTRequiredPrices a score to picture per second of master at SCORE_TO_PICTURE_SECOND (0 today: priced like the app's other music) with balanceAfter / sufficient. Refused before any price when the cut cannot be scored. Input: {surface, jobId}.
startScoreToPicturePOSTRequiredThe confirmed start: passes the project spend gate, holds the quoted line (none at today's rate), records the request and queues the composer — or, on a draft plan or while the first render pass runs, waits for its master and queues the sketch to the planned cut. Input: {surface, jobId, direction?}.
revertScoreToPicturePOSTRequiredTakes the score off the timeline, puts the beds it replaced back and re-stitches the master through the serialized assembly (restitch); cancels a score still waiting for the master. Free. Input: {surface, jobId}.

bell-lane.ts

BELL ON THE BEAT (owner on Karamazov v2, 2026-10-06: the composed score held no regular 2 s strike, so a real bell is laid): one bell sound — a My Library sound effect, or one made from words with the sound-effect generator and banked — struck on every beat of a deck's bell beat grid (its title sequence's, #1116), frame-exact, as ONE lane on its audio timeline (audioTimeline.bell, lib/marketing/bell-lane.ts). Every beat or every 2nd; fromSeconds/toSeconds (default the whole deck); skipSceneIndexes (black interludes, chapters, the title sequence); the intros and the title accented; its level (default 0.12) on a sound levelled to one peak — when the lane's sound is set, a copy peaking at -1 dBFS is made once with ffmpeg's volume (server/bell-sound-level.ts, sound.normalized) and struck, so a level is the same loudness for every bell; ducked under the spoken lines with the score's duck. The sketch mixes the bell against the score at the level the master lays it (0.25), and both the sketch and the master check weigh each strike against the score (audibility, a note when the bell sits more than 12 dB under it). The strikes are derived wherever the lane plays: the master mix (resolveTimelinePlacements with the deck's pitchDeckMasterBellGrid, one <Audio> per strike), the sketch (lib/marketing/bell-lane-mix.ts, the score-to-picture worker's sketch and bell-mix modes: the score alone kept as sketch.scoreUrl, the sketch mixed again whenever the lane changes) and the check after assembly (server/bell-lane-check.ts, run where every deck master is published: each strike's onset measured in the master's audio, kept as assetManifest.bellCheck, bookkeeping). A score to picture composed with a lane leaves room for it (no bell descriptors, bells and downbeat percussion among the composer's negatives, the planner handed a Bell lane: line; the score_to_picture_cue_sheet row's THE BELL LANE section, rollout bell-lane). Free: a deck in review or finished re-stitches its master for its sound. Story API: GET|POST /pitch-decks/{jobId}/bell-lane, POST …/bell-lane/remove; MCP get_pitch_deck_bell_lane, strike_pitch_deck_bell_on_the_beat, remove_pitch_deck_bell_lane; CLI pitch-deck bell <jobId> show|strike|remove; Slug mounts the same three tools. The UI reads the bell (one read-only line on the deck page, components/audio/bell-lane-line.tsx, and the timeline's Bell lane) and changes it through Slug: its three tools, and a painted bell wizard's Apply (server/surfaces.ts). The bespoke Bell on the beat panel and its setBellLane / removeBellLane server fns are gone (owner, 2026-10-06).

FunctionMethodAuthDescription
getBellLaneGETRequiredThe deck's bell lane: available (or unavailable with how to turn the grid on), the grid, the lane, every strike on the cut (the planned cut before any master), the parts it can skip, start/end points, the check on the master (checkCurrent) and whether the laid score made room for it. Input: {jobId}.

video-versions.ts, video-versions-review.ts

A DECK'S AND A TRAILER'S VERSIONS (owner, 2026-10-09: "the raw individual clips are saved off and organized pre-stitching so we can always repurpose them"; owner D9, 2026-10-11: trailers too, so a trailer re-assembly never overwrites its master without keeping it). One core serves both surfaces through a surface adapter. Every published master — preview, early look or finished — is recorded as a numbered PitchDeckVersion or TrailerVersion (docs/database.md) by recordPitchDeckVersionSafely / recordTrailerVersionSafely, called where the master is published (pitch-deck-video-export.ts, trailer-video-export.ts; never fails the stitch). Its clips are copied server side (R2 CopyObject, never re-encoded) into pitch-decks/<jobId>/versions/<n>-<YYYYMMDD-HHMM>/chapters/<NN>-<slug>/ or trailers/<jobId>/versions/<n>-<YYYYMMDD-HHMM>/beats/<NN>-<slug>/ with a manifest.json by the video-version-archive queue (video-version-archive.ts, { surface, versionId, task: 'folder' }; a copy already there with the source's ETag is kept; at most 400 objects), queued only through the queue the stitching worker already runs. Its zip is packed on request (task zip: stored, streamed from R2 into a multipart upload, at most 16 GB) and read back as a signed link. Story API GET /pitch-decks/{jobId}/versions / GET /trailers/{jobId}/versions, GET …/versions/{number}, POST …/versions/{number}/zip, POST …/versions/{number}/restore (BL-4114: the takes it played chosen again and a master assembled from them); MCP list_pitch_deck_versions / list_trailer_versions, get_pitch_deck_version / get_trailer_version, request_pitch_deck_version_zip / request_trailer_version_zip, restore_pitch_deck_version / restore_trailer_version; CLI pitch-deck|trailer versions <jobId>, pitch-deck|trailer version-clips <jobId> <number> [--download <dir>] [--zip], pitch-deck|trailer restore-version <jobId> <number>; Slug mounts the same four tools in each chat (the reads work with a chapter or beat focused). The reviews' Versions list (components/writing-lab/render/video-versions-list.tsx) reads them through:

FunctionMethodAuthDescription
listVideoVersionsGETRequiredThe deck's or trailer's versions, newest first: number, stage, date, master, chapter or beat count and take count, folder and copy status, zip. Input: {surface, jobId}.
getVideoVersionGETRequiredOne version: every chapter's or beat's cut before the finish and the take each shot played (trim, finished copy), each with its path in the folder and its copied URL; the sound and finish; every file; the zip (signed link while ready). Input: {surface, jobId, number}.
requestVideoVersionZipPOSTRequiredAsks for the version's zip (free): a ready one answers its link; otherwise it is queued. Input: {surface, jobId, number}.
restoreVideoVersionPOSTRequiredPuts an earlier version back (BL-4114, free): restoreVideoVersionForUser reads the version's own snapshot (videoVersionSelectionParts) and hands the takes it played to the surface's selection core (restoreTrailerVersionSelectionForUser / restorePitchDeckVersionSelectionForUser), which chooses them in ONE manifest write and asks for a new master; a take no longer held is named in missing instead of stopping the undo. Review or complete only. Input: {surface, jobId, number}.

video-interludes.ts, video-closing-sequence.ts, master-interludes.ts

A DECK'S AND A TRAILER'S BLACK INTERLUDES AND CLOSING SEQUENCE (owner, 2026-10-02 and 2026-10-06 for decks; owner D9, 2026-10-11: trailers too). One core each serves both surfaces through a surface adapter (which table, which plan column, which key a unit goes by: a deck's afterChapterIndex, a trailer's afterBeatIndex, a beat's place, re-keyed when beats are inserted, moved or removed). The rules are one module each (lib/video-interludes.ts, lib/video-closing-sequence.ts): both masters lay the blacks and the closing as segments of their one segment list (pitchDeckMasterLayout, getTrailerRenderPlan), the score's cut, video coverage and plan coverage read them, and the voices check counts a line over black. updateVideoInterludesForUser({surface,…}) replaces the whole list (validated whole; a cast speaker needs a voice): a draft's plan is queued for coverage again, a rendered video's master is stitched again (free). updateVideoClosingSequenceForUser({surface,…}) sets the closing whole; directClosingTextMotionForUser / clearClosingTextMotionForUser direct one card through the motion-text director (every save a compare-and-swap on the plan). Each assembly voices a line over black and makes its sound through one loop (voiceInterludes, server/master-interludes.ts; our cost): the narrator's lines in the video's narrator (a deck's narrator rule; a trailer's resolveTrailerNarratorVoice), a cast line in that person's voice, a sound banked and never laid when someone talks in it. Story API POST /pitch-decks|trailers/{jobId}/interludes, POST …/closing-sequence, POST …/storyboard/direct-text-motion with a closingElement; MCP update_pitch_deck_interludes / update_trailer_interludes, update_pitch_deck_closing_sequence / update_trailer_closing_sequence; CLI pitch-deck|trailer update-interludes, pitch-deck|trailer closing-sequence, … direct-text-motion --closing-element; Slug mounts the same tools in each chat. The editors carry no transition control between two units and no Closing form (owner, 2026-10-18: the knobs are Slug's): the black between units and the closing are told to Slug, and the page only reads them. The server functions that remain:

FunctionMethodAuthDescription
updatePitchDeckClosingSequence / updateTrailerClosingSequencePOSTRequiredSets the video's closing sequence whole, or removes it with null (free; its directions are kept). Input: {jobId, closingSequence}. Core: updateVideoClosingSequenceForUser.

project-brand-logo.ts

The deck and trailer Closing cards read, upload, replace and remove their project's logo through the same project update as the Story API's brandLogo, CLI projects update, and MCP set_project_brand_logo. Uploads accept PNG, JPEG or WebP up to 2 MB and 4096 pixels on a side. The server decodes the image and stores its own PNG or WebP copy without metadata or trailing content. Removal clears the project field; saved closings retain their copied logo until saved again. The site's "Remove logo" also saves this video's closing without the logo card when it was on.

FunctionMethodAuthDescription
readVideoProjectBrandLogoGETWriting Lab accessReads the logo for an owned video's project. Input: {surface, jobId}.
setVideoProjectBrandLogoPOSTWriting Lab accessUploads a logo for an owned video's project, fully decoded and encoded before storage. Input: {surface, jobId, dataUrl}.
removeVideoProjectBrandLogoPOSTWriting Lab accessRemoves the logo for an owned video's project. Input: {surface, jobId}. Core: removeVideoProjectBrandLogoForUser.

project-access.ts / project-members.ts / project-members-fns.ts

THE ACCESS RULE FOR A PROJECT (server/project-access.ts). requireProjectAccess({ userId, projectId, need: 'read'|'edit'|'manage', match?, operation?, via? }) returns { projectId, routeId, name, kind, ownerUserId, actorUserId, role }: 404 project_not_found for anyone who is neither the owner nor a member (as before sharing), 403 project_read_only for a viewer asking to change it, 403 project_owner_only for a member asking to manage (delete, members). Admins get no content access from it. A member's edit is recorded as a member_write ProjectMemberEvent. Rows inside the project are read and stamped with ownerUserId; credits, caps, My Library, voices, conversations and the audit stay actorUserId. requireProjectOwner is the owner-only check for every surface not yet opened to members (assertProjectOwned in story-project-ownership.ts calls it); requireProjectJobAccess checks a trailer or deck through its project (a standalone job stays its owner's); accessibleProjectsWhere(userId) is the list filter.

PROJECT MEMBERS (packages/shared/src/project-members.ts). Core: listProjectMembersForUser (anyone who can open the project, or an admin), addProjectMemberForUser (by email — an address with no account is invited to set a password and joins in the same step, invited: true; owner or admin; product projects only, 409 project_members_product_only), updateProjectMemberForUser, removeProjectMemberForUser (a member may remove themselves), each with a ProjectMemberEvent in the same transaction and a live update to the member; listUserProjectsForAdmin (admin Users tab). A shared project's kind cannot change while it has members. Server fns (project-members-fns.ts): listProjectMembersFn, addProjectMemberFn, updateProjectMemberFn, removeProjectMemberFn (the project ⋯ menu's "Share project"; admin: true from the admin Users tab records admin_ui; an admin impersonating the owner is recorded as the actor), listUserProjectsForAdminFn (never while impersonating). Story API GET|POST /story-projects/{projectId}/members, PATCH|DELETE /story-projects/{projectId}/members/{userId}; MCP list_project_members, add_project_member, update_project_member, remove_project_member; CLI projects members list|add|update|remove; Slug list_project_members, manage_project_members (proposes, then acts on confirmed: true). Live updates of a shared project reach its members (server/live-events.ts).

ORGANIZATION MEMBERS (packages/shared/src/organization-members.ts). Core (server/organization-members.ts): listOrganizationMembersForUser (anyone in the caller's business organization), addOrganizationMemberForUser (by email as admin, editor or viewer — an address with no account is invited to set a password and joins in the same step, invited: true; the owner and admins, never above their own role; one organization per person: 409 organization_member_elsewhere), updateOrganizationMemberForUser, removeOrganizationMemberForUser (anyone may leave; the owner never changes or leaves: 403 organization_forbidden). Server fns (organization-members-fns.ts): the Integrations page's Team card. Story API GET|POST /organization/members, PATCH|DELETE /organization/members/{userId}; MCP list_organization_members, add_organization_member, update_organization_member, remove_organization_member; CLI integrations team list|add|update|remove; Slug list_organization_members, manage_organization_members (proposes, then acts on confirmed: true).

workspace-role.ts / workspace-role-fns.ts

UNLIMITED STUDIO CREDITS (server/unlimited-credits.ts, docs/credit-billing.md): setUnlimitedCredits (admins only, read from the database; only an admin or an allow-listed demo account, else 403 unlimited_credits_not_allowed; sets User.unlimitedCredits + internalAccount and writes an UnlimitedCreditsEvent; idempotent). Server fn (unlimited-credits-fns.ts): setUnlimitedCreditsFn (POST {userId, unlimited}; admin Users tab, never while impersonating). Story API PUT|DELETE /admin/users/{userId}/unlimited-credits; MCP set_unlimited_credits; CLI admin unlimited-credits on|off <userId>. Every limit reads the flag through isUnlimitedCreditAccount (lib/unlimited-credits.ts) or balanceCovers (lib/credits.ts).

THE WORKSPACE ROLE (Screenwriter writer, Content creator creator, Business business; packages/shared/src/workspace-role.ts). Read from the profile's track + creator context, never stored twice. Core: readWorkspaceRoleForUser, setWorkspaceRoleForUser (refused 403 role_switch_not_enabled unless User.workspaceRoleSwitchEnabled, admins included; writes the pair + a WorkspaceRoleEvent in one transaction; the same role is a no-op), setWorkspaceRoleSwitchForUser (admins only, read from the database; idempotent; audited), defaultProjectKindForUser (Business starts a project with no kind as product), assertFirstRunRoleWrite (onboarding's own track/context writes refuse once onboarding is done). Server fns (workspace-role-fns.ts): getMyWorkspaceRoleFn (GET), setMyWorkspaceRoleFn (POST {role}; the account menu's role picker, components/auth/workspace-role-picker.tsx, and the profile page; neither UI says who can turn switching on), setWorkspaceRoleSwitchFn (POST {userId, enabled}; admin Users tab, never while impersonating). Story API GET|PUT /account/workspace-role, PUT|DELETE /admin/users/{userId}/workspace-role-switch; MCP get_workspace_role, set_workspace_role, set_workspace_role_switch (and whoami.workspaceRole); CLI role, role set <writer|creator|business>, admin role-switch enable|disable <userId>; Slug set_workspace_role. Business adds the BUSINESS_PROMPT snippet on top of STORYTELLER_PROMPT (server/writer-guidance.ts). updateProfile no longer takes workspaceTrack.

THE CRAFT FOCUS (the kind of work Slug coaches toward; packages/shared/src/craft-focus.ts holds the one list, stored on UserProfile.storytellerSubset). Core: setCraftFocusForUser (server/craft-focus.ts): a key or plain words matched by normalizeStorytellerSubset, null/empty clears, a value naming no focus refused 400 validation_failed with details.options; the same focus twice writes nothing (changed: false). Server fn saveCraftFocus (POST {craftFocus}; Profile → Craft focus, components/profile/craft-focus-control.tsx). Story API PUT /account/craft-focus (read on GET /account/workspace-role as craftFocus); MCP set_craft_focus; CLI craft-focus, craft-focus set <focus>, craft-focus clear; Slug set_craft_focus.

demo-accounts.ts / demo-accounts-fns.ts

DEMO ACCOUNTS (admins only; docs/auth.md "Demo accounts"). Core: readDemoAccountIds, setDemoAccount (audited; an admin account is refused; removal revokes every minted session), listDemoAccounts, mintDemoSession (via the server-only Better Auth plugin; refused for non-admins, non-listed or admin targets, OAuth grants and TTLs outside 5 min–8 h), revokeDemoSession, checkDemoSession (the per-use check), demoImpersonationActor / demoSessionActor (who may act for a demo account), recordDemoAccountEvent. Server fns for the Users tab (demo-accounts-fns.ts, signed-in admin, never while impersonating): getDemoAccountsFn (GET), setDemoAccountFn (POST {userId, demo}), revokeDemoSessionFn (POST {sessionId}). No mint from the browser. Story API /admin/demo-accounts[/{userId}[/sessions]], /admin/demo-sessions/{sessionId}; MCP list_/add_/remove_demo_account, mint_/revoke_demo_session; CLI admin demo-accounts|demo-account|demo-session.

video-destinations.ts

VIDEO DESTINATIONS (destination videos PV1). listVideoDestinations() reads every switched-on VideoDestination row in order and checks each one's spec (and family) against @sleeperhit/shared/video-destination-spec: the usable ones as destinations, the failing ones as unavailable with the reason, never guessed. getVideoDestination(key) reads one: 404 video_destination_not_found without a switched-on row, 409 video_destination_unavailable when its spec fails. readVideoDestinationRow(row, now) is the pure check, and sets specMayBeStale when verifiedAt is more than 90 days old (or missing on a row with sourceUrls). The same catalog for every account; free. Story API GET /video-destinations and /video-destinations/{key} (source:read); MCP list_video_destinations / get_video_destination; CLI destination list|get <key>; Slug's list_video_destinations in the project's chats, painted as a Summary card (components/chat/video-destinations-card.tsx). No server fn: the UI is that painted read.

capture-depth.ts / capture-depth-setting.ts / capture-depth-review.ts

A CAPTURE BEAT'S DEPTH (app-to-trailer PR 1.6). captureDepthComposite(input, deps?) makes the composite a capture beat with a depth preset plays in the master's card slot: it reads the CaptureDepthPreset rows (clamped), plans the shot from the capture's events.json (lib/capture-depth/planner.ts), publishes the stage plate once, renders the CaptureShot composition through renderRemotionMediaToBuffer (label trailer-capture-depth; anything above flat farmPolicy: 'farm-only', and when no box takes it the pod renders subtle at 720p), stores it beside the trailer's media by a content key, and checks it against a flat twin (rendered text size, a sub-pixel exact text-crop SSIM via measureCaptureTextSsim, the camera's speed), stepping down one preset at a time while it got harder to read. Called by the assembly (server/trailer-capture-picture.ts), which hands it the edit's raw master and edit map when it has one: an edit with its cursor burned in then plays from the clean master under the one drawn cursor, planned from the edit's own events.json, decided per preset planned (captureCursorSource, lib/capture-depth/edit-map.ts; S3a). The raw master is private: the renderer gets a presigned GET (≤1 h, signTrailerRawCapture), never a public copy, and nothing stored names it. Free. getTrailerCaptureDepthForUser / setTrailerCaptureDepthForUser read and set TrailerJob.captureDepth (free, any state; a rendered trailer's master is assembled or stitched again); the server fn setTrailerCaptureDepth (server/trailer.ts, requireAIAccess) is the review's picker. A beat's own preset and stage caption are refineTrailerBeat's depthPreset / stageCaption. applyCaptureLegibilityVerdicts (the video coverage worker, after a report) sets a step-down ceiling on each capture beat the Gemini watch found harder to read and asks for the master again. Story API GET|POST /trailers/{jobId}/capture-depth; MCP get_/set_trailer_capture_depth; CLI trailer capture-depth <jobId> [--preset …]; Slug mounts the same two tools.

quiet-track-setting.ts

A DECK'S AND A TRAILER'S QUIET TRACK (Karamazov v5, 2026-10-08 for decks; owner D9, 2026-10-11: trailers too). One core reads and sets PitchDeckJob.quietTrack / TrailerJob.quietTrack (silence, room_tone, or null: none): getQuietTrackForUser({surface,…}) counts the renders it reaches, the quiet renders too long for one and the takes already rendered with each track, over the master's units (a deck's chapters, whose stretches that keep their own sound are left out; a trailer's beats, which keep none); setQuietTrackForUser({surface,…}) sets it (free, any state; it changes only the takes rendered after it). Each take is built with it by quietTrackForTake (server/scene-render/quiet-tracks.ts), from the deck's call audio and from the trailer's renderOneTake, and records what it was sent. No server fn: the UI reaches it through Slug. Story API GET|POST /pitch-decks|trailers/{jobId}/quiet-track; MCP get_/set_pitch_deck_quiet_track, get_/set_trailer_quiet_track; CLI pitch-deck|trailer quiet-track <jobId> [--track silence|room_tone | --off]; Slug mounts the same two tools in each chat. A duplicate carries it.

project-sounds.ts

THE SOUNDS A PROJECT USES (owner on Karamazov v3, 2026-10-06: "use the same bell as v2"): listProjectSoundsForUser finds every sound where it was used — each deck's bell lane, every music clip and sound effect on a deck's or trailer's audio timeline — named as used and by its video, numbered in the project by when it was made ("Deck 2 · Karamazov v2"), each with bellSound ({sound: {fromJobId}} or {assetId}) to strike it as a deck's bell. No server fn: the UI reaches it through Slug (list_project_sounds, and the bell rework wizard's answers). Story API GET /story-projects/{projectId}/sounds?kind=&query=; MCP list_project_sounds; CLI projects sounds <projectId> [--kind bell|music|sfx] [--query <text>]. Reads only.

surfaces.ts

PAINTED SURFACES (owner, 2026-10-06; docs/design/slug-painted-surfaces.md): Slug paints a short, curated wizard in the deck conversation (paint_surface: one question a step, 2–4 answers with one recommended, Listen on a sound, then one line and Apply) or a one-card summary, from a fixed catalog rendered by components/chat/painted-surface.tsx. The spec is a json-render Spec (lib/surfaces/spec.ts), stored as a ChatSurface row with the deck the chat resolved when it painted. An apply sends only the chosen answer ids; the arguments come from the stored spec and run through the op's own core (lib/surfaces/ops.ts: pitchDeck.bellLane.set → setBellLaneForUser, pitchDeck.scoreToPicture.start → priceOrScoreToPicture). A priced op answers a quote first and runs only on a confirm with that quote's token. What was applied reaches Slug on the writer's next turn (lib/surfaces/history.ts), with no model turn per apply. Headless callers call the op's own route, MCP tool or CLI command.

FunctionMethodAuthDescription
applySurfacePOSTRequiredApplies a painted wizard's answers to the conversation's own deck. Refused for another writer's surface, a replaced one, an answer the spec does not offer, or a deck that is no longer the conversation's. Priced ops: {status: 'quoted', quote, quoteToken} first, then confirmed: true with that token. Input: {surfaceId, answers: {choiceKey: answerId}, confirmed?, quoteToken?}.
getSurfaceStateGETRequiredA painted surface's state (open, applied with its summary and answers, replaced), for a wizard reopened later. Input: {surfaceId}.

chat-reply-status.ts

FunctionMethodAuthDescription
getChatReplyStatusPOSTRequired (owner)What became of a Slug message the chat panel stopped waiting for (lib/ai/chat-late-reply.ts): received (the server saved it; null when it has no visible text to match), replied (Slug's reply is saved), and, once replied or on the panel's final check, the conversation's tail as saved with olderMessageCount (the panel shows it without a reload). Read-only; a POST because the message text can be longer than a URL. Input: {conversationId, pendingText, final?}.

writing-lab-projects.ts (project pages)

writing-lab-projects.ts serves the project pages from one index: getWritingLabProjectOverview (the project Overview page, /tools: project, gate, written episodes; its Next steps tab is the checklist and its Series Bible tab, ?tab=bible, the whole Bible read-only) and getWritingLabProjectForUser (Videos: every version with its job counts and characterCount; no version's cast or text).

conversation-messages.ts

A chat panel shows a conversation's tail (lib/ai/chat-message-window.ts: the latest 12 messages, plus anything streaming). getConversation({ slug, tail: true }) (the project chats, the full-page chat, and the screenplay editor chat outside a surgical pass), the trailer thread reads, the Cast chat (getOrCreateCastConversation) and the table-read chats (getOrCreateTableReadConversation, and the scene theater, global theater, DJ Studio and SFX Studio chats through openTableReadModeConversation in server/table-read-mode-conversation.ts) load only that tail and say how many saved messages come before it (olderMessageCount); "Show earlier messages" loads older ones a page at a time. While the panel holds only part of the history, each chat turn sends historyBeforeMessageId (its oldest saved message), and routes/api/chat/stream.ts reads everything saved before it, so the model still reads the whole conversation. One read, readConversationMessageWindow, serves all three, ordered by (createdAt, id). A conversation select takes the tail with ...CHAT_TAIL_SELECT and chatTailOf returns { messages, olderMessageCount }. getConversation never returns systemPromptSnapshot.

FunctionMethodAuthDescription
getEarlierConversationMessagesGETRequired (owner)The page of up to 24 saved messages before beforeMessageId in the writer's own conversation (any chat surface), oldest first, with olderMessageCount. A message that is not this conversation's has nothing before it. Read-only. Input: {conversationId, beforeMessageId}.

chat-tool-worker.ts: saving an approved preview

detect_sound_effects and audit_scene (the table-read setup chat and the Live Read manage pane) work in two steps. apply=false queues a preview: a ChatToolJob whose result.preview is true, which the chat card shows and Slug reads with get_tool_job_result. When the writer approves, Slug calls the same tool with apply=true and that preview's chatToolJobId, plus any subset the writer kept: onlyEntryIndices / excludeEntryIndices (sound effects, and a single-scene audit's emotion and timing changes), onlySceneIndices / excludeSceneIndices (the all-scenes soundscape audit), or keepCurrentSoundscape (a single-scene audit). The tool queues the apply job with previewChatToolJobId, so the chat card and its polling are unchanged. The worker saves exactly the previewed list: it does not call generateObject, resolve a provider or log usage, so there is no second provider cost. The table_read_manage_director prompt row teaches this apply.

HelperDescription
loadApprovedPreview({userId, scriptId, chatToolJobId, toolName})Owner-checked through readChatToolJobForUser, so someone else's job reads as not found. The job must also be on this script, come from the same tool, have status completed, and still be a preview. Anything else returns a writer-readable error, and nothing is queued, detected or saved. The worker runs the check again when it applies.
resolveApprovedPreview({userId, scriptId, conversationId, toolName, chatToolJobId?, sceneIndex?})Picks the preview apply=true saves. A named chatToolJobId must pass loadApprovedPreview; it never falls back to a fresh run. With no id, it takes the newest preview in this conversation that is still waiting; for audit_scene it must have the same scope (the same sceneIndex, or the all-scenes audit). When an apply is newer than every preview, or there is no preview, it returns none, and only then does apply=true run fresh. Passing a subset when there is no preview is an error.

A previewed sound-effect cue is skipped, with its reason in skipped, when its line text changed since the preview, the line is not an action line, or the line already has a sound effect. buildSoundEffectCues holds that one-cue-per-line rule for both a fresh run and a preview save. An audit proposal is skipped when its scene or line changed. Indices the writer named that were not in the preview come back in notInPreview. An audit save returns applied: true, plus scenesUpdated for the all-scenes audit.

Slug read cores

The Slug read-access work (docs/plans/slug-read-gaps.md) lets every chat surface read what the writer can see. Wherever a read lived only inside a createServerFn handler, which needs a request session, it was split into an owner-checked core that takes an explicit userId. The server fn calls that core, and so does the chat tool, so the page, Story API, MCP and Slug all go through the same ownership check. A read never dispatches, recovers or bills work. Where a load path used to generate a missing asset, it now takes readOnly, and every read passes it: the chat tools, the Videos panel server fn (getVideoWorkspaceAssets), the pitch-deck status poll (/api/pitch-deck/status) and getPitchDeckJobById, and both storyboard reads on every surface (the getPitchDeckStoryboardBoards / getTrailerStoryboardBoards server fns in the UI, and GET /api/v1/pitch-decks/{jobId}/storyboard / GET /api/v1/trailers/{jobId}/storyboard behind MCP and the CLI). Missing location plates are made by the paths that quote and reserve credits: the approval quote (requirePitchDeckVideoModelAssets) and the render workers.

The reads Slug has on every surface (background activity, Studio Credits, the Series Bible with its coverage and mood board) are chat tools, not server functions. See "Shared reads on every Slug surface" in docs/prompting-and-streaming.md.

Coverage add-on reports (blacklist.ts, production.ts)

HelperDescription
getBlackListReportForUser{userId, coverageReportId, isAdmin?}. Returns the Black List report for one coverage version, exactly as the Coverage tab shows it: owner-scoped (admins see any), and null when the script is not eligible for professional coverage add-ons. errorMessage comes back in writer-facing words (writerFacingFailureMessage): operator wording such as "schema not found — please configure via Admin → AI Config" becomes the shared setup-problem reason, while a reason the writer can act on ("Retry this analysis.") passes through. Everyone sees the same sanitized sentence; an admin caller also gets the worker's raw line in errorDetail beside it (the AdminTechnicalDetail disclosure on the tab), and a writer's payload carries errorDetail: null. Called by getBlackListReport and by the chat's get_blacklist_analysis.
getProductionReportForUserThe same for the Production report, including the writer-facing errorMessage and the admin-only errorDetail. Called by getProductionReport and by get_production_analysis.

The script page's status poll (/api/script/status) applies the same writer-facing relays to the errorMessage of coverage, tableRead, blackList and production (writerFacingFailureMessage on the last two, which read the whole failure in one plain sentence), so a failed job reaches the writer's toast and inline error in writer-facing words. characterAnalysis.errorMessage stays raw on purpose: script-job-status.tsx reads it to detect a missing provider (isMissingAiProviderMessage).

table-read.ts

HelperDescription
readTableReadReviewItemsForUser(userId, scriptUploadId). Returns the lines the writer flagged on the script's latest table read, with the notes they typed, and that job. Owner-checked; throws Script not found for a script the user does not own. Called by getReviewItems and by get_table_read_notes.

table-read-chat.ts

Function / helperMethodAuthDescription
saveDjStudioViewStatePOSTRequiredStores the DJ console's unsaved music and the bar's entry range on the DJ Studio conversation, so Slug critiques what the writer is hearing. It never writes the scene's saved music. Input: {conversationId, draftMusic (or null), startEntry?, endEntry?}. The core is saveDjStudioViewStateForUser.
readDjStudioMusicStateForUserhelperOwner{userId, conversationId, scriptUploadId}. Returns the bar-aware music state: the beat's own cue on a beat bar (with the scene bed under it), otherwise the scene bed, plus the synced draft when it differs from what is saved. Behind the DJ Studio get_current_music_state tool.
resolveDjStudioMusicStatehelpernonePure resolver shared by that reader and buildDjStudioSystemPrompt, so the tool and the per-turn brief cannot disagree.
readChatToolJobForUserhelperOwner{userId, jobId}. Returns one ChatToolJob's status, result and error. Ownership goes through the job's conversation, or through its script for jobs started from the page (they have no conversation). A job that belongs to someone else returns {status: 'not_found'}, the same as one that does not exist. Behind getChatToolJobStatus (the page's poll) and Slug's get_tool_job_result.
listChatToolJobsForScripthelperOwner{userId, scriptUploadId, toolName?, limit? (1-25, default 10)}. Returns recent tool jobs on one of the user's scripts, newest first. A script the user does not own returns no jobs. Also behind get_tool_job_result.

table-read-manage-chat.ts

Function / helperMethodAuthDescription
saveTableReadPlaybackPositionPOSTRequiredStores the Live Read line on screen in TableReadJob.resumePosition, so the manage chat's prompt can show the current line and scene. Input: {jobId, dialogueIndex (or null to clear)}. The core, saveTableReadPlaybackPositionForUser, uses a raw single-column UPDATE whose WHERE carries the owner check. A Prisma update would bump updatedAt, which is the concurrency token for voice-track synthesis.
readTableReadPlaybackPositionhelpernoneParses a stored position ({dialogueIndex, updatedAt?}); returns null when there is none.

theater-canvas-chat.ts

Function / helperMethodAuthDescription
recordTheaterCanvasApplyResultPOSTRequiredThe canvas dialog reports whether Slug's committed canvas applied, so the next turn knows. Input: {conversationId, status: applying/applied/failed, beatCount?, shareUrl?, error?}. Saved as metadata.lastApply on the owner's theater-canvas conversation. The core is recordTheaterCanvasApplyResultForUser.
formatApplyOutcomehelpernoneTurns lastApply into a line for the canvas prompt. An applying status older than THEATER_CANVAS_APPLY_STALE_MS (10 minutes) reads as never reported.

cast-chat.ts

HelperDescription
buildEpisodeScreenplayBlock(userId, scriptId, content). Builds the {EPISODE_SCREENPLAY} block of the Cast prompt. Every line with its entry index when the read's lines exist and fit in CAST_SCREENPLAY_FULL_TEXT_MAX (30,000 characters). Otherwise a scene index with each speaker's line count; the lines stay readable by tool. Before parsing, it uses the stored screenplay text. Best effort: a failure leaves the block empty.

script-read-voices.ts, cast-jobs.ts: the Cast card's read voice and Redraw

The owner on the Karamazov Cast tab (2026-10-05): the card's voice and the episode's read must never silently disagree, and each picture gets its own Redraw.

Function / helperDescription
readScriptReadVoices{userId, scriptUploadId}. The screenplay's finished read as the Cast card sees it: artifactId, restorable, and behindCanon: each character the finished read's own voice map (the job behind the artifact's share link, which the re-voice core plans against) still speaks with another voice than their cast canon voice. Behind readCastReadVoices (the card's "This episode's table read still uses …" and its priced Re-voice in this read), GET /story-projects/{projectId}/cast?scriptUploadId= (episode.read, so MCP get_project_cast and CLI cast status), and Slug's get_episode_cast_status (read).
voicesBehindCanon(voiceMap, canon)Pure. A person is read under the FIRST voice-map key that names them, as revoiceArtifactCharacter picks the cue it re-voices, so a listed character is one a re-voice with no voice would change and never one it refuses as "already speaks with". A person with no canon voice, or a cue the canon does not know, is never listed.
readsSpeakingOtherwise (cast-voice-design.ts)The choose answer's reads. A read belongs to the project by its job's project or by the project screenplay its share link serves, so a Writing Lab read made before its screenplay joined the project is still offered its re-voice.
quoteEpisodeCast / startEpisodeCast with imagesAn episode cast narrowed to pictures (face, body, sheet) and no voices or narrator: with force they are drawn again (the card's Redraw on a body shot or sheet), priced per picture (EPISODE_CAST_CREDIT_RATE). A body shot or sheet for a person with no headshot is refused before the price (cast_face_missing, a 409 cast_precondition_failed on the API). Any episode quote with a body shot or sheet in it is refused before the price when no model that keeps a face is bound (resolveCastIdentityImageModel), as the project cast is. The job skips the voice pass, completes on its own pictures, and its final progressDetail carries each check against the headshot (onArtCheck, threaded through generateCastForScript to generateCastFigures and generateAllCharacterSheets). quoteCast / startCast take the same images; generateCastForProject sends images without a scriptUploadId to the project canon's Redraw (below).
readEpisodeCastJobAdds pictures ({character, image} from the job's line items): the card marks each tile a running job is drawing.

cast-picture-redraw.ts: the project canon's Redraw

A person on the project cast canon whom no episode names (Karamazov v2's Grushenka) had no body shot or sheet Redraw: the episode Redraw casts only its screenplay's roster, and the project cast's force redraws the face too.

FunctionNotes
redrawProjectCastPicturesimages (body / sheet; face is a 400, a headshot is redrawn as candidates) for canon people (characters, any name they answer to; omitted, everyone but the narrator). Refused before a price: not owned (404), the workspace gate, a person not on the canon (404 character_not_found), no headshot (cast_face_missing), nothing to draw from (cast_missing_descriptions), no model that keeps a face, more than MAX_REDRAW_PICTURES (4) pictures (the chat-tool queue reruns a job at its 600 s expiry while the first attempt still draws). Priced per picture at EPISODE_CAST_CREDIT_RATE; the quote carries a quoteId hashed from the people, their face and their current pictures. Confirmed with that quoteId (else cast_quote_changed), it refuses a picture already being redrawn (cast_redraw_running), checks the spend gate, reserves the pictures and queues cast_picture_redraw on the chat-tool queue. Reached by generateCastForProject (images without scriptUploadId: API, MCP, CLI), the generateProjectCast server fn (the Cast card, surface: 'app') and Slug's redraw_cast_pictures (surface: 'chat').
drawProjectCastPicturesThe worker. Per person, the sheet then the body (wearing the new or current sheet), from the chosen face on the identity model with the person's look, each checked against the face (#1040). A face that changed since the price draws nothing. Each picture is written onto the canon with rewriteCastCanonForProjectId (a compare-and-swap on the headshot) and read back; only then is it settled. A failed draw, write or read-back releases it and fails the job (nonRetryable). A retried job draws only pictures still queued.
readProjectCastRedrawThe project's newest redraw, picture by picture (url once the canon holds it, check, error). On readProjectCast (redraw) and the Cast tab's getProjectCast.

gold-standard.ts

HelperDescription
readGoldStandardExemplar(id). Returns the exemplar as the Gold Standards editor shows it (title, medium, genre, logline, scores and the source report). No auth of its own: getGoldStandard gates on the admin session, and the calibration chat's get_gold_standard checks the acting user's admin role.
readGoldStandardCalibrationScript(id, offset = 0). Returns the screenplay the exemplar calibrates, in windows of CALIBRATION_SCRIPT_WINDOW_CHARS (100,000) with nextOffset. Calibration scripts belong to other writers, so callers must be admins (read_calibration_script checks the role).

opportunity.ts, strategy-card.ts

HelperDescription
_listOpportunityCards(userId, filters). The feed query behind listOpportunityCards, with the feed's watch, person, company and pursuit includes. Filters: status (new, reviewed, archived, promoted), watchId, kind, scriptId, query (case-insensitive on title, summary or source title) and limit. Behind the chat's list_opportunity_cards.
_getOpportunityCard(userId, isAdmin, id). Returns one card with the same includes. Another user's card reads as not found. Behind get_opportunity_card.
_getStrategyCardDetail(userId, isAdmin, strategyCardId). The pursuit panel's read: the card, its person, company and script links, and every action item with its detail. Behind getStrategyCardDetail (the pursuit panel) and the Pursuits chat's get_pursuit.

videos.ts, video-model-reference-assets.ts

HelperDescription
getVideoWorkspaceAssetsForUser{userId, scriptUploadId, readOnly?}. Returns the data for the "Required Series assets" panel, owner-checked, including pendingLocations: the script's scene rooms with no plate yet ({locationKey, label}). The panel lists each as "made when the video renders" (the flag reads X ready · Y missing) and offers no generate action. A pending room does not hold the panel's readiness back, but a script with no plate and no known room is still not ready. Called by getVideoWorkspaceAssets (the panel's server fn) and by the chat's get_series_video_assets; both pass readOnly, so opening the panel never makes a plate.
loadVideoModelReferenceCatalog(scriptUploadId, { readOnly }) / loadVideoSeriesAssetReadiness(scriptUploadId, { readOnly })VideoModelReferenceCatalogOptions.readOnly reports the location plates that exist and leaves out a missing one. Without it, the catalog load runs ensureStoryboardLocationsForScript and generates every missing plate, which is how the paid paths (the approval quote and the render workers) load it. loadVideoSeriesAssetReadiness (videos.ts) also returns pendingLocations from listScriptLocationsAwaitingPlates.
listScriptLocationsAwaitingPlates(scriptUploadId, plated)Lists the scene-heading rooms the render will make plates for, without making them. Reads only the stored scriptUpload.parsedDialogues (no parse, no writes), so a script with no stored parse lists nothing. A room counts as plated when it matches a plated key exactly or with INT/EXT relaxed, the same match generateAllLocations uses. Camera-ladder rungs are not listed: a room's rungs are made with its master plate.

The same readOnly flag was added to existing reads the chat tools reuse: getPitchDeckStoryboardBoardsForUser and inspectPitchDeckVideoModelPreflightSafe (pitch-deck.ts), inspectPitchDeckVideoModelAssets (pitch-deck-video-model-preflight.ts) and getTrailerStoryboardBoardsForUser (trailer.ts) report missing plates instead of generating them. getAudioTimelineStateForUser (marketing-audio-timeline.ts) shows a legacy timeline migrated without persisting it, since a chat read runs every turn and would race the render workers that write the same manifest.

A read-only inspectPitchDeckVideoModelAssets also passes the script's pending rooms to buildPitchDeckVideoModelAssetPreflight (pendingLocations). A chapter location that resolves only against a room the render will make then reads canonical_location status will_generate ("Made when the video renders…") instead of missing, and does not block plan approval. The approval quote is what makes that plate. The quote and render paths pass no pending set, so a plate they failed to make still reads missing and blocks. A chapter with no location still reads missing.

product-video-plans.ts / product-video-plans-fns.ts

PLAN A PRODUCT VIDEO FROM THE FEATURE MAP (P-101). One core (server/product-video-plans.ts) and one queued run (product-video-plan-worker.ts, budgets in product-video-plan-budget.ts): a queued planner writes capture beats for one feature of the project's feature map, or for a named release and its features, into an ordinary capture-only draft trailer — each beat names its capture, its kept stretch, a narration line, whether it keeps its own sound, and its depth. Free: nothing is charged, reserved or rendered. The run answers at once (202, or the live run an equal body already started with reused: true) and is polled until ready, when it names the draft (trailerJobId, the review link, the conversation bound to it), nextStep: approve_trailer_plan and its live plan check. A strict body: an unknown key is 400 validation_failed naming it; an unknown featureId is 400 with the feature map in details.features; a planned feature is 400 (details.availability); a story project is 409 project_not_product; no shipped feature with a recording of its own yet is 409 product_video_not_ready in the readiness step's own sentence (details.next its verb: the project's first readiness step not done, connect_app first), and a feature with no recording of its own is 409 product_video_not_ready naming it (details.recordedFeatures lists what can be planned) — both read through readProductVideoPlanningReadiness (server/product-readiness.ts, the Overview's readiness read); no eligible capture in the shape is 409 product_video_no_captures (details.byAspect); a run of another project is 404 product_video_plan_not_found; the 31st run in a UTC day is 403 usage_limit_reached. Approval is the ordinary approve_trailer_plan, gated inside approveTrailerPlanForUser (trailer-reference-api.ts) by assertProductVideoPlanCovered: a blocked plan check (409 trailer_state_invalid, details.refusal.code: product_video_plan_blocked) names each blocking finding with its fix and reserves nothing. Story API POST|GET /story-projects/{projectId}/product-video-plans, GET /story-projects/{projectId}/product-video-plans/{runId}, GET /trailers/{jobId}/product-plan-coverage (story:write for the plan; story:read for the reads); MCP plan_product_video, list_product_video_plans, get_product_video_plan, get_product_video_plan_coverage; CLI product-video plan|runs|run|coverage, then trailer approve-plan; the project Videos page's "Plan a video from your feature map" planner (registered only when the project makes capture videos) and the draft's "Plan check" card above Approve (components/writing-lab/trailer/product-plan-coverage-card.tsx); Slug mounts the same four tools in the project's chats. The planner row runs on its own product_video_planning sidecar (Feature TRAILER, its own binding, otherwise the chat's provider).

Function / helperDescription
startProductVideoPlanFnCalls startProductVideoPlanForUser. Starts one run (or joins the live run an equal body already started) and answers {run, reused, note}. Input: {projectId, body}.
listProductVideoPlansFnCalls listProductVideoPlansForUser. The project's runs (newest first), the feature map to plan from (each feature's availability) and the eligible capture count per shape. Input: {projectId}.
getProductVideoPlanFnCalls getProductVideoPlanForUser. One run: its status, and once ready its draft trailer, review link, nextStep and its live plan check. Input: {projectId, runId}.
getProductVideoPlanningFnCalls readProductVideoPlanningReadiness. What can be planned now, from the project's readiness: recordedFeatures (shipped features with a recording of their own) and block — while none has one, the first readiness step not done with its sentence, its button and its verb. The Videos page reads it with the page (getProjectVideoLibrary carries it as planning) and again on window focus. Input: {projectId}. Null for a story project.
getProductVideoPlanCoverageFnCalls getProductVideoPlanCoverageForUser. One planned draft's plan check — the scope's features with the beats that cover each, and every finding with its fix. A trailer not planned from a feature map answers coverage: null with a note. Input: {jobId}.

Exact-run one-time-code routing

app-connections-fns.ts keeps the owner's current connectionId + testId + askedAt checks. Recording answers keep the existing wire captureId + askedAt fields. Both relay through private CaptureRunBinding rows rather than process memory. Recording discovery lists only active bindings for the owned project. The runner GET embeds the expected wire run ID in the signed path (/v1/one-time-code/<encoded run ID>); another run's pause returns null, and the old unscoped GET is absent. POST retains the exact run and question timestamp. No API, CLI, MCP or Slug code entry capability is added. Web needs only its existing runner authentication configuration, never an opening key.

Running Test sign-in browser preview

app-connection-test-preview.ts requires the product's owner and exact current connection/test. getAppConnectionTestPreviewForUser reads signed metadata without taking a picture; CLI, MCP and Slug use its explicit metadata allowlist. A replaced test is app_connection_test_changed (409). Missing or unreachable browser state does not invent an image or sequence.

getAppConnectionTestPreviewFrameForUser is used only by the native owner-session proxy and direct owner-key Story API JPEG adapter; OAuth receives metadata only. It verifies the durable run lease and current configured target before and after the signed read. The private answer authenticates the image together with connection/test, phase, sequence and times. JPEG responses are private/no-store, at most 1 MiB, expire after ten seconds, and never appear in tool results. getProjectAppConnectionTestPreviewFn supplies the native dialog's metadata/current test and existing scoped one-time-code question. listProjectAppConnectionsFn.canPreviewTest comes from owner/product access, independently of sealing configuration or final-picture permissions.

The runner observes the actual login and session-check pages without creating a browser or controlling app state. Explicit image reads coalesce and capture at most every two seconds. Page changes, learned secret values, test completion and expiry invalidate cached bytes. Native masks cover inspectable editable controls, configured sensitive selectors and DOM echoes of already-available secret/session/header values; known uninspectable sensitive surfaces or uncertainty withhold the preview. No arbitrary image/OCR or unknown closed-shadow discovery is claimed. Existing authentication, final-picture handling, budgets and secure code-entry gates remain separate.