Recipes
Task-oriented patterns for common agent workflows. Each recipe is self-contained: it describes the problem, lists the steps, and includes working code.
Recipe 1: Turn an RSS feed item into a podcast table read
Scenario: Your agent monitors an RSS feed and automatically generates a shareable audio experience for each new item.
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. - Create a plan with
mode: "podcast"andautoApprove: true. - Poll the plan until
APPROVED. - 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) { // 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: { '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. 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], autoApprove: true, }), } ) // 5. Poll plan to APPROVED const plan = await pollUntil( () => api(`/story-plans/${pendingPlan.id}`).then((r) => r.plan), (p) => ['APPROVED', 'FAILED', 'REJECTED'].includes(p.status) ) if (plan.status !== 'APPROVED') throw new Error(`plan failed: ${plan.failureCode}`) // 6. 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 }), }) // 7. 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.
export async function pdfToDocumentary(pdfUrl, label) { 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: { '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) } // 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], autoApprove: true, }), } ) const plan = await pollUntil( () => api(`/story-plans/${pendingPlan.id}`).then((r) => r.plan), (p) => ['APPROVED', 'FAILED', 'REJECTED'].includes(p.status) ) if (plan.status !== 'APPROVED') throw new Error(`plan ${plan.status}`) 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". 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: conversational in-place revision const { storyJob } = await api(`/artifacts/${artifactId}/refine`, { method: 'POST', headers: { 'Idempotency-Key': randomUUID(), 'Content-Type': 'application/json' }, body: JSON.stringify({ instruction, scope: 'screenplay' }), }) // 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 from the completed live-read recording 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 'needs_recording' // 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' || s === 'needs_recording' }, 7000 ) if (finalizedArtifact.manifest.audio.finalize.status === 'needs_recording') { throw new Error(finalizedArtifact.manifest.audio.finalize.error ?? 'record the live read before finalizing MP3') } 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 incurs a separate Studio Credit charge (TABLE_READ_FINALIZE_VIDEO — 12 credits).
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 music 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 music. - Poll
GET /artifacts/{artifactId}/musicuntilmusic.statusisready. - Optionally POST to
/artifacts/{artifactId}/musicwithsceneIndexto tune or mute one scene. - 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: { '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({ sceneIndex: 1, prompt: 'heavier bass pulse under the reveal', weight: 0.85, }), }) 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', }), }) 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: { '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: 'generate_story_plan', description: 'Generate a StoryPlan from sources. Pass autoApprove: true for fully automated flows. 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', 'storyboard', 'trailer', 'production_video'] }, 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 '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 plan → job → artifact flow through natural-language goal specification, while this layer handles all API mechanics.
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 11: Drive the live audio performance from your own UI
Scenario: You want to build a custom player in your own product rather than embedding the provided player pages. You have the share token from an artifact manifest.
async function startLiveRead(shareToken) { const res = await fetch('https://sleeperhit.studio/api/share/table-read/start-live', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: shareToken }), }) if (!res.ok) throw new Error(`start-live ${res.status}`) const { dailyRoomUrl } = await res.json() return dailyRoomUrl // Pass to Daily.co client SDK or your own WebRTC stack }
startLiveUrl from the manifest is the same endpoint as the theater and audio-only players use. Calling it directly gives you the raw Daily.co room URL to integrate into your own player. No API key is needed — the share token authorizes the call.
Finalize to MP3: To get a durable audio-only file, use POST /artifacts/{artifactId}/finalize with { "mode": "audio" } (6 credits). This freezes a completed Pipecat/Daily live-read recording into a permanent full-mix MP3; if no completed recording exists yet, the endpoint returns needs_recording and does not charge. Poll manifest.audio.finalize.status until complete, then read manifest.audio.recordingUrl. See Recipe 3 for a full example combining refine + audio finalize.