Task-oriented patterns for common agent workflows. Each recipe is self-contained: it describes the problem, lists the steps, and includes working code.
Shared step: reach a table read (Bible, coverage gate, cast)
A table read is not a plan you can jump to. POST /story-projects/{projectId}/story-plans answers 409 project_precondition_failed until the project's Series Bible is written and has passed its coverage checkpoints (project.workspaceGate.canPlan), and POST /story-jobs answers 409 cast_precondition_failed until every Series Bible character — plus NARRATOR for a narrated read — has a voice pinned on the project's cast canon. Run this once after the sources are READY, before you plan:
// Needs the api(), pollUntil() and randomUUID helpers from Recipe 1 (or your own equivalents). async function prepareTableRead(projectId, instructions) { const post = (path, body) => api(path, { method: 'POST', headers: { 'Idempotency-Key': randomUUID(), 'Content-Type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body), }) // 1. Write the Series Bible from the sources (free). A second POST while a run is being // written returns that run (alreadyRunning: true), never a second one. await post(`/story-projects/${projectId}/series-bible`, instructions ? { instructions } : {}) const { document: bible, generation } = await pollUntil( () => api(`/story-projects/${projectId}/series-bible`), (r) => r.generation?.status !== 'generating', 5000 ) if (generation?.status === 'failed') throw new Error(`series bible: ${generation.error}`) // 2. Pass every coverage checkpoint the gate waits on (7.0+ each; a complete report under // the bar is never treated as passed). A one-off Bible has no coverage stage at all. async function passCoverage(phase) { await post(`/story-projects/${projectId}/series-bible/coverage`, { phase }) const { coverage } = await pollUntil( () => api(`/story-projects/${projectId}/series-bible/coverage?phase=${phase}`), (r) => ['complete', 'failed'].includes(r.coverage.status), 5000 ) if (coverage.status === 'failed') throw new Error(`coverage: ${coverage.failureMessage}`) if (!coverage.gatePassed) { throw new Error(`${phase} coverage scored ${coverage.overallScore} under ${coverage.gateThreshold}: ${coverage.report.priorityFixes.join('; ')}`) } } while (true) { const { project } = await api(`/story-projects/${projectId}`) const { workspaceGate } = project if (workspaceGate.canPlan) break if (workspaceGate.stage === 'premise') throw new Error(`Series Bible incomplete: ${workspaceGate.missingFields.join(', ')}`) // Premise coverage passed and the Bible has no episode map: save one, then plan. if (workspaceGate.stage === 'planning') throw new Error('The Bible has no episode map yet: save one on the Bible, then plan') await passCoverage(workspaceGate.stage === 'full_coverage' ? 'full' : 'premise') } // 3. Cast it: a read performs a cast, never makes one. A saved voice is free to reuse; on a // fresh account design one per Bible character (price first, then confirmed: true) and keep // candidate 1 — choosing is free, pins it on the canon and saves it to your library. const pin = (character, voice) => api(`/story-projects/${projectId}/cast-voice`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ character, voiceId: voice.voiceId, voiceName: voice.voiceName ?? voice.name, provider: voice.provider }), }) async function designAndKeepVoice(character) { const path = `/story-projects/${projectId}/cast-voice/design` const quote = await post(path, { character }) // the price: draws nothing, charges nothing console.log(`${character}'s voice costs ${quote.credits} credits: ${quote.note}`) const design = await post(path, { character, confirmed: true }) const { voice } = await post(`/story-projects/${projectId}/cast-voice/choose`, { designId: design.designId, candidate: 1 }) return voice } const { voices } = await api('/voices') const savedVoice = voices[0] let narratorVoice = savedVoice for (const character of bible.content.characters) { if (savedVoice) { await pin(character.name, savedVoice) } else { const designed = await designAndKeepVoice(character.name) // choose already pinned it narratorVoice ??= designed } } await pin('NARRATOR', narratorVoice) return bible }
Recipes 1 and 2 call it between the source and the plan; Recipe 9 exposes the same three steps as agent tools. Keep its order — writing the Bible, passing coverage, pinning the cast — and a new project reaches a table read instead of a 409 at the plan step.
Recipe 1: Turn an RSS feed item into a podcast table read
Scenario: Your agent monitors an RSS feed and prepares a shareable audio experience for each new item, pausing for exact human approval of every table-read plan.
Steps
- Fetch the RSS feed, parse item links.
- For each new item URL, create a project and attach it as a
urlsource. - Poll the source until
READY. - Prepare the project for a table read: write the Series Bible, pass its coverage gate, and pin a voice for every character (the shared
prepareTableReadstep above). Skipping this meets a 409 at the plan and the job. - Create a plan with
mode: "podcast". - Poll to
REQUIRES_APPROVAL, show the exact plan to the user, and approve only after they explicitly accept it. - Create a job and poll until
READY. - Store
manifest.audio.liveUrlin your database alongside the RSS item.
Code
import { randomUUID } from 'node:crypto' const BASE = 'https://sleeperhit.studio/api/v1' const KEY = process.env.SLEEPERHIT_API_KEY async function api(path, options = {}) { const res = await fetch(`${BASE}${path}`, { ...options, headers: { Authorization: `Bearer ${KEY}`, ...options.headers, }, }) if (!res.ok) { const body = await res.json().catch(() => ({})) throw Object.assign(new Error(body?.error?.message ?? `HTTP ${res.status}`), { code: body?.error?.code, status: res.status, }) } return res.json() } async function pollUntil(fn, isTerminal, intervalMs = 4000) { while (true) { const result = await fn() if (isTerminal(result)) return result await new Promise((r) => setTimeout(r, intervalMs)) } } export async function rssItemToTableRead(articleUrl, articleTitle, confirmPlan) { // 1. Create project const { project } = await api('/story-projects', { method: 'POST', headers: { 'Idempotency-Key': randomUUID(), 'Content-Type': 'application/json' }, body: JSON.stringify({ name: articleTitle, description: articleUrl }), }) // 2. Attach URL source const { source: pendingSource } = await api( `/story-projects/${project.id}/sources`, { method: 'POST', headers: { 'Idempotency-Key': randomUUID(), 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'url', uri: articleUrl, label: 'article' }), } ) // 3. Poll source to READY const source = await pollUntil( () => api(`/story-projects/${project.id}/sources/${pendingSource.id}`).then((r) => r.source), (s) => s.status === 'READY' || s.status === 'FAILED' ) if (source.status === 'FAILED') throw new Error(`source failed: ${source.failureCode}`) // 4. Prepare for a table read: Bible, coverage gate, then a pinned voice for every character // (the shared prepareTableRead step). The plan is 409 until the gate opens and the job is // 409 until the cast is complete. await prepareTableRead( project.id, 'A podcast table read of the attached article: one host and one guest, conversational.' ) // 5. Generate plan with podcast mode const { plan: pendingPlan } = await api( `/story-projects/${project.id}/story-plans`, { method: 'POST', headers: { 'Idempotency-Key': randomUUID(), 'Content-Type': 'application/json' }, body: JSON.stringify({ target: { audience: 'general listeners', objective: 'summarize and discuss the article', outcome: 'listeners understand the key points and want to read more', tone: 'conversational, engaging', }, artifactRequests: [{ type: 'table_read', mode: 'podcast' }], sourceIds: [source.id], }), } ) // 6. Stop for exact human review; confirmPlan must collect real user input. const reviewPlan = await pollUntil( () => api(`/story-plans/${pendingPlan.id}`).then((r) => r.plan), (p) => ['REQUIRES_APPROVAL', 'FAILED', 'REJECTED'].includes(p.status) ) if (reviewPlan.status !== 'REQUIRES_APPROVAL') throw new Error(`plan failed: ${reviewPlan.failureCode}`) if (typeof confirmPlan !== 'function' || !(await confirmPlan(reviewPlan))) { throw new Error('The user did not approve this plan.') } const { plan } = await api(`/story-plans/${pendingPlan.id}/approve`, { method: 'POST', headers: { 'Idempotency-Key': randomUUID(), 'Content-Type': 'application/json' }, body: JSON.stringify({ userConfirmed: true }), }) // 7. Create job const { job: pendingJob } = await api('/story-jobs', { method: 'POST', headers: { 'Idempotency-Key': randomUUID(), 'Content-Type': 'application/json' }, body: JSON.stringify({ storyPlanId: plan.id }), }) // 8. Poll job to READY const job = await pollUntil( () => api(`/story-jobs/${pendingJob.id}`).then((r) => r.job), (j) => ['READY', 'PARTIAL', 'FAILED', 'CANCELED'].includes(j.status), 6000 ) if (job.status === 'FAILED') throw new Error(`job failed: ${job.failureCode}`) const artifact = job.artifacts.find((a) => a.type === 'table_read' && a.status === 'READY') return { projectId: project.id, planId: plan.id, jobId: job.id, audioUrl: artifact.manifest.audio.liveUrl, theaterUrl: artifact.manifest.theaterUrl, expiresAt: artifact.manifest.expiresAt, } }
Recipe 2: Ingest a PDF research report and produce a documentary table read
Scenario: An agent is given a publicly accessible PDF (white paper, annual report, research study) and must produce a documentary-style story experience.
Notes
- PDF sources extract asynchronously — always poll until
READY. metadata.pageCountis available on the source after extraction.- Documentary mode works well for evidence-heavy content.
- Wait for
digestStatus: READYbefore planning to get higher-quality source grounding. - A plan for a new project is 409
project_precondition_faileduntil the Series Bible is written and has passed coverage, and its table read is 409cast_precondition_faileduntil every character has a pinned voice — call the sharedprepareTableReadstep between the source and the plan.
export async function pdfToDocumentary(pdfUrl, label, confirmPlan) { const { project } = await api('/story-projects', { method: 'POST', headers: { 'Idempotency-Key': randomUUID(), 'Content-Type': 'application/json' }, body: JSON.stringify({ name: label }), }) // Submit PDF source const { source: pending } = await api( `/story-projects/${project.id}/sources`, { method: 'POST', headers: { 'Idempotency-Key': randomUUID(), 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'pdf', uri: pdfUrl, label }), } ) // Wait for extraction const extracted = await pollUntil( () => api(`/story-projects/${project.id}/sources/${pending.id}`).then((r) => r.source), (s) => ['READY', 'FAILED'].includes(s.status), 3000 ) if (extracted.status === 'FAILED') throw new Error(`PDF extract failed: ${extracted.failureCode}`) console.log(`Extracted ${extracted.metadata?.pageCount ?? '?'} pages, ~${extracted.tokenEstimate} tokens`) // Wait for digest (improves planning quality) const digested = await pollUntil( () => api(`/story-projects/${project.id}/sources/${pending.id}`).then((r) => r.source), (s) => ['READY', 'FAILED', 'SKIPPED'].includes(s.digestStatus), 4000 ) if (digested.digestStatus === 'READY') { console.log('Thesis:', digested.digest.thesis) } // Bible, coverage gate, then a pinned voice for every character (the shared prepareTableRead // step): without it the plan is 409 and the table-read job is 409. await prepareTableRead( project.id, 'A documentary table read of the attached PDF research report: authoritative and evidence-forward.' ) // Plan with documentary mode, explicit style constraints const { plan: pendingPlan } = await api( `/story-projects/${project.id}/story-plans`, { method: 'POST', headers: { 'Idempotency-Key': randomUUID(), 'Content-Type': 'application/json' }, body: JSON.stringify({ target: { audience: 'informed professionals', objective: 'distill key findings from the research', outcome: 'audience understands the core thesis and supporting evidence', tone: 'authoritative, measured, evidence-forward', }, artifactRequests: [{ type: 'table_read', mode: 'documentary', durationSeconds: 120 }], styleConstraints: { musicPolicy: 'Ambient underscore, no lyrics', brandSafety: ['do not over-claim uncertain findings'], }, sourceIds: [pending.id], }), } ) const reviewPlan = await pollUntil( () => api(`/story-plans/${pendingPlan.id}`).then((r) => r.plan), (p) => ['REQUIRES_APPROVAL', 'FAILED', 'REJECTED'].includes(p.status) ) if (reviewPlan.status !== 'REQUIRES_APPROVAL') throw new Error(`plan ${reviewPlan.status}`) if (typeof confirmPlan !== 'function' || !(await confirmPlan(reviewPlan))) { throw new Error('The user did not approve this plan.') } const { plan } = await api(`/story-plans/${pendingPlan.id}/approve`, { method: 'POST', headers: { 'Idempotency-Key': randomUUID(), 'Content-Type': 'application/json' }, body: JSON.stringify({ userConfirmed: true }), }) const { job: pendingJob } = await api('/story-jobs', { method: 'POST', headers: { 'Idempotency-Key': randomUUID(), 'Content-Type': 'application/json' }, body: JSON.stringify({ storyPlanId: plan.id }), }) const job = await pollUntil( () => api(`/story-jobs/${pendingJob.id}`).then((r) => r.job), (j) => ['READY', 'PARTIAL', 'FAILED', 'CANCELED'].includes(j.status), 8000 ) const artifact = job.artifacts.find((a) => a.type === 'table_read' && a.status === 'READY') return artifact.manifest }
Recipe 3: Refine the tone or recast a host, then finalize an MP3 for download
Scenario: A table read artifact is live and the operator wants to adjust the cast (rename the host, drop the narrator, make a character warmer) and then produce a durable MP3 for email delivery or archival — all without changing the share URLs.
Steps
- Have an existing
table_readartifact that isREADYand itsid. - POST to
/artifacts/{artifactId}/refinewith the instruction andscope: "screenplay". Withoutconfirmedit answers the quote and reserves nothing; send the same body withconfirmed: trueto create the job. Poll the returned storyJob untilREADY. - Open the theater/audio share and complete a recorded Pipecat/Daily live-read session if one has not been recorded yet.
- POST to
/artifacts/{artifactId}/finalizewith{ mode: "audio" }. Pollmanifest.audio.finalize.statusuntilcomplete. - Read
manifest.audio.recordingUrlfor the durable MP3.
The original share URLs (theaterUrl, theaterFullscreenUrl, audio.liveUrl) are unchanged throughout.
import { randomUUID } from 'node:crypto' const BASE = 'https://sleeperhit.studio/api/v1' const KEY = process.env.SLEEPERHIT_API_KEY async function api(path, options = {}) { const res = await fetch(`${BASE}${path}`, { ...options, headers: { Authorization: `Bearer ${KEY}`, ...options.headers }, }) if (!res.ok) { const body = await res.json().catch(() => ({})) throw Object.assign(new Error(body?.error?.message ?? `HTTP ${res.status}`), { code: body?.error?.code }) } return res.json() } async function pollUntil(fn, isTerminal, intervalMs = 5000) { while (true) { const result = await fn() if (isTerminal(result)) return result await new Promise((r) => setTimeout(r, intervalMs)) } } export async function refineThenFinalizeAudio(artifactId, instruction) { // Step 1 — Refine: price first, then spend. The quote reserves nothing. const refineBody = { instruction, scope: 'screenplay' } const quote = await api(`/artifacts/${artifactId}/refine`, { method: 'POST', headers: { 'Idempotency-Key': randomUUID(), 'Content-Type': 'application/json' }, body: JSON.stringify(refineBody), }) if (quote.quotedOnly !== true) { throw new Error('Expected the refine price without confirmed; got a job') } if (!quote.sufficient) { throw new Error(`Not enough credits: ${quote.note}`) } // Say go with the same body plus confirmed: true — this reserves the quote and makes the job. const { storyJob } = await api(`/artifacts/${artifactId}/refine`, { method: 'POST', headers: { 'Idempotency-Key': randomUUID(), 'Content-Type': 'application/json' }, body: JSON.stringify({ ...refineBody, confirmed: true }), }) // Poll the refine job const completedRefineJob = await pollUntil( () => api(`/story-jobs/${storyJob.id}`).then((r) => r.job), (j) => ['READY', 'FAILED', 'CANCELED'].includes(j.status), 6000 ) if (completedRefineJob.status !== 'READY') { throw new Error(`refine job ${completedRefineJob.status}: ${completedRefineJob.failureCode}`) } // Confirm version incremented (optional — informational only) const { artifact } = await api(`/artifacts/${artifactId}`) console.log(`Artifact now at v${artifact.currentVersion} (refineCount: ${artifact.refineCount})`) // Step 2 — Finalize: durable MP3, rendered from the read itself (free) const { finalize } = await api(`/artifacts/${artifactId}/finalize`, { method: 'POST', headers: { 'Idempotency-Key': randomUUID(), 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: 'audio' }), }) console.log('Finalize started, status:', finalize.status) // 'queued' (or 'complete' when it already exists) // Poll manifest.audio.finalize.status until complete or terminal const finalizedArtifact = await pollUntil( () => api(`/artifacts/${artifactId}`).then((r) => r.artifact), (a) => { const s = a.manifest?.audio?.finalize?.status return s === 'complete' || s === 'failed' }, 7000 ) if (finalizedArtifact.manifest.audio.finalize.status === 'failed') { throw new Error(finalizedArtifact.manifest.audio.finalize.error ?? 'audio finalize failed') } return { recordingUrl: finalizedArtifact.manifest.audio.recordingUrl, recordingDurationMs: finalizedArtifact.manifest.audio.recordingDurationMs, // The original share URLs are still valid and unchanged: theaterUrl: finalizedArtifact.manifest.theaterUrl, audioLiveUrl: finalizedArtifact.manifest.audio.liveUrl, } } // Usage: const result = await refineThenFinalizeAudio( 'cma...', 'rename the host to Dana and drop the narrator; make the skeptic warmer' ) console.log('MP3:', result.recordingUrl) console.log('Duration:', result.recordingDurationMs, 'ms')
Note on plan-level refinement: If you need to change the narrative direction substantially (shift audience, add sources, change the thesis), create a new plan against the same project. The old plan transitions to SUPERSEDED and a new artifact is produced. Use the artifact-level refine endpoint when the plan is good but the execution needs adjustment.
Recipe 4: Embed the fullscreen theater in your own page
Scenario: You have an artifact manifest and want to embed the live table read in an <iframe> on your website. The theaterFullscreenUrl is designed for this — it renders the stage without attribution chrome.
HTML embed
<iframe src="https://sleeperhit.studio/share/table-read/YOUR_SHARE_TOKEN?view=fullscreen" width="100%" style="aspect-ratio: 16/9; border: none; border-radius: 8px;" allow="autoplay; microphone" allowfullscreen ></iframe>
Replace the URL with manifest.theaterFullscreenUrl from the artifact.
Dynamic embed (React/JS)
function TableReadEmbed({ manifest }) { if (!manifest || !manifest.theaterFullscreenUrl) return null const isExpired = manifest.expiresAt && new Date(manifest.expiresAt) < new Date() if (isExpired) return <p>This table read has expired.</p> return ( <iframe src={manifest.theaterFullscreenUrl} style={{ width: '100%', aspectRatio: '16/9', border: 'none' }} allow="autoplay; microphone" allowFullScreen /> ) }
Important: The shareToken in the URL authorizes the embed — it is not your API key. End-users can view the embed without any API credentials. The token expires at manifest.expiresAt.
Recipe 5: Trigger and poll the MP4 render
Scenario: After a table read artifact is READY, you need a durable MP4 file (for download, email delivery, or video platform upload).
The MP4 render is never produced by default. It must be explicitly requested, and it is free.
You can trigger the MP4 in two equivalent ways:
POST /artifacts/{artifactId}/render-video(no body, scopeartifact:publish)POST /artifacts/{artifactId}/finalizewith{ "mode": "video" }(scopeartifact:publish, replay-safe)
Steps
- Confirm the artifact is
READYand has atableReadShareLinkId(the render endpoint requires a live share link). - POST to
/artifacts/{artifactId}/render-video(or/finalizewithmode:"video") with anIdempotency-Key. - Poll
GET /artifacts/{artifactId}untilmanifest.video.statusiscomplete. - Read
manifest.video.urlfor the durable MP4.
export async function triggerAndPollMp4(artifactId) { // Trigger const { render } = await api(`/artifacts/${artifactId}/render-video`, { method: 'POST', headers: { 'Idempotency-Key': randomUUID() }, }) console.log('Render requested:', render) // Poll until complete const artifact = await pollUntil( () => api(`/artifacts/${artifactId}`).then((r) => r.artifact), (a) => { const videoStatus = a.manifest?.video?.status return videoStatus === 'complete' || videoStatus === 'failed' || videoStatus === 'stale' // 'failed' or 'stale' here would be unexpected — log and handle }, 8000 ) if (artifact.manifest?.video?.status !== 'complete') { throw new Error(`unexpected video status: ${artifact.manifest?.video?.status}`) } return artifact.manifest.video.url }
Recipe 6: Add adaptive soundtrack and timed SFX to a live read
Scenario: A table read is already live, but you want the playback to feel more produced without regenerating the artifact or changing share URLs.
Steps
- Confirm the artifact is
READY. - POST to
/artifacts/{artifactId}/musicto generate adaptive per-scene soundtrack. - Poll
GET /artifacts/{artifactId}/musicuntilmusic.statusisready. - Optionally POST to
/artifacts/{artifactId}/musicwithscope,sceneIndex,entryIndex, andmodeto tune, mute, merge, or replace soundtrack direction. - POST to
/artifacts/{artifactId}/sfxto add timed cues. - GET
/artifacts/{artifactId}/sfxto list the cue bank.
export async function addMusicAndSfx(artifactId) { await api(`/artifacts/${artifactId}/music`, { method: 'POST', headers: { 'Idempotency-Key': randomUUID(), 'Content-Type': 'application/json' }, body: JSON.stringify({ musicCoveragePercent: 0.35 }), }) const music = await pollUntil( () => api(`/artifacts/${artifactId}/music`).then((r) => r.music), (m) => m.status === 'ready', 5000 ) await api(`/artifacts/${artifactId}/music`, { method: 'POST', headers: { 'Idempotency-Key': randomUUID(), 'Content-Type': 'application/json' }, body: JSON.stringify({ scope: 'scene', sceneIndex: 1, prompt: 'heavier bass pulse under the reveal', weight: 0.85, }), }) await api(`/artifacts/${artifactId}/music`, { method: 'POST', headers: { 'Idempotency-Key': randomUUID(), 'Content-Type': 'application/json' }, body: JSON.stringify({ scope: 'screenplay', mode: 'replace', prompt: 'industrial electronic pulse, distorted analog synth', summary: 'Reznor/Ross-style electronic palette', }), }) const { cue } = await api(`/artifacts/${artifactId}/sfx`, { method: 'POST', headers: { 'Idempotency-Key': randomUUID(), 'Content-Type': 'application/json' }, body: JSON.stringify({ op: 'add', entryIndex: 12, label: 'door knock', prompt: 'three crisp knocks on a wooden door', triggerOffset: 'before', offsetMs: -150, generatedDurationS: 1.2, enabled: true, }), }) const { sfx } = await api(`/artifacts/${artifactId}/sfx`) return { music, cue, cues: sfx.cues } }
Music and SFX mutations require artifact:publish; the read endpoints require artifact:read.
Recipe 7: Attach multiple source types to a single project
Scenario: You have a mix of sources — a press release in text, a research PDF, and a landing page URL — and want all three to inform the plan.
The key is to submit all sources first, then wait for all async ones to be READY before creating the plan. The planner uses sourceIds you pass explicitly, so you control which sources are included.
export async function multiSourceProject(sources) { // sources: [{ type, content?, uri?, label }] const { project } = await api('/story-projects', { method: 'POST', headers: { 'Idempotency-Key': randomUUID(), 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Multi-source project' }), }) // Submit all sources concurrently const submitted = await Promise.all( sources.map((s) => api(`/story-projects/${project.id}/sources`, { method: 'POST', headers: { 'Idempotency-Key': randomUUID(), 'Content-Type': 'application/json' }, body: JSON.stringify(s), }).then((r) => r.source) ) ) // Poll async sources (url, pdf) until terminal; inline sources are already READY const ready = await Promise.all( submitted.map((source) => { if (source.status === 'READY') return Promise.resolve(source) return pollUntil( () => api(`/story-projects/${project.id}/sources/${source.id}`).then((r) => r.source), (s) => ['READY', 'FAILED'].includes(s.status), 3000 ) }) ) const failed = ready.filter((s) => s.status === 'FAILED') if (failed.length > 0) { console.warn( 'Some sources failed:', failed.map((s) => `${s.label}: ${s.failureCode}`).join(', ') ) } const goodSourceIds = ready.filter((s) => s.status === 'READY').map((s) => s.id) return { project, sourceIds: goodSourceIds } }
Recipe 8: Handle credit shortfalls gracefully
Scenario: Your agent runs in production and must handle insufficient_credits without crashing.
Best practice: check the balance and compare against the plan quote before creating a job.
export async function createJobWithCreditCheck(planId) { // Read current plan for the quote const { plan } = await api(`/story-plans/${planId}`) const required = plan.quote?.total ?? 0 // Read current balance const { credits } = await api('/credits') if (credits.balance < required) { return { ok: false, reason: 'insufficient_credits', required, available: credits.balance, deficit: required - credits.balance, } } // Proceed with job creation try { const { job } = await api('/story-jobs', { method: 'POST', headers: { 'Idempotency-Key': randomUUID(), 'Content-Type': 'application/json' }, body: JSON.stringify({ storyPlanId: planId }), }) return { ok: true, job } } catch (err) { if (err.code === 'insufficient_credits') { // Race condition: balance dropped between check and reserve return { ok: false, reason: 'insufficient_credits', required } } throw err } }
Recipe 9: Add the Story API to your own agent workflow
Scenario: You have an existing LLM agent (e.g. built with the OpenAI Assistants API, LangChain, or Claude's tool use) and want to add story generation as a tool call.
Tool definition (Claude tool-use style)
const storyTools = [ { name: 'create_story_project', description: 'Create a new story project workspace. Returns the project ID to use in subsequent calls.', input_schema: { type: 'object', required: ['name'], properties: { name: { type: 'string', description: 'Short name for the project' }, description: { type: 'string', description: 'Optional context' }, }, }, }, { name: 'attach_source', description: 'Attach a source to a project. For URL and PDF sources, returns status PENDING — use poll_source to wait for READY.', input_schema: { type: 'object', required: ['projectId', 'type'], properties: { projectId: { type: 'string' }, type: { type: 'string', enum: ['text', 'markdown', 'url', 'pdf'] }, content: { type: 'string', description: 'For text/markdown sources' }, uri: { type: 'string', description: 'For url/pdf sources — must be publicly accessible' }, label: { type: 'string' }, }, }, }, { name: 'write_series_bible', description: 'Write the Series Bible from the attached sources (free, async). Poll GET /story-projects/{projectId}/series-bible until generation.status is ready or failed. A plan is 409 project_precondition_failed until the Bible has passed coverage.', input_schema: { type: 'object', required: ['projectId'], properties: { projectId: { type: 'string' }, instructions: { type: 'string', description: 'Optional direction for the Bible run' }, }, }, }, { name: 'pass_series_bible_coverage', description: 'Queue one Series Bible coverage checkpoint, then poll GET /story-projects/{projectId}/series-bible/coverage?phase=... until coverage.status is complete or failed and read coverage.gatePassed. project.workspaceGate names the phase to run next (premise, then full).', input_schema: { type: 'object', required: ['projectId', 'phase'], properties: { projectId: { type: 'string' }, phase: { type: 'string', enum: ['premise', 'full'] }, }, }, }, { name: 'pin_cast_voice', description: 'Pin one saved voice on the project cast canon. A table read needs a pinned voice for every Series Bible character and NARRATOR: without a complete cast the job is 409 cast_precondition_failed before any audio.', input_schema: { type: 'object', required: ['projectId', 'character', 'voiceId'], properties: { projectId: { type: 'string' }, character: { type: 'string' }, voiceId: { type: 'string', description: 'A saved voice id from GET /voices' }, voiceName: { type: 'string' }, provider: { type: 'string' }, }, }, }, { name: 'generate_story_plan', description: 'Generate a StoryPlan from sources. Answers 409 project_precondition_failed until the Series Bible has passed its coverage gate: call write_series_bible and pass_series_bible_coverage first. Table-read plans always require exact human review and approval; autoApprove is only eligible for supported non-table artifacts. Returns planId to poll.', input_schema: { type: 'object', required: ['projectId', 'audience', 'objective', 'outcome', 'artifactType'], properties: { projectId: { type: 'string' }, sourceIds: { type: 'array', items: { type: 'string' } }, audience: { type: 'string' }, objective: { type: 'string' }, outcome: { type: 'string' }, tone: { type: 'string' }, artifactType: { type: 'string', enum: ['table_read', 'pitch_deck', 'trailer'] }, mode: { type: 'string', enum: ['documentary', 'podcast', 'drama'] }, autoApprove: { type: 'boolean' }, }, }, }, { name: 'create_story_job', description: 'Run artifact generation against an APPROVED plan. Reserves Studio Credits. Returns jobId to poll.', input_schema: { type: 'object', required: ['storyPlanId'], properties: { storyPlanId: { type: 'string' } }, }, }, { name: 'get_story_job', description: 'Poll a story job for status and artifact manifest URLs.', input_schema: { type: 'object', required: ['jobId'], properties: { jobId: { type: 'string' } }, }, }, ]
Tool execution handlers
async function executeStoryTool(name, input) { switch (name) { case 'create_story_project': return api('/story-projects', { method: 'POST', headers: { 'Idempotency-Key': randomUUID(), 'Content-Type': 'application/json' }, body: JSON.stringify(input), }) case 'attach_source': return api(`/story-projects/${input.projectId}/sources`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: input.type, content: input.content, uri: input.uri, label: input.label, }), }) case 'write_series_bible': return api(`/story-projects/${input.projectId}/series-bible`, { method: 'POST', headers: { 'Idempotency-Key': randomUUID(), 'Content-Type': 'application/json' }, body: JSON.stringify({ instructions: input.instructions }), }) case 'pass_series_bible_coverage': return api(`/story-projects/${input.projectId}/series-bible/coverage`, { method: 'POST', headers: { 'Idempotency-Key': randomUUID(), 'Content-Type': 'application/json' }, body: JSON.stringify({ phase: input.phase }), }) case 'pin_cast_voice': return api(`/story-projects/${input.projectId}/cast-voice`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ character: input.character, voiceId: input.voiceId, voiceName: input.voiceName, provider: input.provider, }), }) case 'generate_story_plan': return api(`/story-projects/${input.projectId}/story-plans`, { method: 'POST', headers: { 'Idempotency-Key': randomUUID(), 'Content-Type': 'application/json' }, body: JSON.stringify({ target: { audience: input.audience, objective: input.objective, outcome: input.outcome, tone: input.tone }, artifactRequests: [{ type: input.artifactType, mode: input.mode }], sourceIds: input.sourceIds ?? [], autoApprove: input.autoApprove ?? false, }), }) case 'create_story_job': return api('/story-jobs', { method: 'POST', headers: { 'Idempotency-Key': randomUUID(), 'Content-Type': 'application/json' }, body: JSON.stringify({ storyPlanId: input.storyPlanId }), }) case 'get_story_job': return api(`/story-jobs/${input.jobId}`) default: throw new Error(`Unknown tool: ${name}`) } }
The agent's tool-call loop then drives the full Bible → coverage → cast → plan → job → artifact flow through natural-language goal specification, while this layer handles all API mechanics: call write_series_bible and poll it, run the coverage phases project.workspaceGate names, pin every Series Bible character plus NARRATOR with pin_cast_voice, then generate_story_plan.
Recipe 10: Error recovery and retry
Scenario: Your agent runs unattended overnight and must handle transient failures without human intervention.
const RETRYABLE_CODES = new Set(['rate_limited', 'internal_error']) const AUTH_FAILURE_CODES = new Set(['authentication_required', 'invalid_api_key', 'api_key_revoked', 'api_key_expired']) async function resilientApi(path, options = {}, maxAttempts = 4) { let attempt = 0 while (attempt < maxAttempts) { attempt++ try { return await api(path, options) } catch (err) { if (AUTH_FAILURE_CODES.has(err.code)) { // Non-recoverable without key rotation — surface to operator throw Object.assign(err, { fatal: true }) } if (err.code === 'rate_limited') { const retryAfter = Number(err.retryAfter ?? 10) * 1000 console.warn(`Rate limited on ${path}; waiting ${retryAfter}ms`) await new Promise((r) => setTimeout(r, retryAfter)) continue } if (err.code === 'internal_error' && attempt < maxAttempts) { // Retry with the SAME Idempotency-Key (already in options.headers) const backoff = Math.min(1000 * 2 ** attempt, 30000) console.warn(`Internal error on ${path}; attempt ${attempt}/${maxAttempts}, backoff ${backoff}ms`) await new Promise((r) => setTimeout(r, backoff)) continue } throw err } } throw new Error(`${path} failed after ${maxAttempts} attempts`) }
Key rules:
- Auth failures (
authentication_required,invalid_api_key,api_key_revoked,api_key_expired) require key rotation — do not retry automatically. - Rate limits (
rate_limited) — wait exactlyRetry-Afterseconds, then retry. - Internal errors (
internal_error) — safe to retry with the sameIdempotency-Key. Use exponential backoff. - Idempotency conflict (
idempotency_conflict) — do not retry; poll the status endpoint for the in-flight request. - Insufficient credits (
insufficient_credits) — surface the shortfall; do not retry until credits are added.
Recipe 12: Make a video of your web app
Scenario: You want a narrated video of your own product — from a fresh workspace to a planned, coverage-checked draft — driven from the public Story API alone. Each of the nine steps below names the exact method and /api/v1/... path an outside agent calls, in the order the API requires them.
Send every request to https://sleeperhit.studio/api/v1 with your bearer API key (Authorization: Bearer <key>) and Content-Type: application/json. {projectId}, {connectionId}, {captureId}, {runId} and {jobId} are ids returned by the earlier steps.
-
Create a product project. Call
POST /api/v1/story-projectswith{ "name": "My Product", "kind": "product" }—nameis required, and the call is refused without it (aproductkind workspace holds a Product Bible and is judged by the product rubric; omitted,kindfollows the account's role default, so pass it explicitly). Note the created project's id — it is{projectId}for every step below. -
Learn your feature inventory from your site. Queue the run with
POST /api/v1/story-projects/{projectId}/feature-inventory. Sleeper reads your public pages, sitemap and llms.txt (public pages only — no login, cookie or key is ever sent) and lists your features, audience, claims and pricing, each with the page it came from. It runs in the background: readGET /api/v1/story-projects/{projectId}/feature-inventoryuntil the run finishes; the result is the feature map every later step names features from. -
Fill the claims ledger. Read what your site claims with
GET /api/v1/story-projects/{projectId}/claims-ledger— each claim carries its status (declared,inferred,confirmedorrejected) and the pages behind it. Record the founder's verdict per claim withPOST /api/v1/story-projects/{projectId}/claims-ledger/{claimId}using body{ "decision": "confirm" | "reject" | "reopen" }(an optionalnoterides along;decisionis the field the call requires — onlyconfirm,rejectorreopenare accepted). Only the founder decides; a rejected claim is carried into the Product Bible's "do not claim" list when the Bible is written. -
Write the Product Bible and pass its coverage. Generate the Bible with
POST /api/v1/story-projects/{projectId}/series-bible— for aproductproject this writes the Product Bible (alternatives, positioning, call to action, do-not-claim, compliance) in the background. The coverage call refuses (Save or generate the Series Bible before requesting foundational coverage, or aComplete … before product coveragereadiness error) until that write has landed, so first pollGET /api/v1/story-projects/{projectId}/series-bibleuntil it answers with the saveddocument(any run still writing it shows undergeneration). Only then score it withPOST /api/v1/story-projects/{projectId}/series-bible/coverageusing body{ "phase": "product" }, and read the result withGET /api/v1/story-projects/{projectId}/series-bible/coverage(queryphase=product). Step 8 refuses with409 project_precondition_faileduntil the Bible is complete and product coverage has passed — do not skip this step. -
Connect the app and prove the sign-in works. Register it with
POST /api/v1/story-projects/{projectId}/app-connections(itsorigin,loginUrlandloginMethod), then save the credential it signs in with — e.g.PUT /api/v1/story-projects/{projectId}/app-connections/{connectionId}/secrets/{kind}with body{ "value": "…" }(the body is{ value }only) forusername,password,totpSeedorstorageState. Run a real sign-in withPOST /api/v1/story-projects/{projectId}/app-connections/{connectionId}/test-loginand pollGET /api/v1/story-projects/{projectId}/app-connections/{connectionId}/test-loginuntil it answersok— a missing secret queues nothing, and a failing sign-in means the recording machine could not log in either. -
Describe each feature as a tour, and check it. A tour is the JSON walkthrough a screen recording follows — what to open, where to click and type, and the moments a take is cut on. Validate one before storing it (or re-check one already stored) with
POST /api/v1/story-projects/{projectId}/tours/check— send exactly one oftour(the document) ortourId(a stored tour) — then store the passing document withPOST /api/v1/story-projects/{projectId}/toursfor the feature it walks. Fix what the check reports before recording. -
Record the captures. Start one with
POST /api/v1/story-projects/{projectId}/captures— it answers a presigned PUT URL; send the MP4 (H.264, constant 30 fps, at most 1920×1080, up to 512 MB) asvideo/mp4to that URL, then check the stored file withPOST /api/v1/story-projects/{projectId}/captures/{captureId}/finalize, which marks itreadyorrejectedwith the exact re-encode command. List what you have withGET /api/v1/story-projects/{projectId}/captures— each row names its feature and tour, and a shape with no ready capture (16:9,9:16,1:1) cannot be planned into in step 8. -
Plan the product video and read its plan coverage. Create the run with
POST /api/v1/story-projects/{projectId}/product-video-plans(free): it turns one feature of the feature map — or a named release and its 2–8 features — plus your recorded captures into an ordinarydrafttrailer whose beats each name the capture they play. Read the run withGET /api/v1/story-projects/{projectId}/product-video-plans/{runId}until its status isreadyand take itstrailerJobId. Then read the plan check withGET /api/v1/trailers/{jobId}/product-plan-coverage(use thattrailerJobIdas{jobId}): it lists what the cut covers, feature by beat, and every finding with its fix — blocking findings gate the approval. -
Pick the video destination. List where a finished video may go with
GET /api/v1/video-destinations— each row is one destination with its spec: shape and length, sound, delivery, what it must show and the checks it must pass. One whose spec fails its check is listed underunavailablewith the reason and is never guessed. Read a single destination's whole spec withGET /api/v1/video-destinations/{key}before cutting for it; a spec last checked against itssourceUrlsmore than 90 days ago reportsspecMayBeStale.
Once coverage is clear, finish the loop: approve the plan with POST /api/v1/trailers/{jobId}/plan/approve (a blocked coverage check refuses it), re-stitch or copy the master with POST /api/v1/trailers/{jobId}/video-export, and publish the watch page with POST /api/v1/trailers/{jobId}/publish. See Recipe 10 for retry rules on every call above.
Finalize to MP3: To get a durable audio-only file, use POST /api/v1/artifacts/{artifactId}/finalize with { "mode": "audio" } (free). It renders the read's full mix — its voices, music, and sound effects — into a permanent MP3, so nothing has to be recorded in a browser first. Poll manifest.audio.finalize.status until complete, then read manifest.audio.recordingUrl. See Recipe 3 for a full example combining refine + audio finalize.