# 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 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. Create a plan with `mode: "podcast"`.
5. Poll to `REQUIRES_APPROVAL`, show the exact plan to the user, and approve only after they explicitly accept it.
6. Create a job and poll until `READY`.
7. 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: { '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],
      }),
    }
  )

  // 5. 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 }),
  })

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

```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: { '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],
      }),
    }
  )

  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"`. 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: 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

```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: { '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: { '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: 'generate_story_plan',
    description: 'Generate a StoryPlan from sources. 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: { '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.

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

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