# 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.
- `kind` — what this project is: `story` (characters, seasons, episodes) or `product` (personas, features, truth). A create with no `kind` takes the account's role default: `product` for a Business account, `story` otherwise. Once the project exists the kind is the project's own — it never follows the account — and it selects the coverage rubric and the Series Bible's shape.
- `archivedAt` — archive timestamp (null while the project is live). Archiving is a listing-level hide: archived projects still exist and can be read. It is not deletion.
- `sourceCount` — count of non-deleted sources attached to this project.

**Delete vs archive.** These are two different actions:

- **Archive** sets `archivedAt`; the project keeps answering reads and can be unarchived.
- **Delete** (`DELETE /story-projects/{projectId}`) hides the project at once — its feed, show page, decks and share links stop answering — and keeps it **restorable for 30 days**. `GET /story-projects/deleted` lists what is still inside the window (each with `restorableUntil`), and `POST /story-projects/{projectId}/restore` brings a deleted project back with its episodes' scripts. After the window the project is purged with its Series Bible, sources, captures, plans, decks and episodes for good. Both delete and restore need `story:write`.

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.

---

## Product projects

A product project is a project created with `kind: "product"` (or the account-role default above). Book on a Business account and a project with no `kind` is already a product project. Its path is: connect the app the promo will record, test that sign-in, script tours of the app, record captures from them, then run product video plans that play those captures. Each step reports its own status below; every value a surface can show is named here.

### App connections (stored app logins)

An app connection holds the app's origins, sign-in page and method, and sealed secrets (saved write-only — no surface can read a secret back). Its `status`:

| Status | Meaning |
|---|---|
| `untested` | Saved, but no test sign-in has been run against it yet. |
| `verified` | A test sign-in ran and passed: the recorder opened the sign-in page and reached the app's signed-in state. Safe to record against. |
| `failed` | The last test sign-in failed (bad credentials, missing TOTP, unreachable app, and so on). Fix the login or the app before recording. |

Each test sign-in also has its own `status`, newest test wins:

| Status | Meaning |
|---|---|
| `running` | The test is executing right now; one test at a time per connection. |
| `passed` | Sign-in succeeded — this is what moved the connection to `verified`. |
| `failed` | Sign-in did not complete; the test carries a machine-readable reason. |

### Captures and tours

A **tour** is the script of one screen recording: where it starts, what it clicks and types, where it waits and where it cuts. It names controls by what a person sees (role and accessible name, or a test id) so it survives a restyle, carries an allow list of the actions a recording may take against a real account, and states the reset precondition that must hold before each take. The viewport is always exactly 1920×1080 device pixels.

A **capture** is one recorded take of a tour, uploaded straight to storage (the bytes never pass through the API: create answers a short-lived presigned PUT, then finalize hashes and probes the file). Its `status`:

| Status | Meaning |
|---|---|
| `uploading` | The presigned PUT is open and the file is on its way; finalize has not run yet. Not playable. |
| `ready` | Finalized and probed: H.264, constant 30 fps, at most 1920×1080, tagged bt709. This capture can be played by a plan. |
| `rejected` | Finalize refused the file (wrong codec, variable frame rate, oversized) and answered the exact re-encode command. Re-record or re-encode, then finalize again. |

Captures are free. A tour itself carries no pass/fail state — it is the script; the take is the capture.

### Product video plans

A product video plan is generated asynchronously against the project's captures and product Bible. One plan run's `status`:

| Status | Meaning |
|---|---|
| `queued` | Accepted and waiting for a planner slot (runs per day are capped). |
| `planning` | The planner is reading the captures, the Bible and the reference and writing the draft. |
| `ready` | The plan finished; read it, check its findings, then approve or amend it. |
| `failed` | Planning stopped; the run carries the error. Fix what it names and start a new run. |

---

## 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. `pageCount` for PDF sources).

On `FAILED`:
- `failureCode` — machine-readable reason (e.g. `source_fetch_failed` or `source_incomplete`).
- `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`, GCP `metadata.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_ONLY` and no extracted text is available.
- The extracted text is too short to be useful.
- The source is a full-context producer item (`producer` set, `metadata.sourceContextMode: "full"`): its plan reads every character, so it is skipped by design (`digestFailureMessage: "Used in full by its episode plan."`).

A `SKIPPED` digest does not prevent the source from being used in planning. It is terminal: stop polling.

### Digest fields (when `digestStatus: READY`)

```json
{
  "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  (always for table_read)
                       -->  APPROVED  (eligible non-table autoApprove, 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`.

### A failed plan says what kind of failure it was, and how to resume it for free

`FAILED` is not a dead end. Read three fields on the plan:

