A story project is a script plus its cast: an ordered list of blocks, each one a voice and a line. /v1/story keeps the script so it survives the tab it was written in. The console surface is Studio → Story.
This route stores text. It synthesizes nothing. Audio comes from POST /v1/audio/speech, one call per block, and the pieces are assembled by whatever client is rendering — in the console, that is the browser. Per-request synthesis limits and costs therefore apply per block, not per story.

A block

The 4,000-character block cap sits under the 4,096-character limit on POST /v1/audio/speech, so a block always fits in one synthesis request. That is the point of the smaller number.

The project

There is no SDK method for /v1/story. Call it over plain HTTP:
PATCH replaces blocks wholesale when you send it, and leaves them untouched when you omit it. There is no per-block update, so read, modify the list you got back, and send the whole thing. A concurrent editor in another tab loses the race.
A list row is a summary: project_uuid, title, block_count, preview — the first line with words in it, up to 280 characters — and updated_at. The full script comes only from the detail read, because a long script is tens of kilobytes and a library page does not need it.

Rendering it

The loop is yours to write, and it is short:
  1. Walk the blocks in order.
  2. Skip every block whose type is not text. A heading organizes the script for whoever is writing it; speaking “Chapter two” aloud in the middle of a story is what a heading is not for.
  3. Skip blocks with no text.
  4. POST /v1/audio/speech with the block’s voice_id as voice and its text as input. Ask for wav if you are going to concatenate — stitching compressed frames is more work than decoding once.
  5. Append the block’s delay as silence, plus whatever beat you want between turns. The console adds 0.3 seconds of its own.
Set source: "story" on each synthesis request so these takes are distinguishable from everything else in your generation history. Because each block is a separate request against the same stored reference clip, a voice sounds the same in block one and block ninety. That is the reference-clip invariant doing the work.

Sharp edges

A block can name a voice that no longer exists

voice_id is stored as text. Nothing links a block to the voice row, and nothing stops you deleting a voice a script depends on — deliberately, because the alternative is a voice you cannot erase on request. A script written six months ago may name a voice that has since been deleted or has aged out of its three-year retention window. Synthesis is where you find out, with a 400 naming the id. Check the cast against GET /v1/voices before rendering a long script, rather than discovering it at block forty.

Bracketed direction is read aloud

The synthesis model has no markup vocabulary. Send "The kettle boiled. [short pause] Nobody noticed." and it says “short pause” out loud, in the middle of your sentence. The console resolves a small set of tags in the editor before it calls the API: timing tags ([pause], [short pause], [long pause], [breath]) split the line and become silence between the rendered pieces, and delivery tags ([soft], [whispering], [emphasis], [excited], [sad], [warm], [urgent]) become an instructions string on the piece that follows. Anything bracketed that it does not recognize is left in the text on purpose — “[sic]” and “[laughs]” are things people write and mean to be read. That resolution is a client convention. The API stores your text verbatim, so if you are writing your own renderer, do the same thing: strip direction out of the text before it becomes input.

Delivery instructions do not apply to a named voice

instructions on POST /v1/audio/speech is a voice-design description, and it is read only when voice does not resolve to a stored clip. A block that names an ev_… voice is conditioned on that clip, and an instructions string sent alongside it has no effect — not an error, no effect. Direction changes the words and the timing you send; it does not restyle a cloned voice.

Limits, cost and failures

/v1/story is not one of the OpenAI-shaped routes, so its errors use the plain envelope rather than the OpenAI one:

Next

Text to speech

The endpoint every block goes through.

Voices

Casting: where a voice_id comes from.