Concepts
This page defines the core abstractions in the Sleeper Hit Studio Story API. Understanding these concepts is prerequisite to using the API effectively and interpreting its responses.
Projects
A StoryProject is a named workspace that groups sources, plans, and jobs together. Every source, plan, and job belongs to exactly one project. Projects are long-lived — you can attach more sources and generate new plans against the same project over time.
Key properties:
id— stable string ID.name— human-readable label (1–200 chars).description— optional free-form context (up to 4000 chars).metadata— arbitrary JSON blob for your own system's cross-reference data.archivedAt— soft-delete timestamp; archived projects still exist and can be read.sourceCount— count of non-deleted sources attached to this project.
A project carries no generative logic itself. It is purely a grouping and ownership boundary — every resource created under a project is scoped to the account that owns the project.
Sources
A StorySource is a single piece of raw material attached to a project. The planner reads sources and uses them as evidence for the narrative. Sources can be inline text, markdown, a URL to a web page, or a URL pointing at a PDF.
Source types
| Type | Extraction | Content type required |
|---|---|---|
text | Synchronous — READY immediately | n/a (inline) |
markdown | Synchronous — READY immediately | n/a (inline) |
url | Async (pg-boss worker) — poll status | text/html, text/plain, text/markdown, application/json, application/xhtml+xml |
pdf | Async (pg-boss worker) — poll status | application/pdf |
Source lifecycle (status)
PENDING --> EXTRACTING --> READY
--> FAILED
For inline types (text, markdown) the transition is instant and the response already carries READY. For async types (url, pdf) the response returns PENDING; poll GET /story-projects/{projectId}/sources/{sourceId} until terminal.
Source fields
When status: READY a source carries:
byteSize— byte count of the extracted text.tokenEstimate— estimated token count (used for quota calculations).contentHash— SHA-256 of the extracted content (deduplication signal).extractedTextPreview— first 4000 characters of extracted text.extractedAt— timestamp when extraction completed.metadata— type-specific metadata (e.g.pageCountfor PDF sources).
On FAILED:
failureCode— machine-readable reason (e.g.source_fetch_failed).failureMessage— human-readable description of what went wrong.
Retention policies
Every source has a retentionPolicy that controls what the API stores after extraction:
| Policy | What is kept |
|---|---|
EXTRACTED_ONLY (default) | Extracted text + metadata; raw bytes discarded |
STORE_ORIGINAL | Extracted text + raw bytes |
METADATA_ONLY | Metadata only; extracted text discarded after digest |
METADATA_ONLY sources can still participate in planning (via the digest), but the planner cannot fall back to raw extracted text if the digest is SKIPPED.
SSRF guards (URL and PDF)
The API blocks sources that could expose internal infrastructure:
- Private IP ranges (RFC 1918), link-local (
169.254.x.x), loopback, multicast. - Cloud-metadata endpoints (AWS
169.254.169.254, GCPmetadata.google.internal, etc.). - Maximum 3 redirects followed.
- Maximum 10 MB response body.
- Content-type must match the allowlist for the source type.
Sources that fail these checks land at FAILED with failureCode: source_fetch_failed.
Source digest
After a source reaches READY with extracted text, the API queues a structured digest pass. The digest is produced with structured-output generation — no free-form JSON from the model; the output is validated against a strict schema.
Digest lifecycle (digestStatus)
NOT_STARTED --> PENDING --> RUNNING --> READY
--> FAILED
--> SKIPPED
SKIPPED is the normal terminal state when:
- The digest sidecar or provider is not configured in this environment.
- The source has
retentionPolicy: METADATA_ONLYand no extracted text is available. - The extracted text is too short to be useful.
A SKIPPED digest does not prevent the source from being used in planning.
Digest fields (when digestStatus: READY)
{ "summary": "3-6 sentence plain-prose summary of the source", "thesis": "Single sentence capturing the dominant point", "claims": [ { "statement": "Acme reduced onboarding time by 60%", "kind": "estimate", "attribution": "Acme internal study, 2025" } ], "evidence": [ { "claimIndex": 0, "support": "Quote from the study supporting claim 0" } ], "gaps": ["Does not address enterprise pricing"], "rightsNotes": ["Contains proprietary Acme research — check usage rights"] }
Claim kinds: fact, estimate, opinion, forecast, quotation, definition.
The digest is source-grounded: the model is instructed not to invent claims the source does not make. rightsNotes surfaces any usage concerns the model detects (copyright language, restricted data, etc.).
StoryPlan
A StoryPlan is the central planning abstraction. It is a source-grounded narrative structure with thesis, evidence, beats, visual system, and audio system — plus a deterministic Studio Credit quote for the requested artifacts.
Plans are generated asynchronously. The API creates the plan record immediately and the generation worker runs in the background.
Plan lifecycle (status)
PENDING --> RUNNING --> REQUIRES_APPROVAL
--> APPROVED (when autoApprove: true, or after /approve)
--> FAILED
--> SUPERSEDED (when a new plan is created for the same project)
REJECTED is the terminal state when the account explicitly calls POST /story-plans/{planId}/reject.
The autoApprove flag
autoApprove: true— the worker transitions the plan directly toAPPROVEDon success. Use this when you trust the plan output and want fully automated flows.autoApprove: false(default) — the worker lands atREQUIRES_APPROVAL. You can inspectplan.plan(the narrative envelope) andplan.quote(the credit estimate) before callingPOST /story-plans/{planId}/approveorPOST /story-plans/{planId}/reject.
The plan envelope
When status is APPROVED (or REQUIRES_APPROVAL), the plan.plan field carries the full narrative envelope:
{ "version": 1, "projectId": "cmp...", "title": "Q3 Platform Launch: The Collaboration Revolution", "target": { "audience": "enterprise buyers", "objective": "introduce the Q3 platform launch", "outcome": "book a demo" }, "sourceDigest": { "summary": "Cross-source synthesis...", "sourceIds": ["cmq..."], "evidence": [ { "sourceId": "cmq...", "claim": "60% onboarding reduction", "support": "...", "confidence": "high" } ], "gaps": [], "rightsNotes": [] }, "thesis": { "oneLine": "Real-time collaboration at enterprise scale, proven in weeks not months.", "supportingClaims": ["60% onboarding reduction"], "avoidClaims": ["competitor comparisons"] }, "journey": { "beats": [ { "id": "hook-1", "role": "hook", "title": "The collaboration problem", "narration": "Every enterprise team faces the same moment...", "visualIntent": "Split screen: fragmented tools vs unified platform", "evidenceIds": ["ev-0"], "estimatedSeconds": 12 } ] }, "visualSystem": { "style": "Corporate documentary with kinetic data viz", "palette": ["#1a1a2e", "#16213e", "#0f3460"], "typography": "Sans-serif, high legibility at distance" }, "audioSystem": { "voice": "Authoritative narrator, measured pace", "music": "Ambient corporate — builds through conflict beat", "sfxPolicy": "Minimal UI chimes on key data reveals" }, "artifacts": [ { "type": "table_read", "mode": "documentary", "renderPlan": {} } ] }
Beat roles
Every beat in journey.beats has a role from the seven-point story arc:
| Role | Purpose |
|---|---|
hook | Open with the tension or question that pulls the audience in |
context | Establish the world, the stakes, or the before state |
conflict | Name the core problem or friction |
proof | Present evidence, data, or demonstration |
turn | The pivot — the resolution or revelation |
payoff | The after state; what is now possible |
cta | Call to action |
The credit quote
Every plan carries a quote computed deterministically from the source count and artifact requests. Planning and source digest lines are included/unmetered; artifact and add-on lines are the billable work:
{ "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 } ], "notes": [], "computedAt": "2026-05-29T10:05:00.000Z" }
The quote is provisional — final reserve and settle happen at the job creation step. The actual cost cannot differ if the plan inputs have not changed, but treat the quote as an estimate if you are comparing across environments or API versions.
Post-creation operation costs (reserved at call time, not quoted in the plan):
| Operation | Credit key | Credits |
|---|---|---|
Refine a table read (POST /artifacts/{id}/refine) | TABLE_READ_REFINE | 8 |
Finalize audio MP3 (POST /artifacts/{id}/finalize mode audio) | TABLE_READ_FINALIZE_AUDIO | 6 |
Finalize video MP4 (POST /artifacts/{id}/finalize mode video) | TABLE_READ_FINALIZE_VIDEO | 12 |
Refining a plan (via the refine endpoint — see Artifacts)
To revise a generated table read without rebuilding the entire plan-to-job flow, use POST /api/v1/artifacts/{artifactId}/refine. This is a post-creation operation on the artifact (not the plan) — it re-versions the same artifact in place. See the Artifacts section below and api-reference.md for full details.
Alternative pattern: Create a new plan against the same project with updated target, styleConstraints, or sourceIds. The previous plan automatically transitions to SUPERSEDED. This is appropriate when you want to change the narrative direction substantially enough to warrant a fresh plan and a new artifact.
Story Jobs
A StoryJob runs queued artifact generation against an APPROVED plan. Creating a job reserves the quoted Studio Credits.
Job lifecycle (status)
PENDING --> RESERVED --> RUNNING --> READY
--> PARTIAL (some artifacts ready, others running)
--> FAILED
--> CANCELED (on explicit cancel, credits released)
RESERVED— the credit reservation succeeded; the worker has not started yet.RUNNING— the worker is actively generating artifacts.PARTIAL— at least one artifact isREADYwhile others are still running. You can read ready artifacts immediately.READY— all artifacts are in a terminal state (allREADYor someFAILED).
Canceling a job
Cancel a job while it is in PENDING, RESERVED, or RUNNING:
POST /api/v1/story-jobs/{jobId}/cancel
Reserved-but-unsettled credits are released. The cancel operation is idempotent — canceling a CANCELED job is a no-op.
Idempotency on job creation
Always supply an Idempotency-Key when creating a job. If the request fails (network timeout, 5xx) you can retry with the same key and the API will replay the original response instead of creating a second job and charging credits twice.
Artifacts
A StoryArtifact is a single generated output produced by a job. One job can produce multiple artifacts (for example, a table read plus a pitch deck once both artifact adapters are live).
Artifact lifecycle (status)
PENDING --> RUNNING --> READY
--> FAILED
When READY, the manifest field is populated with the artifact's consumable output.
The table read artifact
The first live artifact type. A table read is a live, on-demand audio/video performance driven by the Pipecat/Daily stack — not a static file.
Modes:
documentary— narrated with supporting evidence, factual tone.podcast— conversational, two or more voices, interview-style.drama— character-voiced, scripted scene.
narrationPolicy is separate from mode. Choose narrator/no-narrator as a
creation-time posture for the piece: podcasts, documentaries, dramas, panels,
audio essays, and true-crime or archival casefiles can each be narrated or
narrator-free when that fits the form.
The manifest (when status: READY):
{ "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, "recordingDurationMs": null, "finalize": null }, "video": { "status": "not_rendered", "renderUrl": "/api/v1/artifacts/<id>/render-video", "url": null }, "expiresAt": "2026-06-29T10:00:00.000Z" }
The top-level artifact also carries currentVersion (integer, starts at 1) and refineCount (integer, starts at 0) which update with each refine operation.
The three player URLs:
| URL | Description |
|---|---|
theaterUrl | Framed visual theater page with attribution chrome. Suitable for linking. |
theaterFullscreenUrl | The same theater stage rendered chrome-free and immersive. Best for <iframe> embeds or kiosk displays. |
audio.liveUrl | Minimal-chrome, audio-only player. No visual stage; the end-user clicks play and the live audio streams. |
All three URLs are authorized by the shareToken (not your API key) and are "unlisted" — public but unguessable. End-users can open them without holding your API key.
Starting the live performance programmatically:
Both the theater and audio-only players start the live read by POSTing the share token to audio.startLiveUrl. You can also call this directly from your own UI:
const res = await fetch(manifest.audio.startLiveUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: manifest.shareToken }), }) const { dailyRoomUrl } = await res.json() // dailyRoomUrl is the Daily.co room the end-user joins
Refine — conversational post-creation revision:
POST /api/v1/artifacts/{artifactId}/refine (scope artifact:publish, requires Idempotency-Key)
{ "instruction": "rename the host to Dana; make the skeptic warmer", "scope": "screenplay" }
scope options: "auto" (default, model self-routes), "screenplay" (faster — rewrites screenplay + recasts voices in place), "plan" (re-grounds against sources then re-adapts).
Returns { storyJob }. Poll GET /api/v1/story-jobs/{jobId} until terminal (READY / FAILED), exactly like a standard job. On completion, GET /api/v1/artifacts/{artifactId} reflects updated currentVersion and refineCount.
The artifact id, share token, and all three player URLs are STABLE across refines. Embed once — the updated read is served at the same URLs.
Fetch a prior revision: GET /api/v1/artifacts/{artifactId}?revision=N (v1 = initial generation).
Credit cost: TABLE_READ_REFINE — 8 credits.
Recast, coverage, music, and sound effects:
These controls mutate or analyze the same table read after creation without changing the artifact id or share URLs.
| Operation | Scope | Effect |
|---|---|---|
POST /api/v1/artifacts/{artifactId}/voice | artifact:publish | Reassign one character voice in place; no new revision, no credit charge |
POST /api/v1/artifacts/{artifactId}/coverage | story:write | Generate async professional coverage; read it with GET /coverage |
POST /api/v1/artifacts/{artifactId}/music | artifact:publish | Generate/regenerate adaptive per-scene music, or tune/mute one scene with sceneIndex |
POST /api/v1/artifacts/{artifactId}/sfx | artifact:publish | Add or remove timed sound-effect cues |
GET /api/v1/artifacts/{artifactId}/music and /sfx | artifact:read | Poll/read music status and SFX cues |
Finalize — durable MP3:
POST /api/v1/artifacts/{artifactId}/finalize with { "mode": "audio" } (scope artifact:publish, requires Idempotency-Key)
Freezes a completed Pipecat/Daily table-read recording into a permanent full-mix MP3. If no completed recording exists yet, the endpoint returns needs_recording and does not reserve credits. Poll GET /api/v1/artifacts/{artifactId}:
manifest.audio.finalize.status stages: needs_recording, or queued → rendering → uploading → complete (or failed — inspect manifest.audio.finalize.error).
On complete: manifest.audio.recordingUrl (MP3 download URL) and manifest.audio.recordingDurationMs are populated.
Credit cost: TABLE_READ_FINALIZE_AUDIO — 6 credits. Replay-safe: re-calling when already complete or in-flight returns the current state without re-charging.
Finalize — durable MP4:
POST /api/v1/artifacts/{artifactId}/finalize with { "mode": "video" } (scope artifact:publish, requires Idempotency-Key)
Queues the Remotion MP4 export (same render as render-video). Poll manifest.video.status and manifest.video.url.
Credit cost: TABLE_READ_FINALIZE_VIDEO — 12 credits.
The opt-in MP4 render (legacy alias):
video.status is not_rendered until you explicitly request it. POST /api/v1/artifacts/{artifactId}/render-video remains available and is equivalent to finalize?mode=video for the MP4 render. The status transitions not_rendered → queued → rendering → complete (or failed; stale means the read changed after the last render). Poll manifest.video.status and manifest.video.url.
Live scrubber (coming soon): A timeline scrubber on the theater and audio-only players. Currently the live read plays from the beginning.
Other artifact types (coming soon)
The following artifact types are defined in the API schema but not yet backed by a live adapter:
| Type | Outputs |
|---|---|
pitch_deck | Video (10–120s) + optional PDF |
storyboard | Image pack |
trailer | Video |
production_video | Video |
Requesting these types today returns the plan and job at FAILED with a clear failureCode. Always check GET /api/v1/capabilities before generating to confirm which types are available.
Credits
Studio Credits are the single shared meter for all generation work. They accumulate across two buckets:
- Monthly bucket — allocated at the start of each billing cycle, expires at cycle end.
- Perpetual balance — credits purchased outright, never expire.
The balance field on the credits response is the sum of both buckets.
Credit lifecycle for a job
- Quoted — the plan's
quote.totalgives the projected cost. No credits are spent. - Reserved — when a job is created (
POST /story-jobs), the quoted amount is reserved. Ifbalance < quote.totalthe API returns402 insufficient_credits. - Settled — as each artifact becomes shareable (transitions to
READY), its credit line items are settled from the reservation. - Released — if a job is canceled or an artifact fails, any reserved-but-unsettled credits are released back to the balance.
A 402 insufficient_credits response carries details.required and details.available so you can surface the shortfall and prompt a top-up before retrying.
Share tokens
The shareToken on a table read manifest is an expiring JWT. It is distinct from your customer API key and has no API write capabilities — it only authorizes the read-only player and live-start endpoints.
You can safely give share token–based URLs to end-users. When the token expires (expiresAt), the player URLs stop working and a new job must be run to get a new token.
Scopes
Every customer API key has a set of scopes that gate which endpoints it can call. The default scopes on a new key are story:read and credits:read. The full set:
| Scope | Gates |
|---|---|
story:read | GET on projects, sources, plans, jobs, capabilities |
story:write | POST/PATCH/DELETE on projects, sources, plans, jobs |
source:read | GET on sources |
source:write | POST source creation, DELETE sources |
artifact:read | GET on artifacts |
artifact:publish | POST refine, voice recast, finalize, render-video, adaptive music, and SFX cue mutations |
credits:read | GET on the credits balance |
webhook:write | (Coming soon) register webhook endpoints |
The insufficient_scope error code signals a missing scope. The error.message names the specific scope that was required.
Idempotency
Every POST that reserves credits or queues generation work accepts an Idempotency-Key header. Supply a UUID that is stable for the logical operation (not a new UUID per retry). The API replays the original response on retry with Idempotency-Replayed: true in the response headers.
If you submit a different request body with the same key, the API returns idempotency_conflict. Generate a new key only when you intend a genuinely new operation.
Persist the idempotency key alongside your own job record before sending the request. If the network fails before you receive the response, retry with the same key.
Versioning
The current API base is /api/v1. Breaking changes ship behind a new base prefix (e.g. /api/v2). Additive changes — new endpoints, new optional fields, new artifact types, new scopes — land inside /api/v1 and are reflected immediately in GET /api/v1/capabilities and GET /api/v1/openapi.json.
Match on stable error.code values, not on HTTP status codes alone or human message text. Both status codes and messages may become more specific over time; codes are permanent.