{"version":"v1","baseUrl":"https://docs.sleeperhit.studio/api/v1","authentication":{"header":"Authorization","format":"Bearer sh_<prefix>_<secret>","obtainAt":"https://docs.sleeperhit.studio/dashboard/api"},"errorCodes":["authentication_required","invalid_api_key","api_key_revoked","api_key_expired","account_disabled","ip_not_allowed","insufficient_scope","rate_limited","idempotency_key_required","idempotency_conflict","validation_failed","project_not_found","source_not_found","source_type_unsupported","source_too_large","source_fetch_failed","story_plan_not_found","story_plan_state_invalid","story_plan_failed","story_job_not_found","story_job_state_invalid","artifact_not_found","artifact_not_ready","artifact_generation_failed","insufficient_credits","internal_error"],"examples":[{"id":"discover-capabilities","title":"Discover supported features before integrating","status":"available","scopes":["story:read"],"description":"Read the machine-readable capability manifest to see which sources, artifacts, scopes, and docs are available right now. Use this before writing any flow-specific code so your agent adapts as new features ship.","whenToUse":"First call from any new integration, and after any unexpected `*_unsupported` error from a generation endpoint.","steps":[{"title":"GET /capabilities","description":"Returns the capability manifest. Cache it for at most a minute — availability moves as new artifact adapters launch.","request":{"method":"GET","path":"/capabilities","headers":{"Authorization":"Bearer sh_<prefix>_<secret>"}},"curl":"curl -s https://docs.sleeperhit.studio/api/v1/capabilities \\\n  -H \"Authorization: Bearer sh_<prefix>_<secret>\"","node":"const res = await fetch('https://docs.sleeperhit.studio/api/v1/capabilities', {\n  headers: { Authorization: `Bearer ${process.env.SLEEPERHIT_API_KEY}` },\n})\nif (!res.ok) throw new Error(`capabilities ${res.status}`)\nconst capabilities = await res.json()"}]},{"id":"check-credits","title":"Check Studio Credit balance before generating","status":"available","scopes":["credits:read"],"description":"Read the current Studio Credit balance so the agent can short-circuit before triggering an expensive job that would fail with `insufficient_credits`.","whenToUse":"Before any generation step (once those endpoints are live), and on any `insufficient_credits` error to confirm the new balance.","steps":[{"title":"GET /credits","description":"Returns the available credit balance, monthly bucket usage, and the active cycle window.","request":{"method":"GET","path":"/credits","headers":{"Authorization":"Bearer sh_<prefix>_<secret>"}},"curl":"curl -s https://docs.sleeperhit.studio/api/v1/credits \\\n  -H \"Authorization: Bearer sh_<prefix>_<secret>\"","node":"const res = await fetch('https://docs.sleeperhit.studio/api/v1/credits', {\n  headers: { Authorization: `Bearer ${process.env.SLEEPERHIT_API_KEY}` },\n})\nif (!res.ok) throw new Error(`credits ${res.status}`)\nconst { credits } = await res.json()\nif (credits.balance < estimatedQuote) {\n  // Surface insufficient credits to the operator before reserving.\n}"}]},{"id":"handle-errors","title":"Recover from auth, rate limit, and idempotency errors","status":"available","scopes":[],"description":"Every error response uses the same envelope: `{ error: { code, message, requestId } }`. Match on `code`, not the human message. Surface `requestId` when reporting integration issues — the API logs the same id.","whenToUse":"Wrap every API call. The same recovery rules apply to every endpoint in this spec.","steps":[{"title":"Match on the stable error code","description":"Codes that warrant immediate action: `authentication_required` and `invalid_api_key` (rotate the key), `api_key_revoked`/`api_key_expired` (issue a new key), `insufficient_scope` (request a scoped key from the operator), `rate_limited` (respect `Retry-After`), `idempotency_conflict` (regenerate the Idempotency-Key or wait for completion).","request":{"method":"GET","path":"/capabilities","headers":{"Authorization":"Bearer sh_<prefix>_<secret>"}},"curl":"curl -i https://docs.sleeperhit.studio/api/v1/capabilities \\\n  -H \"Authorization: Bearer sh_invalid\"","node":"const res = await fetch('https://docs.sleeperhit.studio/api/v1/capabilities', {\n  headers: { Authorization: `Bearer ${process.env.SLEEPERHIT_API_KEY}` },\n})\nif (!res.ok) {\n  const { error } = await res.json().catch(() => ({ error: { code: 'unknown' } }))\n  switch (error.code) {\n    case 'authentication_required':\n    case 'invalid_api_key':\n    case 'api_key_revoked':\n    case 'api_key_expired':\n      // Rotate the key in your secret manager and stop the run.\n      throw new Error(error.message)\n    case 'insufficient_scope':\n      // Ask the operator to issue a key with the listed scope.\n      throw new Error(error.message)\n    case 'rate_limited': {\n      const retryAfter = Number(res.headers.get('Retry-After') ?? '5')\n      await new Promise((r) => setTimeout(r, retryAfter * 1000))\n      return retry()\n    }\n    default:\n      throw new Error(`${error.code}: ${error.message} (request ${error.requestId})`)\n  }\n}","responseExample":{"error":{"code":"invalid_api_key","message":"The API key is invalid.","requestId":"7f9d4b6a-4f0a-4a8c-9c2e-d5b3b8e6a0f1"}}}]},{"id":"create-project-and-attach-text-source","title":"Create a project and attach an inline text source","status":"available","scopes":["story:write","source:write"],"description":"Two-step flow: POST the project, then POST one or more sources to it. Inline text and markdown are accepted today and extract synchronously (source returns with `status: \"READY\"`). URL and PDF source types come in follow-on phases.","whenToUse":"Any time the agent has a body of text or markdown to register against a project before later generation phases land.","steps":[{"title":"POST /story-projects","description":"Create the workspace. Idempotency-Key is required so retries are safe.","request":{"method":"POST","path":"/story-projects","headers":{"Authorization":"Bearer sh_<prefix>_<secret>","Idempotency-Key":"<your-stable-uuid>","Content-Type":"application/json"},"body":{"name":"Launch storytelling","description":"Q3 product launch"}},"curl":"curl -s https://docs.sleeperhit.studio/api/v1/story-projects \\\n  -H \"Authorization: Bearer sh_<prefix>_<secret>\" \\\n  -H \"Idempotency-Key: $(uuidgen)\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"name\":\"Launch storytelling\",\"description\":\"Q3 product launch\"}'","node":"import { randomUUID } from 'node:crypto'\n\nconst projectRes = await fetch('https://docs.sleeperhit.studio/api/v1/story-projects', {\n  method: 'POST',\n  headers: {\n    Authorization: `Bearer ${process.env.SLEEPERHIT_API_KEY}`,\n    'Idempotency-Key': randomUUID(),\n    'Content-Type': 'application/json',\n  },\n  body: JSON.stringify({ name: 'Launch storytelling', description: 'Q3 product launch' }),\n})\nif (!projectRes.ok) throw new Error(`project create ${projectRes.status}`)\nconst { project } = await projectRes.json()","responseExample":{"project":{"id":"cmp...","name":"Launch storytelling","description":"Q3 product launch","metadata":null,"archivedAt":null,"sourceCount":0,"createdAt":"2026-05-28T15:00:00.000Z","updatedAt":"2026-05-28T15:00:00.000Z"}}},{"title":"POST /story-projects/{projectId}/sources","description":"Attach inline text. `type: \"markdown\"` is also accepted. Response includes `status: \"READY\"` immediately for inline content, plus contentHash, byteSize, tokenEstimate, and the first 4000 characters of extracted text as a preview.","request":{"method":"POST","path":"/story-projects/{projectId}/sources","headers":{"Authorization":"Bearer sh_<prefix>_<secret>","Content-Type":"application/json"},"body":{"type":"text","label":"press-release-draft.md","content":"<the body of the source>"}},"curl":"curl -s https://docs.sleeperhit.studio/api/v1/story-projects/$PROJECT_ID/sources \\\n  -H \"Authorization: Bearer sh_<prefix>_<secret>\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"type\":\"text\",\"label\":\"press-release\",\"content\":\"...\"}'","node":"const sourceRes = await fetch(`https://docs.sleeperhit.studio/api/v1/story-projects/${project.id}/sources`, {\n  method: 'POST',\n  headers: {\n    Authorization: `Bearer ${process.env.SLEEPERHIT_API_KEY}`,\n    'Content-Type': 'application/json',\n  },\n  body: JSON.stringify({ type: 'text', label: 'press-release', content: bodyText }),\n})\nif (!sourceRes.ok) throw new Error(`source create ${sourceRes.status}`)\nconst { source } = await sourceRes.json()","responseExample":{"source":{"id":"cmq...","projectId":"cmp...","type":"TEXT","label":"press-release","status":"READY","retentionPolicy":"EXTRACTED_ONLY","byteSize":4123,"tokenEstimate":1031,"contentHash":"a1b2c3...","extractedTextPreview":"<first 4000 chars>"}}}]},{"id":"project-series-bible","title":"Read, save, or generate a project Series Bible","status":"available","scopes":["story:read","story:write"],"description":"The Series Bible is the durable project-level canon shared by the web UI, Story API, CLI, and MCP. Use it before generating later installments so episode maps, characters, style, and audio direction stay consistent.","whenToUse":"When an agent is planning a series, building an episode arc, saving a user-authored bible, or refreshing project memory from sources/plans/scripts.","steps":[{"title":"POST /story-projects/{projectId}/series-bible","description":"Generate or refresh the bible from project context through DB sidecar `story_api_series_bible`. Use PATCH instead when you already have caller-authored content to save.","request":{"method":"POST","path":"/story-projects/{projectId}/series-bible","headers":{"Authorization":"Bearer sh_<prefix>_<secret>","Idempotency-Key":"<your-stable-uuid>","Content-Type":"application/json"},"body":{"instructions":"Build a 10-episode animated comedy bible with a clear pilot, season arc, character roster, and audio style."}},"curl":"curl -s https://docs.sleeperhit.studio/api/v1/story-projects/$PROJECT_ID/series-bible \\\n  -H \"Authorization: Bearer sh_<prefix>_<secret>\" \\\n  -H \"Idempotency-Key: $(uuidgen)\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"instructions\":\"Build a 10-episode animated comedy bible with a clear pilot, season arc, character roster, and audio style.\"}'","node":"import { randomUUID } from 'node:crypto'\n\nconst bibleRes = await fetch(`https://docs.sleeperhit.studio/api/v1/story-projects/${projectId}/series-bible`, {\n  method: 'POST',\n  headers: {\n    Authorization: `Bearer ${process.env.SLEEPERHIT_API_KEY}`,\n    'Idempotency-Key': randomUUID(),\n    'Content-Type': 'application/json',\n  },\n  body: JSON.stringify({ instructions: 'Refresh the 10-episode arc and character roster.' }),\n})\nif (!bibleRes.ok) throw new Error(`series bible ${bibleRes.status}`)\nconst { document } = await bibleRes.json()","responseExample":{"document":{"id":"cmdoc...","projectId":"cmp...","kind":"series_bible","title":"Series Bible","generatedFrom":null,"currentVersion":2,"createdAt":"2026-06-07T12:00:00.000Z","updatedAt":"2026-06-07T12:34:56.000Z","content":{"logline":"<one-sentence series premise>","format":"10 x 22-minute animated comedy","genre":"","audience":"","tone":"","premise":"","seasonArc":"","themes":"","visualStyle":"","audioStyle":"","writingStyle":"","worldRules":"","canonTimeline":"","openQuestions":"","notes":"","episodes":[{"id":"episode-1","label":"Episode 1","title":"Pilot","summary":"<summary>","status":"planned"}],"characters":[{"id":"character-1","name":"Sam","role":"<role>","want":"<want>","wound":"<wound>","arc":"<arc>","voice":"<voice>","relationships":"<relationships>"}]}}}}]},{"id":"generate-story-plan","title":"Create a StoryPlan from sources and poll until terminal","status":"available","scopes":["story:write","story:read"],"description":"POST a StoryPlan with target (audience + objective + outcome), artifactRequests, creativeBrief, and sourceIds. The API returns immediately with `status: PENDING` and a preliminary quote. The planner runs async via DB sidecar `story_api_plan`. Table-read plans land at `REQUIRES_APPROVAL` with a scriptBlueprint to review; they cannot use autoApprove and generate one script at a time.","whenToUse":"After each sourceId you will pass has status READY, and ideally a digest in hand. The planner will use source digests when available and fall back to extracted-text previews.","steps":[{"title":"POST /story-projects/{projectId}/story-plans","description":"Synchronous validation: target shape, artifact request shape, and sourceIds owned by this project with status READY. Worker runs async.","request":{"method":"POST","path":"/story-projects/{projectId}/story-plans","headers":{"Authorization":"Bearer sh_<prefix>_<secret>","Idempotency-Key":"<your-stable-uuid>","Content-Type":"application/json"},"body":{"target":{"audience":"enterprise buyers","objective":"explain the Q3 product launch","outcome":"book a demo within 7 days","tone":"confident, specific, executive"},"artifactRequests":[{"type":"pitch_deck","durationSeconds":45,"includePdf":true},{"type":"production_video","durationSeconds":15,"aspectRatio":"9:16"}],"sourceIds":["<source-id-1>","<source-id-2>"],"autoApprove":false}},"curl":"curl -s https://docs.sleeperhit.studio/api/v1/story-projects/$PROJECT_ID/story-plans \\\n  -H \"Authorization: Bearer sh_<prefix>_<secret>\" \\\n  -H \"Idempotency-Key: $(uuidgen)\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"target\":{\"audience\":\"enterprise buyers\",\"objective\":\"explain the Q3 product launch\",\"outcome\":\"book a demo within 7 days\"},\"artifactRequests\":[{\"type\":\"pitch_deck\",\"durationSeconds\":45,\"includePdf\":true}],\"sourceIds\":[]}'","node":"import { randomUUID } from 'node:crypto'\n\nconst planRes = await fetch(`https://docs.sleeperhit.studio/api/v1/story-projects/${projectId}/story-plans`, {\n  method: 'POST',\n  headers: {\n    Authorization: `Bearer ${process.env.SLEEPERHIT_API_KEY}`,\n    'Idempotency-Key': randomUUID(),\n    'Content-Type': 'application/json',\n  },\n  body: JSON.stringify({\n    target: {\n      audience: 'enterprise buyers',\n      objective: 'explain the Q3 product launch',\n      outcome: 'book a demo within 7 days',\n    },\n    artifactRequests: [{ type: 'pitch_deck', durationSeconds: 45, includePdf: true }],\n    sourceIds,\n    autoApprove: false,\n  }),\n})\nif (!planRes.ok) throw new Error(`plan create ${planRes.status}`)\nconst { plan } = await planRes.json()"},{"title":"Poll the plan until terminal","description":"Terminal states: `REQUIRES_APPROVAL`, `READY`, `APPROVED`, `REJECTED`, `FAILED`. For table_read, inspect `plan.plan.scriptBlueprint` before approval: page target, narration policy, scene outline, cast, music, and SFX direction.","request":{"method":"GET","path":"/story-plans/{planId}","headers":{"Authorization":"Bearer sh_<prefix>_<secret>"}},"curl":"curl -s https://docs.sleeperhit.studio/api/v1/story-plans/$PLAN_ID \\\n  -H \"Authorization: Bearer sh_<prefix>_<secret>\"","node":"async function poll(planId) {\n  while (true) {\n    const res = await fetch(`https://docs.sleeperhit.studio/api/v1/story-plans/${planId}`, {\n      headers: { Authorization: `Bearer ${process.env.SLEEPERHIT_API_KEY}` },\n    })\n    if (!res.ok) throw new Error(`poll ${res.status}`)\n    const { plan } = await res.json()\n    if (['REQUIRES_APPROVAL', 'READY', 'APPROVED', 'REJECTED', 'FAILED'].includes(plan.status)) return plan\n    await new Promise((r) => setTimeout(r, 3000))\n  }\n}"},{"title":"Approve the plan","description":"Required only when `autoApprove: false`. Idempotent — re-approving an already-approved plan is a no-op.","request":{"method":"POST","path":"/story-plans/{planId}/approve","headers":{"Authorization":"Bearer sh_<prefix>_<secret>"}},"curl":"curl -s -X POST https://docs.sleeperhit.studio/api/v1/story-plans/$PLAN_ID/approve \\\n  -H \"Authorization: Bearer sh_<prefix>_<secret>\"","node":"await fetch(`https://docs.sleeperhit.studio/api/v1/story-plans/${plan.id}/approve`, {\n  method: 'POST',\n  headers: { Authorization: `Bearer ${process.env.SLEEPERHIT_API_KEY}` },\n})"}]},{"id":"ingest-pdf-source","title":"Ingest a PDF source (async, polled)","status":"available","scopes":["source:write","source:read"],"description":"POST a `pdf` source with a public URL pointing at the file. Runs through the same SSRF + redirect + size guards as `url` sources, plus the upstream Content-Type must be `application/pdf`. Worker extracts text with pdfjs and writes `metadata.pageCount`.","whenToUse":"When the agent has a public PDF (white paper, research report, board deck) it wants to register as source material.","steps":[{"title":"POST a pdf source","description":"Returns `status: PENDING`. Poll the source by id until terminal.","request":{"method":"POST","path":"/story-projects/{projectId}/sources","headers":{"Authorization":"Bearer sh_<prefix>_<secret>","Content-Type":"application/json"},"body":{"type":"pdf","uri":"https://example.com/whitepaper.pdf","label":"Q3 whitepaper"}},"curl":"curl -s https://docs.sleeperhit.studio/api/v1/story-projects/$PROJECT_ID/sources \\\n  -H \"Authorization: Bearer sh_<prefix>_<secret>\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"type\":\"pdf\",\"uri\":\"https://example.com/whitepaper.pdf\"}'","node":"const res = await fetch(`https://docs.sleeperhit.studio/api/v1/story-projects/${project.id}/sources`, {\n  method: 'POST',\n  headers: {\n    Authorization: `Bearer ${process.env.SLEEPERHIT_API_KEY}`,\n    'Content-Type': 'application/json',\n  },\n  body: JSON.stringify({ type: 'pdf', uri: 'https://example.com/whitepaper.pdf' }),\n})\nif (!res.ok) throw new Error(`pdf source create ${res.status}`)\nconst { source } = await res.json()","responseExample":{"source":{"id":"cmq...","projectId":"cmp...","type":"PDF","uri":"https://example.com/whitepaper.pdf","status":"READY","byteSize":124500,"tokenEstimate":8421,"contentHash":"b2c3d4...","extractedTextPreview":"<first 4000 chars>","metadata":{"pageCount":12}}}}]},{"id":"ingest-url-source","title":"Ingest a URL source (async, polled)","status":"available","scopes":["source:write","source:read"],"description":"POST a `url` source, then poll until `status: READY` (or `FAILED`). The fetch happens in a pg-boss worker behind SSRF, redirect, content-type, and size guards. Source `failureCode` and `failureMessage` describe rejection reasons (e.g. `source_fetch_failed` with `details.reason = \"ip_blocked\"`).","whenToUse":"When the agent has a public URL whose extracted text should be available for later planning. Use only on URLs reachable from the public internet — private/cloud-metadata addresses are blocked.","steps":[{"title":"POST a url source","description":"Returns immediately with `status: \"PENDING\"`.","request":{"method":"POST","path":"/story-projects/{projectId}/sources","headers":{"Authorization":"Bearer sh_<prefix>_<secret>","Content-Type":"application/json"},"body":{"type":"url","uri":"https://example.com/article","label":"launch article"}},"curl":"curl -s https://docs.sleeperhit.studio/api/v1/story-projects/$PROJECT_ID/sources \\\n  -H \"Authorization: Bearer sh_<prefix>_<secret>\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"type\":\"url\",\"uri\":\"https://example.com/article\",\"label\":\"launch article\"}'","node":"const res = await fetch(`https://docs.sleeperhit.studio/api/v1/story-projects/${project.id}/sources`, {\n  method: 'POST',\n  headers: {\n    Authorization: `Bearer ${process.env.SLEEPERHIT_API_KEY}`,\n    'Content-Type': 'application/json',\n  },\n  body: JSON.stringify({ type: 'url', uri: 'https://example.com/article', label: 'launch article' }),\n})\nif (!res.ok) throw new Error(`source create ${res.status}`)\nconst { source } = await res.json()"},{"title":"Poll until terminal","description":"Poll once every 2–5 seconds. Terminal states are `READY` (extracted text + content hash + token estimate available) and `FAILED` (see `failureCode`).","request":{"method":"GET","path":"/story-projects/{projectId}/sources/{sourceId}","headers":{"Authorization":"Bearer sh_<prefix>_<secret>"}},"curl":"curl -s https://docs.sleeperhit.studio/api/v1/story-projects/$PROJECT_ID/sources/$SOURCE_ID \\\n  -H \"Authorization: Bearer sh_<prefix>_<secret>\"","node":"async function poll(projectId, sourceId) {\n  while (true) {\n    const res = await fetch(`https://docs.sleeperhit.studio/api/v1/story-projects/${projectId}/sources/${sourceId}`, {\n      headers: { Authorization: `Bearer ${process.env.SLEEPERHIT_API_KEY}` },\n    })\n    if (!res.ok) throw new Error(`poll ${res.status}`)\n    const { source } = await res.json()\n    if (source.status === 'READY' || source.status === 'FAILED') return source\n    await new Promise((r) => setTimeout(r, 3000))\n  }\n}","responseExample":{"source":{"id":"cmq...","projectId":"cmp...","type":"URL","uri":"https://example.com/article","status":"READY","byteSize":8421,"tokenEstimate":2106,"contentHash":"a1b2c3...","extractedTextPreview":"<first 4000 chars>","extractedAt":"2026-05-28T15:10:00.000Z"}}}]},{"id":"refine-table-read","title":"Refine a table read artifact with a free-form instruction","status":"available","scopes":["artifact:publish","story:read","artifact:read"],"description":"POST a conversational instruction to revise the screenplay, recast characters, or adjust tone without creating a new artifact. Share URLs remain stable. Returns a storyJob to poll. Reserves TABLE_READ_REFINE (8 credits).","whenToUse":"After a `table_read` artifact is `READY` and you want to adjust cast or tone without re-running the full plan-to-job flow. Requires scope `artifact:publish`.","steps":[{"title":"POST /artifacts/{artifactId}/refine","description":"Supply a free-form `instruction`. `scope`: `\"auto\"` (default), `\"screenplay\"` (cast + screenplay only), or `\"plan\"` (re-grounds against sources first). Requires `Idempotency-Key`.","request":{"method":"POST","path":"/artifacts/{artifactId}/refine","headers":{"Authorization":"Bearer sh_<prefix>_<secret>","Idempotency-Key":"<your-stable-uuid>","Content-Type":"application/json"},"body":{"instruction":"rename the host to Dana and drop the narrator; make the skeptic warmer","scope":"screenplay"}},"curl":"curl -s https://docs.sleeperhit.studio/api/v1/artifacts/$ARTIFACT_ID/refine \\\n  -H \"Authorization: Bearer sh_<prefix>_<secret>\" \\\n  -H \"Idempotency-Key: $(uuidgen)\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"instruction\":\"rename the host to Dana and drop the narrator; make the skeptic warmer\",\"scope\":\"screenplay\"}'","node":"import { randomUUID } from 'node:crypto'\nconst refineRes = await fetch(`https://docs.sleeperhit.studio/api/v1/artifacts/${artifactId}/refine`, {\n  method: 'POST',\n  headers: { Authorization: `Bearer ${process.env.SLEEPERHIT_API_KEY}`, 'Idempotency-Key': randomUUID(), 'Content-Type': 'application/json' },\n  body: JSON.stringify({ instruction: 'rename the host to Dana; make the skeptic warmer', scope: 'screenplay' }),\n})\nif (!refineRes.ok) throw new Error(`refine ${refineRes.status}`)\nconst { storyJob } = await refineRes.json()","responseExample":{"storyJob":{"id":"cmj...","status":"RESERVED"}}},{"title":"Poll GET /story-jobs/{jobId} until terminal","description":"Terminal states: `READY`, `FAILED`. On `READY`, GET /artifacts/{artifactId} shows updated `currentVersion` + `refineCount`. Fetch prior revisions with `?revision=N`.","request":{"method":"GET","path":"/story-jobs/{jobId}","headers":{"Authorization":"Bearer sh_<prefix>_<secret>"}},"curl":"curl -s https://docs.sleeperhit.studio/api/v1/story-jobs/$JOB_ID \\\n  -H \"Authorization: Bearer sh_<prefix>_<secret>\"","node":"// Poll until refine job is terminal\nwhile (true) {\n  const { job } = await fetch(`https://docs.sleeperhit.studio/api/v1/story-jobs/${storyJob.id}`, {\n    headers: { Authorization: `Bearer ${process.env.SLEEPERHIT_API_KEY}` },\n  }).then((r) => r.json())\n  if (['READY', 'FAILED', 'CANCELED'].includes(job.status)) {\n    if (job.status !== 'READY') throw new Error(`refine ${job.status}`)\n    break\n  }\n  await new Promise((r) => setTimeout(r, 6000))\n}\n// Original artifact id + share URLs are unchanged. Check the updated version:\nconst { artifact } = await fetch(`https://docs.sleeperhit.studio/api/v1/artifacts/${artifactId}`, {\n  headers: { Authorization: `Bearer ${process.env.SLEEPERHIT_API_KEY}` },\n}).then((r) => r.json())\nconsole.log(`v${artifact.currentVersion}, refineCount: ${artifact.refineCount}`)"}]},{"id":"modify-voice-range","title":"Apply a voice effect to a range of lines","status":"available","scopes":["artifact:publish"],"description":"POST /voice-modification to apply a voice effect (currently only autotune) to a contiguous range of dialogue entries on a table-read artifact. No new revision and share URLs stay stable. The autotune recipe is optional and partial: omitted fields fall back to the proven defaults (key D, scale minpent, strength 1.0, smooth 1, reverb chapel). Returns { artifactId, modificationId, status }; the rendered audio is projected onto the read once status is `ready`.","whenToUse":"After a `table_read` artifact is `READY` and you want a stylized (autotuned) pass over specific lines without re-running the plan-to-job flow. Requires scope `artifact:publish`.","steps":[{"title":"POST /artifacts/{artifactId}/voice-modification","description":"Supply the inclusive `startEntryIndex`/`endEntryIndex` range. `effect` defaults to `\"autotune\"`; the `params` recipe is optional and merged over the defaults. Requires `Idempotency-Key`.","request":{"method":"POST","path":"/artifacts/{artifactId}/voice-modification","headers":{"Authorization":"Bearer sh_<prefix>_<secret>","Idempotency-Key":"<your-stable-uuid>","Content-Type":"application/json"},"body":{"startEntryIndex":4,"endEntryIndex":9,"effect":"autotune","params":{"key":"D","scale":"minpent","strength":1,"smooth":1,"reverb":"chapel"}}},"curl":"curl -s https://docs.sleeperhit.studio/api/v1/artifacts/$ARTIFACT_ID/voice-modification \\\n  -H \"Authorization: Bearer sh_<prefix>_<secret>\" \\\n  -H \"Idempotency-Key: $(uuidgen)\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"startEntryIndex\":4,\"endEntryIndex\":9,\"effect\":\"autotune\",\"params\":{\"key\":\"D\",\"scale\":\"minpent\",\"strength\":1.0,\"smooth\":1,\"reverb\":\"chapel\"}}'","node":"import { randomUUID } from 'node:crypto'\nconst modRes = await fetch(`https://docs.sleeperhit.studio/api/v1/artifacts/${artifactId}/voice-modification`, {\n  method: 'POST',\n  headers: { Authorization: `Bearer ${process.env.SLEEPERHIT_API_KEY}`, 'Idempotency-Key': randomUUID(), 'Content-Type': 'application/json' },\n  // params is optional; omit it to use the proven defaults.\n  body: JSON.stringify({ startEntryIndex: 4, endEntryIndex: 9 }),\n})\nif (!modRes.ok) throw new Error(`voice-modification ${modRes.status}`)\nconst { modificationId, status } = await modRes.json()","responseExample":{"artifactId":"cma...","modificationId":"cmvm...","status":"queued"}}]},{"id":"recast-avatars","title":"Regenerate a character avatar, restyle the cast, and poll the enriched cast","status":"available","scopes":["artifact:publish","artifact:read"],"description":"POST /avatar to re-render ONE character avatar, POST /cast to restyle ALL portraits in one call, then GET /cast to poll each character's avatarStatus until the renders are ready. Avatar work is async + queue-backed; voice reassignments apply immediately. No new revision and share URLs stay stable.","whenToUse":"After a `table_read` artifact is `READY` and you want to refresh one character's portrait, restyle the whole cast, or batch-reassign voices — without re-running the plan-to-job flow. Avatar ops need scope `artifact:publish`; reading the cast needs `artifact:read`.","steps":[{"title":"POST /artifacts/{artifactId}/avatar — regenerate one character","description":"Supply the `character` to re-render. `refinement` is a freeform art-direction nudge; `style` overrides the resolved avatar style for this one render. Requires `Idempotency-Key`. Returns `{ character, status, chatToolJobId }`; the render is async.","request":{"method":"POST","path":"/artifacts/{artifactId}/avatar","headers":{"Authorization":"Bearer sh_<prefix>_<secret>","Idempotency-Key":"<your-stable-uuid>","Content-Type":"application/json"},"body":{"character":"MAYA","refinement":"older, weathered, dim backlight"}},"curl":"curl -s https://docs.sleeperhit.studio/api/v1/artifacts/$ARTIFACT_ID/avatar \\\n  -H \"Authorization: Bearer sh_<prefix>_<secret>\" \\\n  -H \"Idempotency-Key: $(uuidgen)\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"character\":\"MAYA\",\"refinement\":\"older, weathered, dim backlight\"}'","node":"import { randomUUID } from 'node:crypto'\nconst avatarRes = await fetch(`https://docs.sleeperhit.studio/api/v1/artifacts/${artifactId}/avatar`, {\n  method: 'POST',\n  headers: { Authorization: `Bearer ${process.env.SLEEPERHIT_API_KEY}`, 'Idempotency-Key': randomUUID(), 'Content-Type': 'application/json' },\n  body: JSON.stringify({ character: 'MAYA', refinement: 'older, weathered, dim backlight' }),\n})\nif (!avatarRes.ok) throw new Error(`avatar ${avatarRes.status}`)\nconst { character, status, chatToolJobId } = await avatarRes.json()","responseExample":{"artifactId":"cma...","character":"MAYA","status":"queued","chatToolJobId":"cmtj..."}},{"title":"POST /artifacts/{artifactId}/cast — restyle the whole cast","description":"A top-level `avatarStyle` with no `entries` re-renders EVERY character against the new art-direction (provide at least one `entries` item OR an `avatarStyle`). Mix in `entries` to also reassign voices or target per-character avatar renders. Requires `Idempotency-Key`. Returns the updated `voiceMap` plus one async avatar op per queued render.","request":{"method":"POST","path":"/artifacts/{artifactId}/cast","headers":{"Authorization":"Bearer sh_<prefix>_<secret>","Idempotency-Key":"<your-stable-uuid>","Content-Type":"application/json"},"body":{"avatarStyle":"noir graphite ink portraits"}},"curl":"curl -s https://docs.sleeperhit.studio/api/v1/artifacts/$ARTIFACT_ID/cast \\\n  -H \"Authorization: Bearer sh_<prefix>_<secret>\" \\\n  -H \"Idempotency-Key: $(uuidgen)\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"avatarStyle\":\"noir graphite ink portraits\"}'","node":"import { randomUUID } from 'node:crypto'\nconst castRes = await fetch(`https://docs.sleeperhit.studio/api/v1/artifacts/${artifactId}/cast`, {\n  method: 'POST',\n  headers: { Authorization: `Bearer ${process.env.SLEEPERHIT_API_KEY}`, 'Idempotency-Key': randomUUID(), 'Content-Type': 'application/json' },\n  // Restyle every portrait; you could also send entries:[{ character, voiceId, voiceName }] to reassign voices.\n  body: JSON.stringify({ avatarStyle: 'noir graphite ink portraits' }),\n})\nif (!castRes.ok) throw new Error(`cast ${castRes.status}`)\nconst { voiceMap, avatars } = await castRes.json()\nconsole.log(`${avatars.length} avatar renders queued`)","responseExample":{"artifactId":"cma...","voiceMap":{"MAYA":{"voiceId":"v_123","voiceName":"Maya","provider":"cartesia"}},"avatars":[{"character":"MAYA","status":"queued","chatToolJobId":"cmtj..."}]}},{"title":"Poll GET /artifacts/{artifactId}/cast until avatars are ready","description":"Read the enriched cast; each character carries `avatarUrl` / `avatarStyle` / `avatarStatus`. Poll until every in-flight `avatarStatus` leaves `queued` / `rendering` (terminal: `ready` or `failed`). `none` means the character has no avatar.","request":{"method":"GET","path":"/artifacts/{artifactId}/cast","headers":{"Authorization":"Bearer sh_<prefix>_<secret>"}},"curl":"curl -s https://docs.sleeperhit.studio/api/v1/artifacts/$ARTIFACT_ID/cast \\\n  -H \"Authorization: Bearer sh_<prefix>_<secret>\" | jq .cast","node":"async function pollCast(artifactId) {\n  const pending = new Set(['queued', 'rendering'])\n  while (true) {\n    const res = await fetch(`https://docs.sleeperhit.studio/api/v1/artifacts/${artifactId}/cast`, {\n      headers: { Authorization: `Bearer ${process.env.SLEEPERHIT_API_KEY}` },\n    })\n    if (!res.ok) throw new Error(`poll ${res.status}`)\n    const { cast } = await res.json()\n    if (!cast.some((c) => pending.has(c.avatarStatus))) return cast\n    await new Promise((r) => setTimeout(r, 5000))\n  }\n}\nconst cast = await pollCast(artifactId)\nconsole.log(cast.map((c) => `${c.character}: ${c.avatarStatus} ${c.avatarUrl ?? ''}`).join('\\n'))","responseExample":{"artifactId":"cma...","avatarStyle":"noir graphite ink portraits","cast":[{"character":"MAYA","voiceId":"v_123","voiceName":"Maya","gender":"female","provider":"cartesia","avatarUrl":"https://example.com/avatars/maya.png","avatarStyle":"noir graphite ink portraits","avatarStatus":"ready"}]}}]},{"id":"finalize-audio-mp3","title":"Finalize a table read artifact as a durable MP3","status":"available","scopes":["artifact:publish"],"description":"POST /finalize to freeze the completed Pipecat/Daily table-read recording into a permanent MP3 alongside the live share. Reserves TABLE_READ_FINALIZE_AUDIO (6 credits) only when a completed recording is available. Replay-safe: re-calling when complete or in-flight returns current state without re-charging.","whenToUse":"When you need a downloadable audio file (email delivery, archival, audio platform upload) alongside the live performance.","steps":[{"title":"POST /artifacts/{artifactId}/finalize","description":"Omit `mode` or pass `\"audio\"` to finalize an MP3 from the completed live-read recording. Requires `Idempotency-Key`. Returns `{ artifactId, mode, finalize: { status } }` immediately; `needs_recording` means start/record the live read first.","request":{"method":"POST","path":"/artifacts/{artifactId}/finalize","headers":{"Authorization":"Bearer sh_<prefix>_<secret>","Idempotency-Key":"<your-stable-uuid>","Content-Type":"application/json"},"body":{"mode":"audio"}},"curl":"curl -s https://docs.sleeperhit.studio/api/v1/artifacts/$ARTIFACT_ID/finalize \\\n  -H \"Authorization: Bearer sh_<prefix>_<secret>\" \\\n  -H \"Idempotency-Key: $(uuidgen)\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"mode\":\"audio\"}'","node":"import { randomUUID } from 'node:crypto'\nconst finalizeRes = await fetch(`https://docs.sleeperhit.studio/api/v1/artifacts/${artifactId}/finalize`, {\n  method: 'POST',\n  headers: { Authorization: `Bearer ${process.env.SLEEPERHIT_API_KEY}`, 'Idempotency-Key': randomUUID(), 'Content-Type': 'application/json' },\n  body: JSON.stringify({ mode: 'audio' }),\n})\nif (!finalizeRes.ok) throw new Error(`finalize ${finalizeRes.status}`)\nconst { finalize } = await finalizeRes.json()\nconsole.log('Finalize status:', finalize.status) // 'queued' or 'needs_recording'","responseExample":{"artifactId":"cma...","mode":"audio","finalize":{"status":"queued"}}},{"title":"Poll GET /artifacts/{artifactId} until finalize is complete","description":"Check `manifest.audio.finalize.status`: `needs_recording`, or `queued → rendering → uploading → complete` (or `failed`). On `complete`: `manifest.audio.recordingUrl` (MP3) and `manifest.audio.recordingDurationMs`.","request":{"method":"GET","path":"/artifacts/{artifactId}","headers":{"Authorization":"Bearer sh_<prefix>_<secret>"}},"curl":"curl -s https://docs.sleeperhit.studio/api/v1/artifacts/$ARTIFACT_ID \\\n  -H \"Authorization: Bearer sh_<prefix>_<secret>\" | jq .artifact.manifest.audio.finalize","node":"async function pollFinalize(artifactId) {\n  const terminal = new Set(['complete', 'failed'])\n  while (true) {\n    const res = await fetch(`https://docs.sleeperhit.studio/api/v1/artifacts/${artifactId}`, {\n      headers: { Authorization: `Bearer ${process.env.SLEEPERHIT_API_KEY}` },\n    })\n    if (!res.ok) throw new Error(`poll ${res.status}`)\n    const { artifact } = await res.json()\n    const s = artifact.manifest?.audio?.finalize?.status\n    if (terminal.has(s)) return artifact\n    await new Promise((r) => setTimeout(r, 7000))\n  }\n}\nconst finalized = await pollFinalize(artifactId)\nif (finalized.manifest.audio.finalize.status === 'failed') {\n  throw new Error(finalized.manifest.audio.finalize.error ?? \"finalize failed\")\n}\nconsole.log(\"MP3 URL:\", finalized.manifest.audio.recordingUrl)\nconsole.log(\"Duration (ms):\", finalized.manifest.audio.recordingDurationMs)"}]},{"id":"idempotent-post-recipe","title":"Safely retry expensive POSTs with Idempotency-Key","status":"planned","scopes":["story:write"],"description":"Every idempotent POST that reserves Studio Credits, queues generation work, or mutates an artifact requires `Idempotency-Key`. A retry with the same key returns the original response with `Idempotency-Replayed: true` instead of double-charging or double-queuing.","whenToUse":"Once generation endpoints land in a later phase. The mechanics here are stable today via `runCustomerApiIdempotentJson`.","steps":[{"title":"POST with Idempotency-Key","description":"Generate one stable identifier per logical request (e.g., a job UUID owned by your system). Retry the exact same request body if the network fails — the API replays the original response.","request":{"method":"POST","path":"/story-jobs","headers":{"Authorization":"Bearer sh_<prefix>_<secret>","Idempotency-Key":"<your-stable-uuid>","Content-Type":"application/json"},"body":{"projectId":"proj_<id>","artifactRequests":[{"type":"table_read","mode":"markdown"}]}},"curl":"curl -s https://docs.sleeperhit.studio/api/v1/story-jobs \\\n  -H \"Authorization: Bearer sh_<prefix>_<secret>\" \\\n  -H \"Idempotency-Key: $(uuidgen)\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"projectId\":\"proj_<id>\",\"artifactRequests\":[{\"type\":\"table_read\",\"mode\":\"markdown\"}]}'","node":"import { randomUUID } from 'node:crypto'\n\nconst idempotencyKey = randomUUID()\nasync function submit() {\n  return fetch('https://docs.sleeperhit.studio/api/v1/story-jobs', {\n    method: 'POST',\n    headers: {\n      Authorization: `Bearer ${process.env.SLEEPERHIT_API_KEY}`,\n      'Idempotency-Key': idempotencyKey,\n      'Content-Type': 'application/json',\n    },\n    body: JSON.stringify({ projectId: 'proj_<id>', artifactRequests: [{ type: 'table_read', mode: 'markdown' }] }),\n  })\n}\n\nlet res = await submit()\nif (!res.ok && res.status >= 500) {\n  // Network or 5xx — retry with the same key returns the original outcome safely.\n  res = await submit()\n}"}]},{"id":"generate-table-read","title":"Generate a table read from an approved plan, then read the share URLs","status":"available","scopes":["story:write","story:read","artifact:read"],"description":"Once a StoryPlan is APPROVED, POST a story job to generate a `table_read`. The approved plan must include a reviewed scriptBlueprint, and each job produces one script/installment. The job reserves Studio Credits, runs the existing table-read pipeline headlessly (parse, voice assignment, creative analysis, adaptive music, SFX), and mints a public share link. Poll the job until terminal, then read the artifact manifest and/or GET the backing script content. The MP4 `video` output is opt-in and never rendered by default.","whenToUse":"After a plan reaches `APPROVED`. The table read is the first live artifact adapter. The returned share token (not your API key) authorizes the URLs, so you can embed them or hand them to end-users.","steps":[{"title":"POST /story-jobs","description":"Reference the approved plan. Omit `artifactRequests` to use the plan's own requests, or pass one `[{ \"type\": \"table_read\", \"mode\": \"documentary\", \"narrationPolicy\": \"include\" }]` (modes: `documentary | podcast | drama`; narrationPolicy: `auto | include | suppress`). Idempotency-Key is required. A `402 insufficient_credits` means the reservation could not be covered.","request":{"method":"POST","path":"/story-jobs","headers":{"Authorization":"Bearer sh_<prefix>_<secret>","Idempotency-Key":"<your-stable-uuid>","Content-Type":"application/json"},"body":{"storyPlanId":"<approved-plan-id>","artifactRequests":[{"type":"table_read","mode":"documentary","narrationPolicy":"include"}]}},"curl":"curl -s https://docs.sleeperhit.studio/api/v1/story-jobs \\\n  -H \"Authorization: Bearer sh_<prefix>_<secret>\" \\\n  -H \"Idempotency-Key: $(uuidgen)\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"storyPlanId\":\"<approved-plan-id>\",\"artifactRequests\":[{\"type\":\"table_read\",\"mode\":\"documentary\",\"narrationPolicy\":\"include\"}]}'","node":"import { randomUUID } from 'node:crypto'\n\nconst jobRes = await fetch('https://docs.sleeperhit.studio/api/v1/story-jobs', {\n  method: 'POST',\n  headers: {\n    Authorization: `Bearer ${process.env.SLEEPERHIT_API_KEY}`,\n    'Idempotency-Key': randomUUID(),\n    'Content-Type': 'application/json',\n  },\n  body: JSON.stringify({ storyPlanId, artifactRequests: [{ type: 'table_read', mode: 'documentary', narrationPolicy: 'include' }] }),\n})\nif (!jobRes.ok) throw new Error(`job create ${jobRes.status}`)\nconst { job } = await jobRes.json()","responseExample":{"job":{"id":"cmj...","projectId":"cmp...","storyPlanId":"cmpl...","status":"RESERVED","artifactRequests":[{"type":"table_read","mode":"documentary","narrationPolicy":"include"}],"artifacts":[],"createdAt":"2026-05-28T15:20:00.000Z","updatedAt":"2026-05-28T15:20:00.000Z"}}},{"title":"Poll GET /story-jobs/{jobId} until terminal","description":"Terminal states: `READY`, `PARTIAL`, `FAILED`, `CANCELED`. When `READY`, the `artifacts[]` entries carry their manifest URLs.","request":{"method":"GET","path":"/story-jobs/{jobId}","headers":{"Authorization":"Bearer sh_<prefix>_<secret>"}},"curl":"curl -s https://docs.sleeperhit.studio/api/v1/story-jobs/$JOB_ID \\\n  -H \"Authorization: Bearer sh_<prefix>_<secret>\"","node":"async function poll(jobId) {\n  while (true) {\n    const res = await fetch(`https://docs.sleeperhit.studio/api/v1/story-jobs/${jobId}`, {\n      headers: { Authorization: `Bearer ${process.env.SLEEPERHIT_API_KEY}` },\n    })\n    if (!res.ok) throw new Error(`poll ${res.status}`)\n    const { job } = await res.json()\n    if (['READY', 'PARTIAL', 'FAILED', 'CANCELED'].includes(job.status)) return job\n    await new Promise((r) => setTimeout(r, 5000))\n  }\n}","responseExample":{"job":{"id":"cmj...","status":"READY","artifacts":[{"id":"cma...","type":"table_read","status":"READY","manifest":{"type":"table_read","status":"ready","shareToken":"<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-28T15:20:00.000Z"}}]}}},{"title":"Read actual script content","description":"After the table-read artifact is READY, GET the backing script by page, scene, character, or entry range. Use this for terminal/MCP agents that need to answer \"what is on page 4?\" or \"which scene is strongest?\" from the real generated text.","request":{"method":"GET","path":"/artifacts/{artifactId}/script?scope=page&page=1","headers":{"Authorization":"Bearer sh_<prefix>_<secret>"}},"curl":"curl -s \"https://docs.sleeperhit.studio/api/v1/artifacts/$ARTIFACT_ID/script?scope=page&page=1\" \\\n  -H \"Authorization: Bearer sh_<prefix>_<secret>\"","node":"const artifact = job.artifacts.find((a) => a.type === 'table_read')\nconst scriptRes = await fetch(`https://docs.sleeperhit.studio/api/v1/artifacts/${artifact.id}/script?scope=page&page=1`, {\n  headers: { Authorization: `Bearer ${process.env.SLEEPERHIT_API_KEY}` },\n})\nconst { script } = await scriptRes.json()\nconsole.log(script.selection.entries.map((e) => `${e.character}: ${e.text}`).join(\"\\n\"))","responseExample":{"script":{"artifactId":"cma...","scope":"page","pageCount":4,"totalEntries":42,"scenes":[{"sceneIndex":0,"heading":"INT. LAB - NIGHT","startEntry":0,"endEntry":12}],"characters":[{"character":"NARRATOR","lineCount":8,"wordCount":140}],"selection":{"pageNumber":1,"startEntry":0,"endEntry":10,"entries":[{"entryIndex":0,"character":"NARRATOR","text":"The lab is quiet except for the relay clicks.","isNarration":true,"sceneHeading":"INT. LAB - NIGHT","parenthetical":null,"page":1}]}}}},{"title":"Open a player, or drive the live feed yourself","description":"Three ready-to-open players, all authorized by the share token: `theaterUrl` (framed theater), `theaterFullscreenUrl` (immersive, chrome-free), and `audio.liveUrl` (audio-only). Each starts the live read on its own. To drive it directly instead, POST the share token to `audio.startLiveUrl` — it spins up an on-demand Pipecat/Daily live read and returns a `dailyRoomUrl` your end-user joins. No prior recording is needed; the read is live-performable from creation. The opt-in MP4 lives at `video.renderUrl` (separate charge).","request":{"method":"POST","path":"/api/share/table-read/start-live","headers":{"Content-Type":"application/json"},"body":{"token":"<shareToken from the manifest>"}},"curl":"curl -s https://sleeperhit.studio/api/share/table-read/start-live \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"token\":\"<shareToken>\"}'","node":"const artifact = job.artifacts.find((a) => a.type === 'table_read')\nconst manifest = artifact.manifest\n// Ready-to-open players (share token authorizes them):\nconsole.log(manifest.theaterUrl)            // framed theater\nconsole.log(manifest.theaterFullscreenUrl)  // immersive, no chrome\nconsole.log(manifest.audio.liveUrl)         // audio-only player\n// ...or drive the on-demand live audio performance directly:\nconst liveRes = await fetch(manifest.audio.startLiveUrl, {\n  method: 'POST',\n  headers: { 'Content-Type': 'application/json' },\n  body: JSON.stringify({ token: manifest.shareToken }),\n})\nconst { dailyRoomUrl } = await liveRes.json()"}]}]}