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. Addartifact:publishfor optional refine, voice recast, finalize, render-video, music, and SFX mutations. If any scope is missing the API returnsinsufficient_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.
curl -s https://sleeperhit.studio/api/v1/capabilities \ -H "Authorization: Bearer $SLEEPERHIT_API_KEY" | jq .
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—truemeans you can skip the human-in-the-loop approval step.
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.
curl -s https://sleeperhit.studio/api/v1/credits \ -H "Authorization: Bearer $SLEEPERHIT_API_KEY" | jq .credits.balance
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:
{ "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.
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"}'
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:
{ "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.
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>"}'
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.
# 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
// 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.
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:
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 — 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.
Pass autoApprove: true to skip the human hold. Pass autoApprove: false (the default) if you want to inspect the plan and quote before credits are reserved.
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>"], "autoApprove": true }'
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], autoApprove: true, }), } ) 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.
With autoApprove: true the worker lands at APPROVED directly. Without it, the plan lands at REQUIRES_APPROVAL and you must POST to /approve.
curl -s https://sleeperhit.studio/api/v1/story-plans/$PLAN_ID \ -H "Authorization: Bearer $SLEEPERHIT_API_KEY" | jq .plan.status
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') { // Inspect approvedPlan.quote.total and approvedPlan.plan.journey.beats before approving await fetch(`https://sleeperhit.studio/api/v1/story-plans/${planId}/approve`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.SLEEPERHIT_API_KEY}` }, }) }
Once approved, inspect the quote field:
{ "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.
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>"}'
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
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}'
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.
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:
{ "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.
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.
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:
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 music:
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:
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}'
Add a timed sound effect cue:
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"}'
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):
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:
curl -s -X POST https://sleeperhit.studio/api/v1/artifacts/$ARTIFACT_ID/render-video \ -H "Authorization: Bearer $SLEEPERHIT_API_KEY" \ -H "Idempotency-Key: $(uuidgen)"
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.
import { randomUUID } from 'node:crypto' 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 plan (autoApprove skips the manual 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], autoApprove: true, }), }) // 5. Poll plan to APPROVED const approvedPlan = await poll( () => api(`/story-plans/${pendingPlan.id}`).then((r) => r.plan), (p) => ['APPROVED', 'REJECTED', 'FAILED'].includes(p.status), 4000 ) 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_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 for the full table.