# Recipes

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:

```js
// 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

1. Fetch the RSS feed, parse item links.
2. For each new item URL, create a project and attach it as a `url` source.
3. Poll the source until `READY`.
4. Prepare the project for a table read: write the Series Bible, pass its coverage gate, and pin a voice for every character (the shared `prepareTableRead` step above). Skipping this meets a 409 at the plan and the job.
5. Create a plan with `mode: "podcast"`.
6. Poll to `REQUIRES_APPROVAL`, show the exact plan to the user, and approve only after they explicitly accept it.
7. Create a job and poll until `READY`.
8. Store `manifest.audio.liveUrl` in your database alongside the RSS item.

### Code

```js
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.pageCount` is available on the source after extraction.
- Documentary mode works well for evidence-heavy content.
- Wait for `digestStatus: READY` before planning to get higher-quality source grounding.
- A plan for a new project is **409 `project_precondition_failed`** until the Series Bible is written and has passed coverage, and its table read is **409 `cast_precondition_failed`** until every character has a pinned voice — call the shared `prepareTableRead` step between the source and the plan.

```js
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

1. Have an existing `table_read` artifact that is `READY` and its `id`.
2. POST to `/artifacts/{artifactId}/refine` with the instruction and `scope: "screenplay"`. Without `confirmed` it answers the quote and reserves nothing; send the same body with `confirmed: true` to create the job. Poll the returned storyJob until `READY`.
3. Open the theater/audio share and complete a recorded Pipecat/Daily live-read session if one has not been recorded yet.
4. POST to `/artifacts/{artifactId}/finalize` with `{ mode: "audio" }`. Poll `manifest.audio.finalize.status` until `complete`.
5. Read `manifest.audio.recordingUrl` for the durable MP3.

The original share URLs (`theaterUrl`, `theaterFullscreenUrl`, `audio.liveUrl`) are unchanged throughout.

```js
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

```html
<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)

```jsx
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, scope `artifact:publish`)
- `POST /artifacts/{artifactId}/finalize` with `{ "mode": "video" }` (scope `artifact:publish`, replay-safe)

### Steps

1. Confirm the artifact is `READY` and has a `tableReadShareLinkId` (the render endpoint requires a live share link).
2. POST to `/artifacts/{artifactId}/render-video` (or `/finalize` with `mode:"video"`) with an `Idempotency-Key`.
3. Poll `GET /artifacts/{artifactId}` until `manifest.video.status` is `complete`.
4. Read `manifest.video.url` for the durable MP4.

```js
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

1. Confirm the artifact is `READY`.
2. POST to `/artifacts/{artifactId}/music` to generate adaptive per-scene soundtrack.
3. Poll `GET /artifacts/{artifactId}/music` until `music.status` is `ready`.
4. Optionally POST to `/artifacts/{artifactId}/music` with `scope`, `sceneIndex`, `entryIndex`, and `mode` to tune, mute, merge, or replace soundtrack direction.
5. POST to `/artifacts/{artifactId}/sfx` to add timed cues.
6. GET `/artifacts/{artifactId}/sfx` to list the cue bank.

```js
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.

```js
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.

```js
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)

```js
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

```js
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: { 'Idempotency-Key': randomUUID(), '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.

```js
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 exactly `Retry-After` seconds, then retry.
- **Internal errors** (`internal_error`) — safe to retry with the same `Idempotency-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 published watch page — driven from the public Story API alone. Each of the twelve 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.

1. **Create a product project.** Call `POST /api/v1/story-projects` with `{ "name": "My Product", "kind": "product" }` — `name` is required, and the call is refused without it (a `product` kind workspace holds a Product Bible and is judged by the product rubric; omitted, `kind` follows the account's role default, so pass it explicitly). Note the created project's id — it is `{projectId}` for every step below.

2. **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: read `GET /api/v1/story-projects/{projectId}/feature-inventory` until the run finishes; the result is the feature map every later step names features from.

3. **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`, `confirmed` or `rejected`) and the pages behind it. Record the founder's verdict per claim with `POST /api/v1/story-projects/{projectId}/claims-ledger/{claimId}` using body `{ "decision": "confirm" | "reject" | "reopen" }` (an optional `note` rides along; `decision` is the field the call requires — only `confirm`, `reject` or `reopen` are accepted). Only the founder decides; a rejected claim is carried into the Product Bible's "do not claim" list when the Bible is written.