- `failureClass` — `quota` (balance, billing or a rate ceiling: a top-up or provider rebind must come first), `provider` (upstream throttling or refusal: recoverable after its cooldown), `contract` (we sent something the platform rejected: deterministic, so an identical retry reaches the identical rejection), `transient` (timeout, reset, capacity blip) or `internal` (a defect on our side).
- `retryable` — true only for `transient` and `internal`. Never branch on `failureMessage`: it is upstream vendor prose and is reworded without notice.
- `failureRecovery` — one sentence naming the free way on, present when the plan failed before it produced anything. It points at `POST /story-plans/{planId}/resume` (MCP `resume_story_plan`, CLI `sleeperhit plans resume <planId>`).

Resuming keeps the same plan id, brief and attempt history, and planning is unmetered, so it costs no credits. **Do not create a second plan for the same brief** — that re-plans and pays again. A plan that already generated output is not resumable; review or reject it instead.

### The `autoApprove` flag

- `autoApprove: true` — eligible non-table plans transition directly to `APPROVED` on success. The API rejects this flag for every plan containing `table_read`; those plans always retain the exact human-review boundary.
- `autoApprove: false` (default) — the worker lands at `REQUIRES_APPROVAL`. Inspect `plan.plan` (the narrative envelope) and `plan.quote` (the credit estimate), show the exact plan to the user, then call `POST /story-plans/{planId}/approve` with `{ "userConfirmed": true }` only after explicit approval (or call `/reject`).

### The plan envelope

When `status` is `APPROVED` (or `REQUIRES_APPROVAL`), the `plan.plan` field carries the full narrative envelope:

