# Quickstart: Source to Playable Table Read

This walkthrough goes from a fresh API key to a live, shareable table read URL. It covers every step in the happy path: discover capabilities, check credits, create a project, attach sources, generate and approve a plan, run a job, and read the artifact manifest.

All examples use `https://sleeperhit.studio` as the base URL and `$SLEEPERHIT_API_KEY` as the environment variable holding your key.

---

## Prerequisites

- An API key from `https://sleeperhit.studio/dashboard/api`.
- The key must have scopes: `story:read`, `story:write`, `source:write`, `credits:read`, `artifact:read`. Add `artifact:publish` for optional refine, voice recast, finalize, render-video, music, and SFX mutations. If any scope is missing the API returns `insufficient_scope` — ask the account owner to add it.
- Enough Studio Credits to cover the quoted artifact work. Planning and source digests are included; credits are reserved when you create/refine/finalize artifacts.

---

## Step 1 — Confirm capabilities

Before every run, fetch the live capability manifest to confirm the features you need are available.

```bash
curl -s https://sleeperhit.studio/api/v1/capabilities \
  -H "Authorization: Bearer $SLEEPERHIT_API_KEY" | jq .
```

```js
const res = await fetch('https://sleeperhit.studio/api/v1/capabilities', {
  headers: { Authorization: `Bearer ${process.env.SLEEPERHIT_API_KEY}` },
})
if (!res.ok) throw new Error(`capabilities ${res.status}`)
const caps = await res.json()
// confirm table_read is available
if (caps.artifacts.table_read?.availability !== 'available') {
  throw new Error('table_read not available in this environment')
}
```

Key fields to check:
- `artifacts.table_read.availability` — must be `"available"`.
- `sources.types` — lists which source types (`text`, `markdown`, `url`, `pdf`) are `"available"`.
- `plans.autoApprove` — whether eligible non-table artifacts may skip approval. Table-read plans always require exact human review and approval.

Cache the response for at most one minute.

---

## Step 2 — Check your credit balance

Read the balance before triggering any generation that could fail with `insufficient_credits`. The plan will carry a `quote` you can compare against this.

```bash
curl -s https://sleeperhit.studio/api/v1/credits \
  -H "Authorization: Bearer $SLEEPERHIT_API_KEY" | jq .credits.balance
```

```js
const credRes = await fetch('https://sleeperhit.studio/api/v1/credits', {
  headers: { Authorization: `Bearer ${process.env.SLEEPERHIT_API_KEY}` },
})
const { credits } = await credRes.json()
console.log('Available balance:', credits.balance)
// credits.monthlyBucketRemaining + credits.perpetualBalance
```

Response shape:
```json
{
  "credits": {
    "balance": 250,
    "monthlyBucketAllocated": 300,
    "monthlyBucketUsed": 50,
    "monthlyBucketRemaining": 250,
    "perpetualBalance": 0,
    "cycleStartedAt": "2026-05-01T00:00:00.000Z",
    "cycleExpiresAt": "2026-06-01T00:00:00.000Z"
  }
}
```

---

## Step 3 — Create a project

A project is a named workspace that groups sources, plans, and jobs together. Use an `Idempotency-Key` so retries are safe.

```bash
curl -s https://sleeperhit.studio/api/v1/story-projects \
  -H "Authorization: Bearer $SLEEPERHIT_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"name":"Q3 Launch Story","description":"Product launch table read"}'
```

