Skip to content

Sleeper Hit makes produced episodes: the script, the voices and the mix.

Sleeper Hit Docs

CLI

Install, configure, and automate Sleeper Hit Studio from a terminal — every command marked for whether it writes, spends credits or only reads.

View as Markdown
On this page (14 sections)

Command-line client for the Sleeper Hit Studio B2B Story API (/api/v1). It maps 1:1 onto the documented endpoints — create projects, build a Series Bible, map and run a podcast season, generate gated episodes, publish an RSS feed, and open the live table-read theater.

The endpoints, auth, error envelope, and flow mirror the Story API docs (quickstart, concepts, API reference). Nothing here contradicts those docs — the CLI is a thin, scriptable wrapper around the same HTTP surface.

Requirements

  • Node.js >= 20 (uses the built-in global fetch and node:crypto.randomUUID).
  • A Sleeper Hit Studio customer API key. Create one at https://sleeperhit.studio/dashboard/developers.
  • Or sign in from the browser with sleeperhit login. A plain login asks for every scope (the approval page lists each one); --scopes story:read,story:write asks for exactly that list instead. The key it saves lasts 90 days; logging in again renews it and revokes the key the previous login saved, and sleeperhit logout revokes it on the server. sleeperhit whoami prints the key's scopes and expiry. The saved file is shared with the stdio MCP server's login tool.

Install

npm install -g https://sleeperhit.studio/downloads/sleeperhit-cli.tgz
sleeperhit --help

The package is one bundled file with no runtime dependencies. It is not on npm yet, so install it from the tarball the studio serves: npm install -g and npx both take that URL.

Or run it once without installing: npx https://sleeperhit.studio/downloads/sleeperhit-cli.tgz --help.

From the monorepo