```json
{
  "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:

```json
{
  "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 |

Finalizing a durable MP3 or MP4 (`POST /artifacts/{id}/finalize`, mode `audio` or `video`) is free.

### 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](#the-table-read-artifact) and [api-reference.md](/api-reference#artifacts) 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
              ^              |
              | provider     | capacity/policy block
              +--- backoff ---+
                                      -->  PARTIAL  (some artifacts ready, others running)
                                      -->  FAILED
              -->  CANCELED  (on explicit cancel, credits released)
```

- `RESERVED` — the credit reservation succeeded. Ordinarily the worker has not started yet; if `progress.stage` is `provider_backoff`, Sleeper Hit preserved the same job/checkpoint and already scheduled its delayed retry. Keep polling—no client recovery loop is needed.
- `RUNNING` — the worker is actively generating artifacts.
- `PARTIAL` — at least one artifact is `READY` while others are still running. You can read ready artifacts immediately.
- `READY` — all artifacts are in a terminal state (all `READY` or some `FAILED`).

### Canceling a job

Cancel a job that has not started: `PENDING`, `RESERVED`, or `FAILED` before it ever started (a refused reservation or a lost enqueue — canceling that one means it can no longer be resumed). A `RUNNING` job cannot be canceled, and neither can a `FAILED` job that had started generating:

```
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, such as a table read plus a pitch deck.

### 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 artifact type. A table read is a durable recording: its MP3 is rendered automatically once the read is ready (and on finalize), and an MP4 can be rendered on request. The theater and audio-only players play that file; nothing streams live.

**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`):

```json
{
  "type": "table_read",
  "status": "ready",
  "shareToken": "<expiring JWT>",
  "theaterUrl": "https://sleeperhit.studio/share/table-read/<token>?view=framed",
  "theaterFullscreenUrl": "https://sleeperhit.studio/share/table-read/<token>?view=fullscreen",
  "audio": {
    "mode": "durable_recording",
    "liveUrl": "https://sleeperhit.studio/share/table-read-audio/<token>",
    "recordingUrl": null,
    "recordingDurationMs": null,
    "adaptiveMusic": false,
    "finalize": {
      "status": "not_finalized",
      "finalizeUrl": "/api/v1/artifacts/<id>/finalize",
      "error": 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; it plays the read's durable MP3. |

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.

`audio.mode` is `"durable_recording"`: the read is a finished file, not a live session. `liveUrl` is the player page, `recordingUrl` is the MP3 itself once `audio.finalize.status` is `complete`, and `finalize` names the endpoint that renders it (`POST /api/v1/artifacts/{artifactId}/finalize` with `{ "mode": "audio" }`, free).

**Refine — conversational post-creation revision:**

`POST /api/v1/artifacts/{artifactId}/refine` (scope `artifact:publish`, requires `Idempotency-Key`)

**1. the price — nothing reserved:**

```json
{ "instruction": "rename the host to Dana; make the skeptic warmer", "scope": "screenplay" }
```

**2. the spend — the same body plus `confirmed: true`:**

```json
{ "instruction": "rename the host to Dana; make the skeptic warmer", "scope": "screenplay", "confirmed": true }
```

`scope` options: `"auto"` (default, model self-routes), `"screenplay"` (faster — rewrites screenplay + recasts voices in place), `"plan"` (re-grounds against sources then re-adapts).

Refine is price-then-spend, two calls. Without `confirmed` the same body returns the quote — `{ quotedOnly: true, credits, costLabel, balanceAfter, sufficient, note, lines }` — and reserves and enqueues nothing. Send it again with `confirmed: true` and it reserves exactly that quote and answers `{ 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 8 credits (TABLE_READ_REFINE) are reserved only on the confirmed call.

**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 soundtrack, or tune/mute/replace direction for the whole read, one scene, or one entry |
| `POST /api/v1/artifacts/{artifactId}/sfx` | `artifact:publish` | Add, update, retime, mute, regenerate, or remove line-level 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`)

Renders the read's full mix — its voices, music, and sound effects — into a permanent MP3, free: nothing has to be recorded in a browser first. Poll `GET /api/v1/artifacts/{artifactId}`:

`manifest.audio.finalize.status` stages: `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.

Free. Replay-safe: re-calling when already complete or in-flight returns the current state without rendering again.

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

Free.

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

**Timeline scrubber (planned):** A scrubber on the theater and audio-only players.

### The pitch deck artifact

Pitch decks are generated from an approved StoryPlan and a completed table read for the same plan/project. This lets the pitch journey reuse canonical script text, avatars, and voices rather than creating a disconnected package.

When `status: READY`, the manifest carries:

| Field | Description |
|---|---|
| `pitchDeckJobId` | Underlying journey-plan pitch deck job. |
| `share.url` | Public pitch-deck page. |
| `video.status`, `video.url`, `video.durationMs` | Rendered pitch-deck video. |
| `pdf.status`, `pdf.url` | Companion PDF deck. |
| `sourceTableReadArtifactId`, `sourceTableReadJobId` | Table-read source used for script, avatars, and voices. |

### Retired artifact types

`storyboard` and `production_video` were retired with storyboarding on 2026-10-09. A request for either is
refused with `400 validation_failed`, whose `details.allowedTypes` names the live types (`pitch_deck`,
`table_read`, `trailer`). `production_video` artifacts made before then are still read and published. 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

1. **Quoted** — the plan's `quote.total` gives the projected cost. No credits are spent.
2. **Reserved** — when a job is created (`POST /story-jobs`), the quoted amount is reserved. If `balance < quote.total` the API returns `402 insufficient_credits`.
3. **Settled** — as each artifact becomes shareable (transitions to `READY`), its credit line items are settled from the reservation.
4. **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 capabilities at all — it authorizes the read-only viewer URLs and nothing else: the table read's player, and a trailer's or pitch deck's public share page (`/{writerSlug}/trailers/{shareToken}`, `/{writerSlug}/pitch-decks/{shareToken}`). Those pages play for anyone holding the link, logged out, and are never indexed. No `/api/v1` endpoint accepts a share token: API calls take your customer API key.

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. A key created on the Developers page has full access by default — every scope the API checks. **Advanced: customize scopes** narrows it while you create it, and scopes cannot be edited after creation: to change what a key can do, make a new key and revoke the old one.

The API checks fifteen scopes, and this table is rendered from the API's own scope registry — the list `GET /api/v1/capabilities` publishes under `scopes`, described in the words the key approval page uses — so it holds every scope a key can carry and the calls each one opens:

| Scope | Gates |
|---|---|
| `story:read` | GET, POST — View your scripts, projects, story plans, and coverage |
| `story:write` | POST, PUT, PATCH, DELETE — Create and edit scripts, projects, and story plans |
| `source:read` | GET, POST — Read the full text of your uploaded scripts |
| `source:write` | POST, PATCH, DELETE — Upload and modify script source text |
| `artifact:read` | GET — View generated artifacts — table reads, pitch decks, trailers |
| `artifact:publish` | POST, DELETE — Publish and render generated artifacts |
| `publishing:read` | GET — View your shows, their episodes and where they publish |
| `publishing:write` | POST, PATCH, DELETE — Create and edit your shows and episodes, including a recurring show's schedule and spending limits |
| `publishing:publish` | POST — Publish your shows' episodes, including on a schedule you turn on |
| `publishing:connect` | GET, POST — Connect and manage publishing destinations |
| `publishing:analytics` | GET — View your publishing analytics |
| `app:connect` | POST, PUT, PATCH, DELETE — Save, change, test and delete the logins Sleeper uses to record your app (saved logins are sealed and can never be read back) |
| `credits:read` | GET — View your Studio Credit balance and usage |
| `account:read` | GET, POST — See your profile, your workspace role and whether your account is scheduled for deletion and your credit balance, and download a copy of everything in your account |
| `account:write` | GET, POST, PUT, PATCH, DELETE — Change your profile, workspace role and craft focus, and schedule or cancel the deletion of your account (it needs your email address, and can be cancelled for 7 days) |

Reads and writes sit on scopes of their own, and a few are easy to guess wrong: a source's text is read under `source:read` (the poll that says whether an upload is ready reads it), `GET /api/v1/capabilities` needs no scope at all (it is public), and the account itself — profile, workspace role, "download my data", and a scheduled deletion — is `account:read` and `account:write`. A key holding too few scopes is refused on its first call outside them, and its scopes cannot be widened afterwards, so grant every scope the work needs while creating it.

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.

A saved response is kept for 7 days. After that the key is free again: a request that reuses it is treated as new and does the work again, so retry within 7 days, and read the result of an older request with the matching GET instead.

---

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