```js
import { randomUUID } from 'node:crypto'

const projectRes = await fetch('https://sleeperhit.studio/api/v1/story-projects', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.SLEEPERHIT_API_KEY}`,
    'Idempotency-Key': randomUUID(),
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    name: 'Q3 Launch Story',
    description: 'Product launch table read',
  }),
})
if (!projectRes.ok) throw new Error(`project create ${projectRes.status}`)
const { project } = await projectRes.json()
const projectId = project.id
```

Response:
```json
{
  "project": {
    "id": "cmp...",
    "name": "Q3 Launch Story",
    "description": "Product launch table read",
    "metadata": null,
    "archivedAt": null,
    "sourceCount": 0,
    "createdAt": "2026-05-29T10:00:00.000Z",
    "updatedAt": "2026-05-29T10:00:00.000Z"
  }
}
```

---

## Step 4 — Attach sources

Sources are the raw material the planner reads. You can attach multiple sources to one project. The planner uses source digests when available and falls back to the extracted-text preview.

### Option A — Inline text (synchronous, instant `READY`)

Best for content you already have in memory: press releases, brand guides, script drafts.

```bash
curl -s https://sleeperhit.studio/api/v1/story-projects/$PROJECT_ID/sources \
  -H "Authorization: Bearer $SLEEPERHIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"type":"text","label":"press-release","content":"<paste your text here>"}'
```

```js
const sourceRes = await fetch(
  `https://sleeperhit.studio/api/v1/story-projects/${projectId}/sources`,
  {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.SLEEPERHIT_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      type: 'text',
      label: 'press-release',
      content: pressReleaseText,
    }),
  }
)
const { source } = await sourceRes.json()
// source.status === 'READY' immediately for inline text
const sourceId = source.id
```

### Option B — URL (async, must poll)

The API fetches the URL in a background worker. Private IP addresses, cloud-metadata endpoints, and non-text content types are blocked (SSRF guard). Returns `PENDING` immediately; poll until `READY` or `FAILED`.

```bash
# Submit
curl -s https://sleeperhit.studio/api/v1/story-projects/$PROJECT_ID/sources \
  -H "Authorization: Bearer $SLEEPERHIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"type":"url","uri":"https://example.com/launch-article","label":"launch article"}'

# Poll
curl -s https://sleeperhit.studio/api/v1/story-projects/$PROJECT_ID/sources/$SOURCE_ID \
  -H "Authorization: Bearer $SLEEPERHIT_API_KEY" | jq .source.status
```

```js
// Submit
const urlSourceRes = await fetch(
  `https://sleeperhit.studio/api/v1/story-projects/${projectId}/sources`,
  {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.SLEEPERHIT_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ type: 'url', uri: 'https://example.com/launch-article', label: 'launch article' }),
  }
)
const { source } = await urlSourceRes.json()

// Poll until terminal
async function pollSource(projectId, sourceId) {
  while (true) {
    const res = await fetch(
      `https://sleeperhit.studio/api/v1/story-projects/${projectId}/sources/${sourceId}`,
      { headers: { Authorization: `Bearer ${process.env.SLEEPERHIT_API_KEY}` } }
    )
    const { source } = await res.json()
    if (source.status === 'READY' || source.status === 'FAILED') return source
    await new Promise((r) => setTimeout(r, 3000))
  }
}
const readySource = await pollSource(projectId, source.id)
if (readySource.status === 'FAILED') {
  throw new Error(`source failed: ${readySource.failureCode} — ${readySource.failureMessage}`)
}
```

### Option C — PDF (async, must poll)

Submit the public URL of a PDF. Same SSRF and redirect guards as URLs; the upstream `Content-Type` must be `application/pdf`. The worker extracts text and records `metadata.pageCount`. Poll the same way as URL sources.

```bash
curl -s https://sleeperhit.studio/api/v1/story-projects/$PROJECT_ID/sources \
  -H "Authorization: Bearer $SLEEPERHIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"type":"pdf","uri":"https://example.com/whitepaper.pdf","label":"whitepaper"}'