The package lives at packages/cli and is picked up by the packages/* workspace glob:

pnpm install
pnpm --filter @sleeperhit/cli build      # bundles src (and @sleeperhit/shared) -> dist/index.js
cd packages/cli
pnpm link --global                       # now `sleeperhit` is on your PATH

Run without building (dev)

# from packages/cli
pnpm dev -- projects list
# or directly with a TS-aware runner:
pnpm exec tsx src/index.ts projects list

Configuration

Env varRequiredDefaultPurpose
SLEEPERHIT_API_KEYno—Bearer key, sh_<prefix>_<secret>. Sent as Authorization: Bearer <key>. Not needed after sleeperhit login, which saves a key for you.
SLEEPERHIT_BASE_URLnohttps://sleeperhit.studioOrigin to target. /api/v1 is appended automatically. Point at a local dev origin (for example http://localhost:3000) when testing.
export SLEEPERHIT_API_KEY="sh_xxxxxxxxxxxxxxxxxx_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
# optional
export SLEEPERHIT_BASE_URL="https://sleeperhit.studio"

The CLI auto-generates an Idempotency-Key (UUID) for every POST. Override it with --idempotency-key <key> when you want a stable key tied to your own job record for safe retries.

Errors are printed in the standard { error: { code, message, requestId } } form, plus a Next step: line whenever the refusal names one (the same sentence the API sends as error.nextStep). On rate_limited the exact Retry-After is surfaced; jobs watch honors it automatically. The CLI exits non-zero on any error (1 API/runtime, 64 usage, 2 when a requested operation is unavailable in the current environment).

Commands

login [--scopes <list>] [--no-browser] [--base-url <url>]  [writes]   # every scope unless --scopes narrows it; 90-day key; revokes the previous login's key
logout  [writes]                                                      # revokes the saved key on the server, then removes it
whoami [--api-key <key>]                                    # the key's scopes and expiry
capabilities
guidance
credits

projects create --name <name> [--kind story|product] [--description <text>] [--metadata <json>]  [writes]
        # --kind defaults to your account's role kind (product for a Business account): a product
        # project takes `bible generate-coverage <projectId> --phase product`, never premise or full
projects list [--limit N] [--cursor C] [--role writer|creator|business]   # a recurring show also prints "262 episodes · Next: tonight 7 pm Central · Latest S4E429 · 10:56 · Oct 19" on stderr
projects get <projectId>        # prints the project JSON, then its workspace gate and table-read cast readiness on stderr
projects episodes <projectId> [--season N]          # seasons, episodes (made in the app included), versions, videos
projects seasons [list] <projectId> [--season N] [--months N]  # the full arc: seasons, episodes, the next step
projects seasons add <projectId> [--name <text>]  [writes]     # name a new season (free)
projects episodes start <projectId> <episodeId>  [writes]      # a Series Bible episode as an empty draft
projects update <projectId> [--name <text>] [--description <text>]  [writes]
        [--metadata <json>] [--archived true|false]

bible get <projectId>
bible save <projectId> --json '<object>' | --file <json> [--title <text>] [--array-mode merge|replace]  [writes]
bible generate <projectId> [--instructions <text>] [--no-wait] [--wait <seconds>] [--interval <s>]  [writes]
bible coverage <projectId>
bible generate-coverage <projectId> [--focus <text>] [--no-wait] [--wait <seconds>]  [writes]
        # generate-coverage queues the score and waits for it, as bible generate does: the report it
        # prints is the scored one (exit 1 if the run failed)

season-runs create <projectId> [--title <text>] [--season-number <n>]  [writes]
        [--publishing-series-id <id>] [--coverage-policy <json>] [--run-key <key>]
season-runs list <projectId>
season-runs get <runId>
season-runs map <runId> <episodeId> --expected-map-version <n>  [writes]
        [--patch <json>] [--title <text>] [--summary <text>] [--objective <text>]
        [--audience <text|null>] [--tone <text|null>] [--notes <text|null>]
        [--duration-minutes <n>] [--episode-number <n>]
        [--confirmed true|false] [--confirm]
season-runs approve <runId> --expected-map-version <n> --confirm  [writes]
season-runs advance <runId> [--episode-id <id>]  [writes]
season-runs replan <runId> [--episode-id <id>] [--instruction <text>] --confirm  [writes]
season-runs retry-coverage <runId> [--episode-id <id>] --confirm  [writes]
season-runs revise <runId> [--episode-id <id>] --instruction <text> --confirm  [writes]
season-runs publish <runId> [--episode-id <id>] --confirm  [writes]
season-runs end <runId> --confirm  [writes]
season-runs delete <runId> --confirm  [writes]

sources add <projectId> --type text|markdown|url|pdf  [writes]
        [--content <text> | --file <path>]   (text / markdown)
        [--uri <http(s) url>]                (url / pdf)
        [--label <text>] [--retention EXTRACTED_ONLY|STORE_ORIGINAL|METADATA_ONLY]
        [--metadata <json>]                  use {"sourceContextMode":"full"} for complete planner + writer context
sources list <projectId> [--limit N] [--cursor C]
sources get <projectId> <sourceId>

capture record <tour.json> --out <dir> [--storage-state <file> | --storage-state-env <VAR> |  [writes]
        --login-url <url> --user <name> --password-env <VAR> [--totp-env <VAR>] | --redeem-url <url> --code-env <VAR>]
        [--extra-origin <origin>]... [--origin-header-env <Header-Name>=<VAR>]... [--secret-env <secretRef>=<VAR>]...
        [--sandbox [--egress-proxy <url>]] [--audio-from <url|file> [--offset <seconds>]]
        [--browser chrome|chromium] [--app-version <tag>] [--upload --project <projectId> [--label <text>]]
        record one take of an app tour on this machine (needs the optional recorder package,
        @sleeperhit/capture: Playwright, Google Chrome, ffmpeg); --upload then imports the take. A protected
        preview: --origin-header-env sends a header only to the app's origins; --secret-env names the variable
        holding each secret the tour types by secretRef (typed only into the app). Values come from environment
        variables by name and are never printed. A stored login: --login-url signs in through the app's own
        form first (with the current code from --totp-env's authenticator secret when it asks), --redeem-url
        POSTs a magic link's one-time code; the sign-in runs in its own browser, closed before the take. Free.
capture import <file|take-dir> --project <projectId> [--label <text>] [--origin-url <url>] [--route <path>] [--app-version <text>] [--tour-id <tourId>] [--feature-id <featureId>]  [writes]
        a real screen recording of an app (MP4: H.264, constant 30 fps, at most 1920x1080, bt709), or a
        capture record take's directory with its events.json, stills and tour, uploaded straight to storage,
        then checked: ready, or rejected with the ffmpeg command that fixes it. --tour-id names the stored
        tour whose run it is (its feature is copied on); --feature-id names the product feature it shows. Free.
capture list --project <projectId> [--feature-id <featureId>] [--tour-id <tourId>]
capture get <captureId> --project <projectId>
capture finalize|delete <captureId> --project <projectId>  [writes]
capture set-feature <captureId> --project <projectId> (--feature-id <featureId> | --none)  [writes]
        set the product feature a capture shows, or none; choosing the feature a match named confirms it. Free.
capture match-features --project <projectId> [--apply]  [writes]
        match the captures that name no feature to the feature each most likely shows (its name, page and tour
        against each feature's): a dry run; --apply tags the strong matches and leaves the rest to you. Free.
capture runner --token-env <VAR> [--port 8791] [--host 127.0.0.1] [--browser chrome|chromium]
        [--box <name>] [--isolation vm|container|shared-host] [--gpu metal|nvidia|none]
        [--app-origin <https://origin>]... [--allow-from <ip>]... [--min-draw-fps <n>]
        [--egress-proxy <http://host:port>] [--no-chrome-sandbox]
        serve "Test sign-in" from this machine (the capture-runner image): Sleeper's worker asks it, over requests
        signed with the token, to sign in to an app and say what it saw. Logins arrive sealed to a key this process
        makes and keeps in memory. The browser always leaves through an egress proxy that refuses private addresses:
        --egress-proxy (smokescreen), or the runner's own, which resolves every name and connects only to the public
        address it checked. The proxy is proven to refuse the metadata address, private ranges, the tailnet and
        loopback before the runner serves and before every test. It advertises what it can do (GET
        /v1/capabilities: box, platform, GPU, app sound, isolation, busy, a self-bench of a heavy page and whether
        that makes it degraded, the app origins it opens). A shared-host runner (a Mac's default) opens only the
        --app-origin list it is given. Point Sleeper at it with CAPTURE_RUNNERS='<box>=<url>' and
        CAPTURE_RUNNER_TOKEN_<BOX>. A capture box on a Mac: docs/capture-box.md.
capture egress-proxy [--port 4750] [--host 127.0.0.1]
        serve the runner's guarded egress proxy on its own (a capture box runs it as a separate user); point
        capture runner --egress-proxy at it.

app connect --project <projectId> --url <origin> [--label <text>] [--login-url <url>]  [writes]
        [--user <name>] [--password-stdin | --password-env <VAR>] [--totp-env <VAR>]
        [--cf-access-id <id> --cf-access-secret-env <VAR> | --vercel-bypass-env <VAR>]
        [--storage-state <file> | --storage-state-env <VAR>] [--provider <url> --grant-env <VAR>]
        [--extra-origin <origin>]... [--mask <selector>]... [--retention-days <n>] [--logged-in-when <json>]
        store the login hosted capture uses to sign in to your app (demo or test accounts only). Each secret
        is sealed when it is saved and is never shown again by any surface; it comes from stdin or a named
        environment variable, never the command line. --provider with --grant-env stores a Sleeper capture
        grant instead: your app's sleeper.capture/1 endpoint hands out a fresh demo session for each recording,
        so no password is stored. Needs a key with the app:connect scope. Free.
app list --project <projectId>         which secrets each connection has saved (never their values)
app get <connectionId> --project <projectId>
app delete <connectionId> --project <projectId>  [writes]
app preview <connectionId> <testId> --project <projectId>
        Read only this exact check’s safe metadata and owner viewUrl. Starts nothing and takes no
        picture. No image download (--save is refused), code input, or automatic replacement-test tracking.
app test <connectionId> --project <projectId> [--no-wait | --result] [--save-screenshot <file>]  [writes]
        test sign-in: a machine that records captures signs in to the app as a recording would and keeps a
        picture of what it saw (yours alone). Waits for the outcome and prints it in plain words, with a private
        link to the picture (or saves it with --save-screenshot). Exit 1 while a secret is missing or when the
        sign-in failed. A running test can wait up to five minutes for a requested code: enter it only in that
        test on Connected apps; Story API, CLI, MCP and Slug never accept it. Retry there with a person available
        to enter a code. For unattended sign-in, save an authenticator setup key if supported, or use a saved
        session or capture grant. Sleeper cannot read email/texts, follow sign-in links, approve push requests,
        or use a passkey. Free.
app update <connectionId> --project <projectId> [--url <origin>] [--login-url <url>] [--login-method ...] [--header ...]  [writes]
        adding an address deletes the saved secrets: a login is only used on the site it was saved for; a new --provider
        endpoint deletes the saved grant
app set-secret <connectionId> <kind> --project <projectId> (--value-stdin | --value-env <VAR>)  [writes]
app learn --project <projectId> [--url <site>] [--no-wait] [--wait <seconds>] [--interval <s>]  [writes]
        learn a product project's product from its public site and docs (pages, llms.txt, sitemap, OpenAPI; up to
        24 pages, robots.txt obeyed): its features, each with the pages it was read from, claims, pricing and brand
        colours. Public pages only; nothing secret is sent. Waits for the run by default. Free, up to 10 runs a day.
app inventory --project <projectId>    the newest run and the product's feature inventory
app claims --project <projectId>       every claim the product's site makes, with the founder's decision on each
app claim <claimId> --project <projectId> (--confirm | --reject | --reopen) [--note <text>]  [writes]
        the founder's decision on one claim: confirm (may be said), reject (never said) or reopen. The product
        Bible written next (bible generate on a product project) follows it. Free.

tour list --project <projectId> [--feature <featureId>]
        the project's stored tours — one JSON walkthrough of a product feature each — newest first,
        with the feature it walks through, its revision and its re-run check; and the project's
        features with how many tours each holds (what a create binds to)
tour get <tourId> --project <projectId>
tour create --project <projectId> --feature <featureId> --file <tour.json> [--label <text>]  [writes]
        store a tour for one feature of the project (a Series Bible episode). The document is
        checked with the recorder's own rules first: one that cannot run is refused with those
        problems, verbatim, and nothing is stored. Free.
tour update <tourId> --project <projectId> [--file <tour.json>] [--feature <featureId>] [--label <text>] [--expected-revision <n>]  [writes]
        --expected-revision (the revision you last read) makes the change safe against a second
        writer: a stale one is refused and nothing is written
tour delete <tourId> --project <projectId>  [writes]
tour check [<tourId>] [--file <tour.json>] --project <projectId>  [writes]
        check a tour — the stored one (<tourId>) or the document in hand (--file), one of the two —
        without storing anything: the recorder's own problems, what a recording needs (its secrets)
        and what it hears. Exits 1 when it is not ok. Free.
tour draft --project <projectId> --feature <featureId> [--connection <connectionId>] [--wait]  [writes]
tour drafts --project <projectId> [--feature <featureId>]
tour draft-status <runId> --project <projectId>
        DRAFT a tour for one product feature: Sleeper writes the walkthrough from the feature's Bible
        entry, its pages and the app connection's address, checks it the way `capture record` would and
        saves it as a DRAFTED tour you edit before anything is recorded — the app is never opened and no
        frame is recorded. `tour draft` answers at once (202 when a run starts, 200 when one is already
        drafting — a repeat never starts a second run); `--wait` polls the run every 3 seconds for up to
        10 minutes and exits 1 when the run failed — or is still drafting when the window closes. `tour
        drafts` lists the project's runs and `tour draft-status` reads one: its status, what the drafting
        held back and the staged tour. Free, up to 50 runs a day.

admin demo-accounts
admin demo-account add|remove <userId>  [writes]
        ADMINS ONLY. The demo-account allowlist (dedicated non-admin accounts agents make showcase videos with)
admin demo-session mint --user <userId> [--ttl 2h] [--out <file>] | admin demo-session revoke <sessionId>  [writes]
        ADMINS ONLY. A real sign-in as a demo account (no impersonation banner), at most 8h; the Playwright storage
        state goes to a mode-600 file outside any repository, never stdout. `capture record … --demo-user <userId>`
        does the same in memory for one take and ends the session after it.
admin capture-runners
admin capture-runner allow <connectionId> [--note <text>] | admin capture-runner disallow <connectionId>  [writes]
        ADMINS ONLY. The capture runners Sleeper records on (never a token): what each can do now (GPU, app sound,
        isolation, busy, its bench and whether it is degraded, the origins it opens), and the shared-host
        allow-list: the connections a shared-host runner (a roscoe) may record. Sleeper's own and approved pilots only.
admin capture-runner route [--connection <id>] [--sound app|off] [--draw-fps <n>] [--runner <name>] [--purpose record|sign-in-test]  [writes]
        ADMINS ONLY. Where a take would record now, its candidates in order and why. Nothing is recorded.
destination list
        where a finished video can go and what each place asks of it: its shape and length, its sound, what it
        must show and the checks it must pass. A row whose spec fails its check is listed as unavailable, never
        guessed; `specMayBeStale` means its spec was last checked more than 90 days ago. Free.
destination get <key>

plans create <projectId>  [writes]
        --target audience=...,objective=...,outcome=...   (repeatable)
        --artifact type=table_read,mode=podcast|documentary|drama[,narrationPolicy=auto|include|suppress][,durationSeconds=N]
        [--brief genre="..." --brief audience="..." --brief pageTarget=4]
        [--source <sourceId> ...] [--title <text>] [--auto-approve]  (non-table artifacts only)
        [--json '<full payload>']    power-user escape hatch
plans list <projectId> [--limit N] [--cursor C]
plans get <planId>
plans watch <planId> [--interval <s>] [--wait <s>]
        Poll every 5s (--interval, seconds) until REQUIRES_APPROVAL or terminal; prints the plan there,
        and stops after 900s (--wait) naming the watch to run again
plans approve <planId> --confirm  [writes]
plans reject <planId> [--reason <text>]  [writes]

jobs create <planId> [--project <projectId>] [--artifact type=...,mode=...]  [writes · spends credits]
        [--voice-map '{"GARY":{"voiceId":"...","provider":"hume"}}'] [--script <scriptUploadId>]
jobs list [--project <id>] [--plan <id>] [--limit N] [--cursor C]
jobs get <jobId>
jobs watch <jobId> [--interval <s>] [--wait <s>]
        Poll every 5s (--interval, seconds) until terminal; print manifest URLs on READY;
        stop after 900s (--wait) naming the watch to run again
jobs cancel <jobId>  [writes]
jobs artifacts <jobId>

artifacts get <artifactId> [--revision N]
artifacts render-video <artifactId> [--watch [--interval <s>] [--wait <s>]]  [writes]

script get <artifactId> [--scope all|page|scene|character|range] [--page N] [--scene N]
        [--character <name>] [--start N --end N] [--limit N]
script page <artifactId> <page>
script scene <artifactId> <sceneIndex>
script character <artifactId> <character...>
script replace <artifactId> --file <path> [--instruction <text>] [--narration-policy include|suppress]  [writes · spends credits]
script upload --title <title> (--file <path> | --source <sourceId> | --file-url <url> [--file-name <name>])  [writes]
        [--format text|fountain|fdx] [--project <projectId>] [--season N] [--episode <episodeId>]
        # a new script in no project, or in --project; --season files it in that season and --episode
        # links it to a planned episode. From a file: --file-url adds it (needs --project) and answers
        # waiting; check the source, then --source <sourceId> makes it. One source makes its script once.
script show <scriptId>                    # the script's id, its Writers Lab URL id, or any version's id
script update <scriptId> [--title <t>] [--logline <text>]  [writes]
        [--genre <text>] [--season N] [--clear logline|genre]...
script outline <scriptId>  [reads; its change flags write]                 # the outline the script was written from, and where it lives
script outline <scriptId> --json '<outline>' | --file <path>  [reads; its change flags write]   # replace it (read it first)
script versions add <scriptId> (--file <path> | --source <sourceId> | --file-url <url> [--file-name <name>]) [--format text|fountain|fdx]  [writes]
script write <scriptUploadId> --file <path> [--expected-file <path>]  [writes]   # a draft's or version's whole text
script finish <scriptUploadId> [--coverage adopt|fresh|acknowledge]  [writes]  # the recommendation, then finish
script versions delete <scriptId> <scriptUploadId> --confirm  [writes]
script export <scriptUploadId> --format fountain|fdx|pdf [--out <path>] [--no-title-page]
        [--from-page N] [--to-page N] [--font screenplay-sim|courier-prime|courier-new|final-draft]
script overrides get <scriptUploadId>
script overrides update <scriptUploadId> [--avatar <text>] [--theater <text>] [--soundscape <text>]  [writes]
        [--pitch-deck <text>] [--locations <text>] [--clear <override>]...
script table-read prepare <scriptUploadId> [--confirm]  [writes · spends credits]
        Prepare a cast episode's table read (the Writers Lab's Continue to Table Read): prints the price;
        --confirm charges it once for this screenplay version and adds its sound effects and music.

cast <artifactId>
cast update <artifactId> --json '<entries-or-{entries,avatarStyle}>'  [writes · spends credits]
cast restyle <artifactId> --style <s>  [writes]
avatar set <artifactId> --character <c> [--style <s>] [--refine <r>] [--reference-image-url <u>]  [writes · spends credits]
voice recast <artifactId> --character <c> --voice-id <id> --voice-name <name> [--gender <g>] [--provider <p>]  [writes]
voice modify <artifactId> --start <n> --end <n>  [writes]
        [--effect autotune] [--key C..B] [--scale major|minor|majpent|minpent|chromatic]
        [--strength <0..1>] [--smooth <int 1+>] [--reverb none|light|chapel]

refine <artifactId> <instruction...> [--scope auto|screenplay|plan]  [writes · spends credits]
finalize <artifactId> [--mode audio|video]  [writes]

coverage generate <artifactId> [--focus <text>]  [writes]
coverage get <artifactId> [--report <id>]
coverage watch <artifactId> [--interval <s>] [--wait <s>]
        Poll every 5s (--interval, seconds) until complete/failed; stop after 900s (--wait)
        naming the watch to run again

music generate <artifactId> [--coverage <0..1|0..100>]  [writes]
music update <artifactId> [--scope screenplay|scene|entry] [--scene <n>] [--entry <n>]  [writes]
        [--mode merge|replace] [--prompt <text>] [--summary <text>] [--weight <0..2>]
        [--mute | --enable]
music get <artifactId>
music watch <artifactId> [--interval <s>] [--wait <s>]
        Poll every 5s (--interval, seconds) until ready/failed; stop after 900s (--wait)
        naming the watch to run again

sfx add <artifactId> --entry <n> --label <text> --prompt <text>  [writes]
        [--volume <0..1>] [--trigger before|with|after] [--offset-ms <n>] [--duration <s>] [--disable]
sfx update <artifactId> --id <cueId>  [writes]
        [--entry <n>] [--label <text>] [--prompt <text>] [--volume <0..1>]
        [--trigger before|with|after] [--offset-ms <n>] [--duration <s>] [--regenerate]
        [--enable | --disable]
sfx list <artifactId>
sfx remove <artifactId> --id <cueId>  [writes]

Run sleeperhit --help for the same reference at the terminal, and sleeperhit help <family> for one family's own commands (it prints that family alone, not the whole catalog).

The other command families

Every family the binary ships is listed here too — one line each, its first or usual command — so a reader can find the deck, trailer, film, publishing, theater, notification and library families without already knowing their names.

account export [--status] [--wait [s]] [--download <dir>]   # download everything the account holds; account deletion, account delete
activity                                                    # background tasks, stages, asset progress, recent results
brand-coverage generate <file|-> | list|get|watch <id>      # how well the brand voice is covered
brand-narrative generate <briefFile|-> [...]                # write the brand narrative from a brief
cast-canon get <projectId> | cast-canon save <projectId> --json '<object>'   # the project's pinned looks and voices
cast-portrait design <projectId> --character <c> [--confirm]  # draw headshot candidates (metered)
cast-voice design <projectId> --character <c> [--confirm]     # design a new voice (metered)
character-notes <projectId> [--character <c>]               # each character's notes and change flags
deliverable list|get|create <projectId>                     # destination video deliverables and their variants
figure set <artifactId> --character <c> [--confirm]         # full-body figures (metered)
generation-health [--probe]                                 # ADMINS: model and queue health
integrations list|connect|sync|disconnect <...>             # connectable products for a business
library cast|audio list|audio import <...>                  # reuse characters and audio across projects
likeness list|link|revoke <...>                             # likeness approvals for real people
locations get <artifactId>                                  # the location plates a video renders in
mcp config [--client codex|claude-desktop|claude-code|cursor]  # add the remote MCP connector to an agent
mood-board get|render <projectId> <...>                      # the agreed look, and candidate frames
notifications [--unread] | notifications mark-read          # the notices the app's bell shows
noun-imagery get <artifactId>                               # each noun's image
outline get <projectId>                                     # every planned episode's outline
performance refine <artifactId> --character <name|NARRATOR|ALL>  # revise one performance (metered)
performance-booth create <jobId>                            # mint an Audio Acting recording link
pitch-deck coverage|intent|chapters|approve-plan <...>      # decks: read, edit the intent, price and render
trailer beats|approve-plan|finish <...>                     # trailers: a cut's beats and notes, plan approval, finishing
profile | profile update [--name <t>] [...]                 # your public writer profile
publishing series create <projectId> [--title <text>]        # a project-owned publishing series (RSS for audio)
recording update <artifactId> [--confirm]                   # add the lines changed since a recording was made
referrals                                                   # your referral link and what it paid
role | role set <writer|creator|business>                   # your workspace role
screenplay-draft start|status|resume <scriptUploadId>       # draft an empty episode scene by scene
show get|set-up|make-episode|runs|publish|...                # a recurring show's sources, schedule and episodes
soundscapes list <artifactId>                               # every scene's soundscape bed and custom regions
story-films brief|pitch|... <...>                            # a story film from one feature's brief
strategy watches|opportunities|pursuits|action-items|contacts  # the Strategy page
conversations [--subject trailer:<id>|deck:<id>]            # Slug conversations and the video selection they hold
theater get <artifactId>                                    # a read's theater presentation and style sets
voices list|preview|rename|remove-retired <...>             # your studio voices
craft-focus [set <focus>]                                   # the kind of work Slug coaches you toward

product-video

Plan a product video from the feature map (P-101): one feature, or a named release and its features, planned from the project's recorded captures into an ordinary capture-only draft trailer — each beat names its capture, the stretch it plays, its narration line, whether it keeps its own sound, and its depth. Free: nothing is charged, reserved or rendered. The run is polled until it is ready; the draft is approved afterwards with sleeperhit trailer approve-plan, behind its plan check.

product-video plan --project <projectId> (--feature <featureId> | --release <name> --features <id,id,...>)
        [--seconds <n>] [--aspect 16:9|9:16|1:1] [--title <text>] [--notes <text>] [--reference <REF-id>] [--destination <key>] [--music score|none] [--wait]
        Start a plan run: one feature, or a named release's 2-8 distinct features. --seconds is 10-360 (default
        30 for a feature, 60 for a release); every capture the plan plays must have --aspect's shape. --destination
        names the destination the plan is cut for (a video destination key); left out, the plan follows the
        deliverable it is made from. --music is score (the default: the draft is scored to picture, free; a sketch
        to the planned cut plays before anything renders) or none (no music bed). --wait
        polls every 3 s for up to 10 minutes, prints each status change and then the run and its plan check,
        and exits 1 on a failed run or a blocked plan check.
product-video runs --project <projectId>       the project's plan runs, its feature map and the eligible capture count per shape
product-video run <runId> --project <projectId> one plan run, with its draft trailer and its live plan check
product-video coverage <jobId>                  a planned draft's plan check (its approval gate); exits 1 when it is blocked
product-video find-references --project <projectId> [--scope feature|release] [--category <name>] [--aspect 16:9|9:16|1:1] [--seconds <n>]
        Up to four reference films from refs.video for the video, each credited "References by refs" with its refs
        link. Free. Pass one to `plan --reference <REF-id>`: the plan is paced by its numbers, never its content.
product-video reference <jobId> [--choose <REF-id> | --clear]   read, choose or clear a video's reference (free)
product-video check-reference <jobId> [--confirm]
        Compare the video's latest master with its reference: an advisory score out of 100 that never blocks
        approval, finishing or export. Without --confirm it prints the quote (2 units of the shared monthly
        reference allowance, no credits); --confirm starts it — read it back with `product-video reference <jobId>`.

Post-creation artifact operations

refine, finalize, cast, avatar, voice, coverage, music, and sfx are wired against the live /api/v1/artifacts/{artifactId} operation families. They mutate or analyze the same table read artifact while keeping the artifact id and share URLs stable. Commands that queue work return JSON immediately; use the matching watch command or poll artifacts get until the status is terminal.

Voice commands name the action: voice recast assigns a character's voice, voice restore returns its previous voice and recording, and voice modify applies an effect over a line range. voice revoice speaks a character's lines again in a finished read; it quotes first and requires --confirm to spend.

cast-voice set pins a voice on the project cast. Casting coverage has two actions: cast-voice coverage reads the saved report, while cast-voice judge runs it again with the current cast. These keep their project scope.

End-to-end example

Copy-pasteable. Replace the captured IDs as you go (or pipe through jq).

export SLEEPERHIT_API_KEY="sh_..."

# 1. Confirm the table read is available and you have credits
sleeperhit capabilities | jq '.artifacts.table_read.availability'
sleeperhit credits      | jq '.credits.balance'

# 2. Create a project. --kind story is NAMED, not left to the account: a project created with no
#    --kind takes its account's default kind — product for a Business account — and a product
#    project takes product coverage (`--phase product`) instead of the premise and full
#    checkpoints this run passes below, so the first coverage command would be refused.
PROJECT_ID=$(sleeperhit projects create --name "Q3 Launch Story" --kind story \
  --description "Product launch table read" | jq -r '.project.id')

# 3. Attach an inline text source (instant READY)
SOURCE_ID=$(sleeperhit sources add "$PROJECT_ID" \
  --type text --label press-release \
  --content "Acme Corp launches its next-gen platform in Q3 2026, cutting onboarding time 60%." \
  | jq -r '.source.id')

# 4. Write the Series Bible from the source, before any plan (free; waits for the run)
sleeperhit bible generate "$PROJECT_ID" \
  --instructions "An episodic documentary table read series drawn from the attached source: one host and one expert voice."

# 5. Pass the coverage checkpoints the plan gate waits on: premise, then full.
#    generate-coverage queues the score and waits for it, as bible generate does: the report it prints
#    is the scored one, and a run that failed exits 1 with the reason.
pass_coverage() {
  local phase="$1" report
  report=$(sleeperhit bible generate-coverage "$PROJECT_ID" --phase "$phase")
  printf '%s\n' "$report" \
    | jq '{status:.coverage.status, score:.coverage.overallScore, passed:.coverage.gatePassed, fixes:.coverage.report.priorityFixes}'
  # Under the bar? Fix the Bible and score it again, or acknowledge the report as the
  # writer: sleeperhit bible acknowledge "$PROJECT_ID" --phase "$phase" --report <reportId> --reason "<why>"
  printf '%s\n' "$report" | jq -e '.coverage.gatePassed' >/dev/null \
    || { echo "$phase coverage is under the bar; see fixes above." >&2; return 1; }
}

pass_coverage premise
# The plan expands the Bible's episode map, so it holds one before full coverage.
sleeperhit bible save "$PROJECT_ID" --array-mode merge \
  --json '{"episodes":[{"title":"Q3 launch: the 60% onboarding cut"}]}'
pass_coverage full

# 6. Cast the voices — free to pin. A read performs the project's cast; it never makes one.
#    Every character the read voices needs a voice, and a narrated read also needs a
#    narrator voice. Pictures are not a read requirement: no headshot, body shot or sheet
#    is asked for here. (The plan below narrates: add a NARRATOR entry if the Bible has none.)
CHARACTERS=$(sleeperhit bible get "$PROJECT_ID" | jq -r '.document.content.characters[]?.name')

# Design one voice for a character: price first (no --confirm draws nothing and charges
# nothing), then draw with --confirm, then keep candidate 1 (free; it pins the voice on
# the cast canon and saves it to your library).
design_voice() {
  local character="$1" design_id
  sleeperhit cast-voice design "$PROJECT_ID" --character "$character" >&2   # the price; draws nothing
  design_id=$(sleeperhit cast-voice design "$PROJECT_ID" --character "$character" \
    --confirm | jq -r '.designId')
  sleeperhit cast-voice choose "$PROJECT_ID" --design "$design_id" --candidate 1 \
    | jq -r '.voice.voiceId'
}

VOICE_ID=$(sleeperhit voices list | jq -r '.voices[0].voiceId // empty')
if [ -n "$VOICE_ID" ]; then
  # You already own a voice: pin it on every character, narrator included.
  sleeperhit bible get "$PROJECT_ID" \
    | jq --arg voice "$VOICE_ID" '{characters: (([.document.content.characters[]?.name] | map({name: ., voiceId: $voice})) + [{name: "NARRATOR", voiceId: $voice}])}' \
    > /tmp/cast-canon.json
  sleeperhit cast-canon save "$PROJECT_ID" --file /tmp/cast-canon.json
else
  # A fresh account has no saved voice: design one for each character, and give the
  # narrator the first one. Each press prints its price before it draws.
  FIRST_VOICE=""
  while IFS= read -r character; do
    voice=$(design_voice "$character")
    [ -n "$FIRST_VOICE" ] || FIRST_VOICE="$voice"
  done <<< "$CHARACTERS"
  sleeperhit cast-voice set "$PROJECT_ID" --character NARRATOR --voice-id "$FIRST_VOICE"
fi
# `sleeperhit projects get <projectId>` prints the gate and tableReadReadiness on stderr:
# ready means every voice is pinned.

# 7. Generate the plan
PLAN_ID=$(sleeperhit plans create "$PROJECT_ID" \
  --target audience="enterprise buyers" \
  --target objective="introduce the Q3 platform launch" \
  --target outcome="book a demo within 7 days" \
  --target tone="confident, specific" \
  --artifact type=table_read,mode=documentary \
  --source "$SOURCE_ID" | jq -r '.plan.id')

# 8. Wait for the approval hold, review the exact plan, then cross the human hold
#    plans create answers PENDING and the planner runs in the background, so watch
#    (not one get) is what reaches the approval hold. watch prints the plan ITSELF,
#    not the create envelope: the review reads .plan, and exits 1 on FAILED.
sleeperhit plans watch "$PLAN_ID" | jq '{status:.status, plan:.plan, total:.quote.total}'
sleeperhit plans approve "$PLAN_ID" --confirm

# 9. Create the job (reserves credits) and watch it to completion
JOB_ID=$(sleeperhit jobs create "$PLAN_ID" | jq -r '.job.id')
sleeperhit jobs watch "$JOB_ID"
# On READY, watch prints:
#   Theater:        https://sleeperhit.studio/share/table-read/<token>
#   Theater (full): https://sleeperhit.studio/share/table-read/<token>?view=fullscreen
#   Audio (live):   https://sleeperhit.studio/share/table-read-audio/<token>
#   Audio (MP3):    https://files.sleeperhit.studio/<key>.mp3
#   Video render URL: /api/v1/artifacts/<artifactId>/render-video

# 10. (Optional) opt into a durable MP4
ARTIFACT_ID=$(sleeperhit jobs get "$JOB_ID" \
  | jq -r '.job.artifacts[] | select(.type=="table_read") | .id')
sleeperhit artifacts render-video "$ARTIFACT_ID"

The shareToken baked into the theater/audio URLs authorizes them — not your API key — so you can hand those URLs directly to end users. The Audio (MP3) line is the finished podcast file itself, not the player page: it sits outside the share token, so it is the link to copy for a podcast and the one that keeps working after the read's share link expires.

A table read performs the project's cast; it never makes one. From a plan, it starts only when the project's cast canon covers every character the read voices — a voice for everyone, and a narrated read also needs a narrator voice. Pictures are not a read requirement: a headshot, body shot or character sheet is drawn for renders, and the read shows each character's initials until it exists. sleeperhit projects get <projectId> prints who still needs what (project.tableReadReadiness); a job that would refuse is a 409 cast_precondition_failed before anything is created or charged.

For voices the cast canon does not carry, first use sleeperhit voices list, then provide a character-to-voice map on an explicit table-read artifact request:

sleeperhit jobs create "$PLAN_ID" \
  --artifact type=table_read,mode=documentary \
  --voice-map '{"GARY":{"voiceId":"voice_gary","provider":"hume"},"MAEVE":{"voiceId":"voice_maeve","provider":"hume"}}'

--voice-map requires exactly one --artifact type=table_read request. Each entry requires voiceId; voiceName, gender, and provider are optional.

To perform a finalized, cast-ready screenplay as written (no new pages, no coverage rewrite), adopt its version id:

sleeperhit jobs create "$PLAN_ID" --script "$SCRIPT_UPLOAD_ID"

--script uses the single table-read request (adding one when none is given) and cannot be combined with --voice-map.

Use it in your own agent workflow

The CLI emits JSON on stdout and human progress on stderr, so it composes cleanly inside scripts or an agent's tool layer. It runs the same order as the end-to-end example, because the API enforces it: plans create answers 409 project_precondition_failed until the Series Bible has passed BOTH coverage checkpoints, and jobs create answers 409 cast_precondition_failed until every voice the read speaks is pinned:

#!/usr/bin/env bash
set -euo pipefail
export SLEEPERHIT_API_KEY="${SLEEPERHIT_API_KEY:?set your key}"

ARTICLE_URL="$1"

# --kind story for the same reason as the end-to-end example: premise and full are a story
# project's checkpoints, and a project with no --kind takes its account's default kind.
PROJECT_ID=$(sleeperhit projects create --name "Agent run $(date +%s)" --kind story | jq -r '.project.id')

# Async URL source — poll to READY before planning
SOURCE_ID=$(sleeperhit sources add "$PROJECT_ID" --type url --uri "$ARTICLE_URL" | jq -r '.source.id')
# A refusal is JSON on stdout too, so the loop stops on it: an empty stdout would read as null and spin.
while :; do
  reply=$(sleeperhit sources get "$PROJECT_ID" "$SOURCE_ID")
  if [ "$(jq -r '.ok' <<<"$reply")" = "false" ]; then
    jq -r '.error.code, .error.nextStep' <<<"$reply" >&2
    exit 1
  fi
  [ "$(jq -r '.source.status' <<<"$reply")" = "READY" ] && break
  sleep 3
done

# The plan gate: write the Series Bible from the source (free; it waits for the run), then pass the two
# coverage checkpoints. generate-coverage waits for its score like bible generate: one call, the report.
pass_coverage() {
  local phase="$1" report
  report=$(sleeperhit bible generate-coverage "$PROJECT_ID" --phase "$phase")
  printf '%s\n' "$report" \
    | jq '{status:.coverage.status, score:.coverage.overallScore, passed:.coverage.gatePassed, fixes:.coverage.report.priorityFixes}'
  # Under the bar? An agent stops here instead of editing canon blind: fix the Bible and score it again,
  # or acknowledge the report on the writer's behalf (sleeperhit bible acknowledge ... --reason "<why>").
  printf '%s\n' "$report" | jq -e '.coverage.gatePassed' >/dev/null \
    || { echo "$phase coverage is under the bar; see fixes above." >&2; return 1; }
}

sleeperhit bible generate "$PROJECT_ID" \
  --instructions "A narrated table-read series drawn from the attached article: one host and one expert voice."

pass_coverage premise
# The plan expands the Bible's episode map, so it holds one before full coverage.
sleeperhit bible save "$PROJECT_ID" --array-mode merge \
  --json '{"episodes":[{"title":"The article, in one episode"}]}'
pass_coverage full

# The cast: a read performs the project's cast, it never makes one. Every character the read speaks needs
# a voice, and a narrated read needs a narrator. Pin the voice you already own (free) on everyone; with no
# saved voice, design one per character — the --confirm press is the only charge in this block.
CHARACTERS=$(sleeperhit bible get "$PROJECT_ID" | jq -r '.document.content.characters[]?.name')
VOICE_ID=$(sleeperhit voices list | jq -r '.voices[0].voiceId // empty')
if [ -n "$VOICE_ID" ]; then
  sleeperhit bible get "$PROJECT_ID" \
    | jq --arg voice "$VOICE_ID" '{characters: (([.document.content.characters[]?.name] | map({name: ., voiceId: $voice})) + [{name: "NARRATOR", voiceId: $voice}])}' \
    > /tmp/cast-canon.json
  sleeperhit cast-canon save "$PROJECT_ID" --file /tmp/cast-canon.json
else
  FIRST_VOICE=""
  while IFS= read -r character; do
    [ -n "$character" ] || continue
    sleeperhit cast-voice design "$PROJECT_ID" --character "$character" >&2   # the price; draws nothing
    design_id=$(sleeperhit cast-voice design "$PROJECT_ID" --character "$character" \
      --confirm | jq -r '.designId')
    voice=$(sleeperhit cast-voice choose "$PROJECT_ID" --design "$design_id" --candidate 1 \
      | jq -r '.voice.voiceId')
    [ -n "$FIRST_VOICE" ] || FIRST_VOICE="$voice"
  done <<< "$CHARACTERS"
  sleeperhit cast-voice set "$PROJECT_ID" --character NARRATOR --voice-id "$FIRST_VOICE"
fi

PLAN_ID=$(sleeperhit plans create "$PROJECT_ID" \
  --target audience="general" --target objective="summarize the article" --target outcome="share a 90s table read" \
  --artifact type=table_read,mode=podcast,narrationPolicy=include,durationSeconds=90 \
  --source "$SOURCE_ID" | jq -r '.plan.id')

# plans create answers PENDING and the planner runs in the background: watch, not get. watch stops at the
# approval hold (and exits 1 with the diagnostic on FAILED) and prints the plan ITSELF, so the review reads
# .plan — the create envelope's nested plan path does not exist here — and then stops for exact human review.
PLAN=$(sleeperhit plans watch "$PLAN_ID")
printf '%s\n' "$PLAN" | jq '{status:.status, plan:.plan, title:.title, total:.quote.total}'
read -r -p "Approve this exact plan? [y/N] " APPROVE
case "$APPROVE" in y|Y|yes|YES) ;; *) exit 1 ;; esac
sleeperhit plans approve "$PLAN_ID" --confirm

JOB_ID=$(sleeperhit jobs create "$PLAN_ID" | jq -r '.job.id')
sleeperhit jobs watch "$JOB_ID"   # blocks, streams progress, prints share URLs on READY

For a power-user payload that exactly matches the API schema, bypass the flag helpers with --json:

PLAN_ID=$(sleeperhit plans create "$PROJECT_ID" --json '{
  "title": "Launch narrative",
  "target": { "audience": "enterprise buyers", "objective": "introduce the launch", "outcome": "book a demo" },
  "artifactRequests": [{ "type": "table_read", "mode": "drama", "narrationPolicy": "suppress", "durationSeconds": 120 }],
  "styleConstraints": { "voicePreference": "authoritative, not salesy" },
  "sourceIds": ["'"$SOURCE_ID"'"]
}' | jq -r '.plan.id')

# Wait for the async planner, then review the exact plan with the user.
sleeperhit plans watch "$PLAN_ID" | jq '{title, plan, quote}'
sleeperhit plans approve "$PLAN_ID" --confirm

A refusal is JSON on stdout too (and still exits 1). The prose stays where it was — human lines on stderr, with the code, the next step and the request id — and stdout carries the same one-line {"ok":false,"error":{…}} payload the MCP tools return, so a script branches on the code instead of matching the prose:

reply=$(sleeperhit plans create "$PROJECT_ID" \
  --target audience="enterprise buyers" --target objective="introduce the launch" --target outcome="book a demo" \
  --artifact type=table_read,mode=drama \
  --source "$SOURCE_ID") || {
  case "$(jq -r '.error.code' <<<"$reply")" in
    project_precondition_failed) jq -r '.error.nextStep' <<<"$reply" >&2; exit 1 ;;
    *) jq -r '.error.code, .error.requestId' <<<"$reply" >&2; exit 1 ;;
  esac
}
PLAN_ID=$(jq -r '.plan.id' <<<"$reply")

The project id is the verb's first argument, as in every plans verb (plans create <projectId>, step 7 above): passing it as a --project flag instead answers a usage error and exits 64 before anything is sent.

A usage mistake (a bad flag, a missing argument) is not a refusal: nothing was sent, so nothing is on stdout, and the command exits 64.

Connect an agent over MCP

sleeperhit mcp config prints the same remote connector the connector page gives, for Codex, Claude Desktop, Claude Code, or Cursor:

sleeperhit mcp config --client claude-code   # codex | claude-desktop | claude-code | cursor

It prints the one add-server command (or the url-only config entry, for Cursor) and the sign-in step: the client opens Sleeper Hit Studio in your browser once, and the account is carried by that sign-in. No API key is written into any file. Point it at another origin with --base-url http://localhost:3000 (or a saved login's base URL).

See also

  • Quickstart — the same flow as raw HTTP / Node.
  • API reference — every endpoint, field, and error code.
  • Concepts — the plan/job/artifact model and live post-creation operations.
  • GET /api/v1/openapi.json — the live OpenAPI 3.1 spec.

License

MIT. See LICENSE.