4. **Write the Product Bible and pass its coverage.** Generate the Bible with `POST /api/v1/story-projects/{projectId}/series-bible` — for a `product` project 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 a `Complete … before product coverage` readiness error) until that write has landed, so first poll `GET /api/v1/story-projects/{projectId}/series-bible` until it answers with the saved `document` (any run still writing it shows under `generation`). Only then score it with `POST /api/v1/story-projects/{projectId}/series-bible/coverage` using body `{ "phase": "product" }`, and read the result with `GET /api/v1/story-projects/{projectId}/series-bible/coverage` (query `phase=product`). Step 8 refuses with `409 project_precondition_failed` until the Bible is complete and product coverage has passed — do not skip this step.

5. **Connect the app and prove the sign-in works.** Register it with `POST /api/v1/story-projects/{projectId}/app-connections` (its `origin`, `loginUrl` and `loginMethod`), 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) for `username`, `password`, `totpSeed` or `storageState`. Run a real sign-in with `POST /api/v1/story-projects/{projectId}/app-connections/{connectionId}/test-login` and poll `GET /api/v1/story-projects/{projectId}/app-connections/{connectionId}/test-login` until `tested` is `true` — the answer carries `test.status` (`running`, then `passed` or `failed`), `message` and, for an owner key, `screenshotUrl`; it never answers `ok`. A missing secret queues nothing (`missingSecrets` names it), and a `failed` sign-in means the recording machine could not log in either — read `test.reason` before changing the secret.

6. **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 of `tour` (the document) or `tourId` (a stored tour) — then store the passing document with `POST /api/v1/story-projects/{projectId}/tours` for the feature it walks. Fix what the check reports before recording.

7. **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) as `video/mp4` to that URL, then check the stored file with `POST /api/v1/story-projects/{projectId}/captures/{captureId}/finalize`, which marks it `ready` or `rejected` with the exact re-encode command. List what you have with `GET /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.

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 ordinary `draft` trailer whose beats each name the capture they play. Read the run with `GET /api/v1/story-projects/{projectId}/product-video-plans/{runId}` until its status is `ready` and take its `trailerJobId`. Then read the plan check with `GET /api/v1/trailers/{jobId}/product-plan-coverage` (use that `trailerJobId` as `{jobId}`): it lists what the cut covers, feature by beat, and every finding with its fix — blocking findings gate the approval.

9. **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 under `unavailable` with the reason and is never guessed. Read a single destination's whole spec with `GET /api/v1/video-destinations/{key}` before cutting for it; a spec last checked against its `sourceUrls` more than 90 days ago reports `specMayBeStale`.

10. **Quote the render, then confirm it — two calls, never one.** `POST /api/v1/trailers/{jobId}/plan/approve` with the `Idempotency-Key` header and **without** `confirmed` (or with `"confirmed": false`) is a QUOTE: it compiles the plan into render calls and answers `{ "quotedOnly": true, "credits": n, "lines": [...], "warnings": [...], "autoFixAllowance": {...} }`, starts nothing and reserves nothing — show `credits` and the `lines` to the founder. It refuses a `blocked` plan-coverage check with `409` (the findings name their fixes) and answers `{ "status": "preparing" }` while the quote is still compiling: ask again, nothing has started. Then repeat the same call with body `{ "confirmed": true }` — and `acknowledgePlanCoverage: { reason }` only when the founder, having read a failing plan check, chooses to approve anyway. The confirmed call reserves exactly those quoted credits and starts the render. Confirming before quoting spends without ever showing a price; quoting after confirming prices a render already underway.

11. **Poll until the cut is complete.** `GET /api/v1/trailers/{jobId}/beats` (scope `story:read`) until `status` is `complete` and `master.stage` is `finished`. `rendering`, then `review` are normal along the way — `review` is the 480p preview, waiting for take selection and `POST /api/v1/trailers/{jobId}/finish` (a second, separately quoted and confirmed call that upscales the selected takes to the delivery class). A `failed` trailer carries `failedStage` and `errorMessage`; nothing below can succeed on it.

12. **Export the master, then publish the watch page.** `POST /api/v1/trailers/{jobId}/video-export` (`Idempotency-Key`; optional `target`, default `site`, which re-stitches the master — `youtube`/`x` make a levelled copy and leave the master alone) re-runs the composition over the already-finished cuts: it generates nothing and only a `complete` trailer exports, so a trailer still in `review` is refused `409 trailer_not_finished` with the finish-first sentence. It answers `{ "renderedVideoStatus": "queued" }` (or the status already in flight — a queued or rendering export short-circuits instead of stacking), so poll the beats read until `renderedVideoStatus` is `ready` and read `renderedVideoUrl`. Then publish with `POST /api/v1/trailers/{jobId}/publish`, which refuses a master that is missing, idle, stale or failed. 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.