```

---

## Step 5 — Wait for source digests (optional but recommended)

After a source reaches `READY`, the API runs a structured digest pass and populates `digestStatus`. When `digestStatus` is `READY`, the `digest` field carries a summary, thesis, typed claims with attributions, evidence, gaps, and rights notes.

The planner requires each `sourceId` to be `READY`. It uses digests when available, which produces higher-quality plans. If you skip the digest wait, the planner falls back to the raw extracted-text preview.

Poll `digestStatus` on the source:

```js
async function waitForDigest(projectId, sourceId) {
  while (true) {
    const res = await fetch(
      `https://sleeperhit.studio/api/v1/story-projects/${projectId}/sources/${sourceId}`,
      { headers: { Authorization: `Bearer ${process.env.SLEEPERHIT_API_KEY}` } }
    )
    const { source } = await res.json()
    const done = ['READY', 'FAILED', 'SKIPPED'].includes(source.digestStatus)
    if (done) return source
    await new Promise((r) => setTimeout(r, 4000))
  }
}
```

`SKIPPED` is normal when the sidecar is not configured or the source has too little text, and by design for a full-context producer item (its plan reads it whole) — the source is still usable for planning.

---

## Step 6 — Generate a StoryPlan

The plan is the central abstraction: a source-grounded narrative with thesis, beats, visual system, audio system, and per-artifact render intent. It also carries a deterministic Studio Credit quote.

Table-read plans always stop for exact human review. Leave `autoApprove` false or omit it, poll to `REQUIRES_APPROVAL`, show the returned plan to the user, and approve only after they explicitly accept it. Other artifact types may use `autoApprove` only when the capability manifest says they are eligible.

```bash
curl -s https://sleeperhit.studio/api/v1/story-projects/$PROJECT_ID/story-plans \
  -H "Authorization: Bearer $SLEEPERHIT_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "target": {
      "audience": "enterprise buyers",
      "objective": "introduce the Q3 product launch",
      "outcome": "book a demo within 7 days",
      "tone": "confident, specific"
    },
    "artifactRequests": [
      { "type": "table_read", "mode": "documentary" }
    ],
    "sourceIds": ["<source-id-1>"]
  }'
```

```js
const planRes = await fetch(
  `https://sleeperhit.studio/api/v1/story-projects/${projectId}/story-plans`,
  {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.SLEEPERHIT_API_KEY}`,
      'Idempotency-Key': randomUUID(),
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      target: {
        audience: 'enterprise buyers',
        objective: 'introduce the Q3 product launch',
        outcome: 'book a demo within 7 days',
        tone: 'confident, specific',
      },
      artifactRequests: [{ type: 'table_read', mode: 'documentary' }],
      sourceIds: [sourceId],
    }),
  }
)
const { plan } = await planRes.json()
const planId = plan.id
```

The plan is created synchronously but the planning run is async. The response immediately returns the plan record at `status: PENDING`.

---

## Step 7 — Poll the plan until approved

Poll every 3–5 seconds until the status is terminal.

Terminal states: `REQUIRES_APPROVAL`, `APPROVED`, `REJECTED`, `FAILED`, `SUPERSEDED`.

A table-read plan lands at `REQUIRES_APPROVAL`. Show the exact returned plan to the user and POST to `/approve` with `{ "userConfirmed": true }` only after they explicitly accept it. Eligible non-table plans may land at `APPROVED` when `autoApprove` is enabled.

```bash
curl -s https://sleeperhit.studio/api/v1/story-plans/$PLAN_ID \
  -H "Authorization: Bearer $SLEEPERHIT_API_KEY" | jq .plan.status
```

```js
async function pollPlan(planId) {
  const terminal = new Set(['REQUIRES_APPROVAL', 'APPROVED', 'REJECTED', 'FAILED', 'SUPERSEDED'])
  while (true) {
    const res = await fetch(`https://sleeperhit.studio/api/v1/story-plans/${planId}`, {
      headers: { Authorization: `Bearer ${process.env.SLEEPERHIT_API_KEY}` },
    })
    const { plan } = await res.json()
    if (terminal.has(plan.status)) return plan
    await new Promise((r) => setTimeout(r, 4000))
  }
}

const approvedPlan = await pollPlan(planId)
if (approvedPlan.status === 'FAILED') {
  throw new Error(`plan failed: ${approvedPlan.failureCode} — ${approvedPlan.failureMessage}`)
}
if (approvedPlan.status === 'REQUIRES_APPROVAL') {
  console.dir(approvedPlan.plan, { depth: null })
  const userConfirmed = await collectExplicitUserApproval(approvedPlan)
  if (!userConfirmed) throw new Error('The user did not approve this plan.')
  await fetch(`https://sleeperhit.studio/api/v1/story-plans/${planId}/approve`, {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${process.env.SLEEPERHIT_API_KEY}`,
        'Content-Type': 'application/json',
        'Idempotency-Key': crypto.randomUUID(),
      },
      body: JSON.stringify({ userConfirmed: true }),
    })
}
```

Once approved, inspect the `quote` field:

```json
{
  "quote": {
    "currency": "studio_credits",
    "total": 11,
    "lineItems": [
      { "kind": "planning", "label": "StoryPlan generation", "credits": 0 },
      { "kind": "source_digest", "label": "Source digest (1 source)", "credits": 0 },
      { "kind": "artifact", "label": "Table read", "credits": 11 }
    ],
    "computedAt": "2026-05-29T10:05:00.000Z"
  }
}
```

This quote is deterministic from the plan. Credits are not spent here — they are reserved in the next step.

---

## Step 8 — Create a story job

The job reserves the credits and queues the artifact generation.

```bash
curl -s https://sleeperhit.studio/api/v1/story-jobs \
  -H "Authorization: Bearer $SLEEPERHIT_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"storyPlanId":"<approved-plan-id>"}'
```

```js
const jobRes = await fetch('https://sleeperhit.studio/api/v1/story-jobs', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.SLEEPERHIT_API_KEY}`,
    'Idempotency-Key': randomUUID(),
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ storyPlanId: planId }),
})
if (jobRes.status === 402) {
  const { error } = await jobRes.json()
  // error.code === 'insufficient_credits'
  // error details carry required vs available
  throw new Error(`insufficient credits: need ${error.details?.required}, have ${error.details?.available}`)
}
if (!jobRes.ok) throw new Error(`job create ${jobRes.status}`)
const { job } = await jobRes.json()
const jobId = job.id
```

The job starts at `RESERVED` (credits held) and immediately moves toward `RUNNING`.

---

## Step 9 — Poll the job until terminal

```bash
curl -s https://sleeperhit.studio/api/v1/story-jobs/$JOB_ID \
  -H "Authorization: Bearer $SLEEPERHIT_API_KEY" | jq '{status:.job.status,artifacts:.job.artifacts[].status}'
```

```js
async function pollJob(jobId) {
  const terminal = new Set(['READY', 'PARTIAL', 'FAILED', 'CANCELED'])
  while (true) {
    const res = await fetch(`https://sleeperhit.studio/api/v1/story-jobs/${jobId}`, {
      headers: { Authorization: `Bearer ${process.env.SLEEPERHIT_API_KEY}` },
    })
    const { job } = await res.json()
    if (terminal.has(job.status)) return job
    await new Promise((r) => setTimeout(r, 5000))
  }
}

const completedJob = await pollJob(jobId)
if (completedJob.status === 'FAILED') {
  throw new Error(`job failed: ${completedJob.failureCode}`)
}
```

Poll every 5–15 seconds. `PARTIAL` means at least one artifact is ready while others are still running — you can read ready artifacts immediately. `READY` means all artifacts are done. A job may move from `RUNNING` back to `RESERVED` with `progress.stage: "provider_backoff"`; this is nonterminal core-owned recovery with the same job id, credits, and checkpoint, so keep polling without adding a client-side retry circuit.

---

## Step 10 — Read the artifact manifest

When the job is `READY` (or `PARTIAL`), each artifact in `job.artifacts[]` with `status: "READY"` carries a `manifest`. For a `table_read` artifact:

```json
{
  "artifact": {
    "id": "cma...",
    "type": "table_read",
    "status": "READY",
    "manifest": {
      "type": "table_read",
      "status": "ready",
      "shareToken": "<expiring JWT>",
      "theaterUrl": "https://sleeperhit.studio/share/table-read/<token>",
      "theaterFullscreenUrl": "https://sleeperhit.studio/share/table-read/<token>?view=fullscreen",
      "audio": {
        "mode": "live_on_demand",
        "liveUrl": "https://sleeperhit.studio/share/table-read-audio/<token>",
        "startLiveUrl": "https://sleeperhit.studio/api/share/table-read/start-live",
        "recordingUrl": null
      },
      "video": {
        "status": "not_rendered",
        "renderUrl": "/api/v1/artifacts/cma.../render-video",
        "url": null
      },
      "expiresAt": "2026-06-29T10:00:00.000Z"
    }
  }
}
```

The three ready-to-open player URLs:

| URL | Experience |
|---|---|
| `manifest.theaterUrl` | Framed visual theater with attribution chrome |
| `manifest.theaterFullscreenUrl` | Immersive, chrome-free fullscreen — best for embeds |
| `manifest.audio.liveUrl` | Audio-only player, no visual stage |

Each player starts the on-demand live read itself when the end-user clicks play. The `shareToken` (not your API key) authorizes all three URLs — you can safely give them to end-users.

```js
const tableReadArtifact = completedJob.artifacts.find((a) => a.type === 'table_read')
const { manifest } = tableReadArtifact

// Hand these to your end user:
console.log('Theater:', manifest.theaterUrl)
console.log('Fullscreen embed:', manifest.theaterFullscreenUrl)
console.log('Audio only:', manifest.audio.liveUrl)

// Or drive the live performance directly from your own UI:
const liveRes = await fetch(manifest.audio.startLiveUrl, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ token: manifest.shareToken }),
})
const { dailyRoomUrl } = await liveRes.json()
// dailyRoomUrl is a Daily.co room the end-user joins
```

---

## Optional: refine the artifact

If the initial result needs adjustments — different cast, different tone, renamed characters — POST a free-form instruction to refine it in place. The artifact id and share URLs are unchanged.

```bash
curl -s -X POST https://sleeperhit.studio/api/v1/artifacts/$ARTIFACT_ID/refine \
  -H "Authorization: Bearer $SLEEPERHIT_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"instruction":"make the narrator warmer and drop the skeptic character","scope":"screenplay"}'
```

Returns `{ storyJob }`. Poll `GET /story-jobs/{jobId}` until `READY`. Scope required: `artifact:publish`. Cost: 8 credits (TABLE_READ_REFINE).

---

## Optional: recast a voice, add music, or add SFX

These post-creation controls mutate the same live table read. They do not create a new revision and do not change the artifact id or share URLs.

**Recast one character voice:**

```bash
curl -s -X POST https://sleeperhit.studio/api/v1/artifacts/$ARTIFACT_ID/voice \
  -H "Authorization: Bearer $SLEEPERHIT_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"character":"NARRATOR","voiceId":"voice_123","voiceName":"Warm Documentary Host"}'
```

**Regenerate adaptive soundtrack:**

```bash
curl -s -X POST https://sleeperhit.studio/api/v1/artifacts/$ARTIFACT_ID/music \
  -H "Authorization: Bearer $SLEEPERHIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"musicCoveragePercent":0.35}'
```

Poll `GET /artifacts/{artifactId}/music` until `music.status` is `ready`.

Tune one scene without changing the artifact/share URLs:

```bash
curl -s -X POST https://sleeperhit.studio/api/v1/artifacts/$ARTIFACT_ID/music \
  -H "Authorization: Bearer $SLEEPERHIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"sceneIndex":2,"prompt":"heavier bass pulse under the reveal","weight":0.85}'
```

Replace the whole-read scoring palette:

```bash
curl -s -X POST https://sleeperhit.studio/api/v1/artifacts/$ARTIFACT_ID/music \
  -H "Authorization: Bearer $SLEEPERHIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"scope":"screenplay","mode":"replace","prompt":"industrial electronic pulse, distorted analog synth","summary":"Reznor/Ross-style electronic palette"}'
```

**Add a timed sound effect cue:**

```bash
curl -s -X POST https://sleeperhit.studio/api/v1/artifacts/$ARTIFACT_ID/sfx \
  -H "Authorization: Bearer $SLEEPERHIT_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"op":"add","entryIndex":12,"label":"door knock","prompt":"three crisp knocks on a wooden door","triggerOffset":"before","offsetMs":-150,"generatedDurationS":1.2,"enabled":true}'
```

Scope required for all three mutations: `artifact:publish`.

---

## Optional: finalize an MP3 or MP4

To get a durable file (for download or delivery), use the finalize endpoint.

**Audio MP3 (6 credits):**

```bash
curl -s -X POST https://sleeperhit.studio/api/v1/artifacts/$ARTIFACT_ID/finalize \
  -H "Authorization: Bearer $SLEEPERHIT_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"mode":"audio"}'
```

Poll `GET /artifacts/{artifactId}` for `manifest.audio.finalize.status` (`needs_recording`, or `queued → rendering → uploading → complete`). `needs_recording` means the live read has not been recorded yet and no credits were reserved. On `complete`, `manifest.audio.recordingUrl` (MP3) and `manifest.audio.recordingDurationMs` are populated.

**Video MP4 (12 credits) — also available via the legacy render-video endpoint:**

```bash
curl -s -X POST https://sleeperhit.studio/api/v1/artifacts/$ARTIFACT_ID/render-video \
  -H "Authorization: Bearer $SLEEPERHIT_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
```

```js
const renderRes = await fetch(
  `https://sleeperhit.studio/api/v1/artifacts/${tableReadArtifact.id}/render-video`,
  {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.SLEEPERHIT_API_KEY}`,
      'Idempotency-Key': randomUUID(),
    },
  }
)
const { artifactId, render } = await renderRes.json()
// render.status will move from 'rendering' -> 'complete'
// poll GET /artifacts/{artifactId} for manifest.video.status and manifest.video.url
```

---

## Complete Node.js example

The following is a minimal, self-contained Node.js script that runs the full flow.

```js
import { randomUUID } from 'node:crypto'
import { createInterface } from 'node:readline/promises'
import { stdin, stdout } from 'node:process'

const BASE = 'https://sleeperhit.studio/api/v1'
const KEY = process.env.SLEEPERHIT_API_KEY

function headers(extra = {}) {
  return { Authorization: `Bearer ${KEY}`, ...extra }
}

async function api(path, options = {}) {
  const res = await fetch(`${BASE}${path}`, {
    ...options,
    headers: { ...headers(), ...options.headers },
  })
  if (!res.ok) {
    const err = await res.json().catch(() => ({}))
    throw new Error(`${options.method ?? 'GET'} ${path} -> ${res.status}: ${err?.error?.code ?? 'unknown'}`)
  }
  return res.json()
}

async function poll(fn, isTerminal, intervalMs = 5000) {
  while (true) {
    const result = await fn()
    if (isTerminal(result)) return result
    await new Promise((r) => setTimeout(r, intervalMs))
  }
}

// 1. Confirm capabilities
const caps = await api('/capabilities')
if (caps.artifacts.table_read?.availability !== 'available') throw new Error('table_read not available')

// 2. Create project
const { project } = await api('/story-projects', {
  method: 'POST',
  headers: { 'Idempotency-Key': randomUUID(), 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: 'Quickstart demo', description: 'End-to-end test' }),
})

// 3. Attach a source
const { source } = await api(`/story-projects/${project.id}/sources`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    type: 'text',
    label: 'sample-content',
    content: 'Acme Corp is launching its next-generation platform in Q3 2026. The platform enables real-time collaboration across global teams and reduces onboarding time by 60 percent.',
  }),
})

// 4. Generate a table-read plan; this always retains the human hold.
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: 'enterprise buyers',
      objective: 'introduce the Q3 platform launch',
      outcome: 'book a demo',
    },
    artifactRequests: [{ type: 'table_read', mode: 'documentary' }],
    sourceIds: [source.id],
  }),
})

// 5. Poll to review, show the exact plan, and ask the human before approval.
const reviewPlan = await poll(
  () => api(`/story-plans/${pendingPlan.id}`).then((r) => r.plan),
  (p) => ['REQUIRES_APPROVAL', 'APPROVED', 'REJECTED', 'FAILED'].includes(p.status),
  4000
)
if (reviewPlan.status !== 'REQUIRES_APPROVAL') throw new Error(`plan ${reviewPlan.status}: ${reviewPlan.failureMessage}`)
console.dir(reviewPlan.plan, { depth: null })
const prompt = createInterface({ input: stdin, output: stdout })
const answer = await prompt.question('Approve this exact plan? [y/N] ')
prompt.close()
if (!/^(y|yes)$/i.test(answer.trim())) throw new Error('Plan not approved by user.')
const { plan: approvedPlan } = await api(`/story-plans/${pendingPlan.id}/approve`, {
  method: 'POST',
  headers: { 'Idempotency-Key': randomUUID(), 'Content-Type': 'application/json' },
  body: JSON.stringify({ userConfirmed: true }),
})
if (approvedPlan.status !== 'APPROVED') throw new Error(`plan ${approvedPlan.status}: ${approvedPlan.failureMessage}`)
console.log('Plan approved. Quote:', approvedPlan.quote.total, 'credits')

// 6. Create job
const { job: pendingJob } = await api('/story-jobs', {
  method: 'POST',
  headers: { 'Idempotency-Key': randomUUID(), 'Content-Type': 'application/json' },
  body: JSON.stringify({ storyPlanId: approvedPlan.id }),
})

// 7. Poll job to READY
const completedJob = await poll(
  () => api(`/story-jobs/${pendingJob.id}`).then((r) => r.job),
  (j) => ['READY', 'PARTIAL', 'FAILED', 'CANCELED'].includes(j.status),
  6000
)
if (completedJob.status === 'FAILED') throw new Error(`job failed: ${completedJob.failureCode}`)

// 8. Print the manifest URLs
const artifact = completedJob.artifacts.find((a) => a.type === 'table_read' && a.status === 'READY')
const m = artifact.manifest
console.log('Theater URL:', m.theaterUrl)
console.log('Fullscreen URL:', m.theaterFullscreenUrl)
console.log('Audio-only URL:', m.audio.liveUrl)
```

---

## Error handling essentials

Match on `error.code`, not the human message. The message may change; the code is stable.

| Code | What to do |
|---|---|
| `authentication_required` / `invalid_api_key` | Rotate the key in your secret manager |
| `api_key_revoked` / `api_key_expired` | Issue a new key |
| `insufficient_scope` | Ask the account owner to add the required scope |
| `rate_limited` | Wait for `Retry-After` seconds, then retry |
| `idempotency_conflict` | Another request with this key is still processing — poll the status endpoint instead |
| `insufficient_credits` | Add credits before retrying — `details.required` / `details.available` tell you the gap |
| `source_incomplete` | Regenerate the full-context source pack; the receiver rejected an incomplete article or comment thread |
| `source_fetch_failed` | The URL or PDF could not be fetched — check `failureMessage` for the reason (IP blocked, redirect limit, wrong content type, etc.) |
| `internal_error` | Safe to retry with the same `Idempotency-Key` |

See [api-reference.md#error-codes](api-reference.md#error-codes) for the full table.
