sanzaru <group> <verb> exposes every Sanzaru capability as a shell command designed for AI
agents: machine-readable JSON on stdout, deterministic exit codes, one-shot workflows for
long-running jobs, and output to any path you choose. The CLI is stateless — job IDs are the
only handles, every command is independently resumable, and nothing is tracked between
invocations.
The base install is just this CLI — it carries none of the MCP stack (mcp, starlette,
uvicorn). Bare sanzaru (no subcommand) and sanzaru serve start the MCP server and need
sanzaru[mcp] (or sanzaru[all]); without it they exit 3 and print the install command. Configs
that launch the server (.mcp.json, Claude Desktop) should name the extra: uvx "sanzaru[mcp]".
uv tool install sanzaru # or: uvx sanzaru ..., pipx install sanzaru
uv tool install 'sanzaru[mcp]' # the same CLI plus the MCP server
export OPENAI_API_KEY=sk-...
sanzaru capabilities # no API key needed — discover what works herestdout carries exactly one JSON envelope per input — nothing else. Fan-out commands stream one envelope per line (JSONL) in completion order. stderr carries progress, heartbeats, and human-readable hints. A TTY only switches formatting (pretty vs compact), never structure.
{"v": 1, "ok": true, "command": "video.create", "result": {"id": "hf_1c9e…", "cost": {"usd": 0.92, "basis": "local_table"}, "...": "...", "file": {"path": "/abs/clip.mp4", "bytes": 4821193}}, "elapsed_s": 84.2}
{"v": 1, "ok": false, "command": "video.wait", "error": {"type": "timeout", "message": "..."}, "resume": "sanzaru video wait hf_1c9e… --download -o ./clip.mp4", "id": "hf_1c9e…", "last_status": "in_progress"}result.file.path is the canonical location of a written artifact and is always present. Where a
result also carries a bare name (output_file, output_filename, filename), it is the basename
of that same path — including when -o renamed the file or it was staged under a temporary name
first. The two never disagree, so jq -r .result.file.path and jq -r .result.output_file always
describe one file. video envelopes carry only file.path, no bare name; prefer file.path in
scripts that handle more than one media type.
Errors are also emitted as envelopes on stdout ("ok": false) so jq pipelines never hang,
with a one-line summary on stderr. error.type is one of: usage, config, api_error,
not_found, job_failed, timeout, download_error, internal, and for video jobs on
Higgsfield:
error.type |
exit | meaning |
|---|---|---|
over_budget |
2 | the estimate is over --max-cost (or no price exists while a cap is set) — nothing was submitted or charged; error carries estimate_usd, limit_usd, basis |
usage |
2 | Higgsfield rejected the arguments (400/422) |
config |
3 | HF_KEY missing, malformed, or rejected (401) |
concurrency_limit |
1 | the account already has 4 jobs in flight (the Higgsfield app counts too); nothing was created, so resubmitting is safe — --retry-busy does it for you |
insufficient_credits |
1 | the API balance is empty (403). It is prepaid and separate from a Higgsfield app subscription — top up in the API console |
model_unavailable |
1 | the model is temporarily blocked or disabled (423/503) |
api_error |
1 | anything else; maybe_submitted: true when the submit failed after it was sent — check before resubmitting |
A resume field is present whenever a follow-up command recovers the situation.
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Runtime/API error (network, 4xx/5xx, write failure, unknown ID) |
| 2 | Usage error (bad flags/arguments; also click's own errors) |
| 3 | Configuration error (missing OPENAI_API_KEY / HF_KEY, missing optional extra) |
| 4 | Timeout — the job keeps running server-side; re-run the resume command |
| 5 | Job failed server-side (failed, nsfw moderation — not charged, canceled) |
| 6 | Partial batch failure (fan-out with ≥1 success and ≥1 failure) |
| 130 | Interrupted (Ctrl-C) — job keeps running; resume hint on stderr |
When every input of a fan-out fails, the exit code is deterministic: 4 if any input timed out (resumable work remains), otherwise the highest per-input code. Per-line envelopes carry the detail either way.
--json (accepted no-op — output is always JSON) · -q/--quiet (suppress stderr progress) ·
-v/--verbose (debug logging) · --media-dir DIR (override SANZARU_MEDIA_PATH for this
invocation). Durations accept 90, 90s, or 5m forms.
video create/video edit/video extend/image create submit a job and return its ID in ~1 second.
status peeks (never blocks); wait blocks with adaptive polling; download fetches the
artifact. Flag implication: -o ⇒ --download ⇒ --wait — so one command composes all
three:
sanzaru video create "the pilot looks up and smiles" --duration 8 --aspect-ratio 16:9 \
--max-cost 5 -o ./assets/pilot.mp4 | jq -r .result.file.pathWhile waiting, stderr gets a line per state change plus a 30s heartbeat
(sanzaru: hf_1c9e… in_progress t=95s — Higgsfield reports no percentage). Polling adapts per
job type (video: 2s → 10s cap, default timeout 30m; image: 2s → 10s cap, default 10m); --poll-interval fixes the cadence and
--timeout sets the deadline.
Waiting is idempotent. On exit 4 the job is still running — the envelope's resume field is
a complete command to attach again. This is the crash/timeout recovery loop for harnesses that
cap foreground commands:
ID=$(sanzaru video create "..." --duration 8 | jq -r .result.id) # returns in ~1s
# ... do other work, then repeat until exit != 4:
sanzaru video wait "$ID" --download -o ./out/clip.mp4 --timeout 100ssanzaru wait polls mixed job types concurrently — hf_* (video) and resp_* (image) IDs are
dispatched by prefix (--type is the escape hatch) — and streams JSONL as each finishes:
sanzaru wait hf_a1… hf_b2… resp_c3 --download -o ./media/ > done.jsonlA pre-0.13 Sora id (video_…) is a usage error (exit 2) explaining that OpenAI retired the Videos
API on 2026-09-24.
Precedence for where artifacts land: -o PATH → --media-dir → individual env vars
(VIDEO_PATH/IMAGE_PATH/AUDIO_PATH) → SANZARU_MEDIA_PATH/{videos,images,audio} → the
current directory (with a stderr note — never a hard error). -o pointing at a directory
(existing, or with a trailing /) auto-names the file inside; parent directories are created.
Inputs (--image, --end-image, --ref, video SOURCE, --input-image, --mask, audio FILEs) accept either a path (used
as-is) or a bare filename resolved from the configured media dir, matching MCP behavior.
Paths in one batch may span directories — each is validated individually under its own parent —
so an episode and its QC windows can be transcribed in one call:
sanzaru audio transcribe ./episodes/ep.mp3 ./windows/w1.mp3 ./windows/w2.mp3Constraints: two inputs of the same media type cannot share a basename (tools are handed
bare names and the envelope reports bare names, so one would be unaddressable); bare filenames
and paths can't be mixed for the same media type; -o still resolves against a single directory
per type, the first input's; with STORAGE_BACKEND=databricks, -o always produces a local
file (bytes are copied out of the volume when needed).
Two rules exist because agents run this from workspaces holding untrusted files:
- A bare name captured by the current directory is announced, never silent. A bare name
normally addresses the media library, but a same-named file in the working directory still
wins (chained commands depend on it). When both exist as different files, the stderr note
names both candidates and how to spell each, so a planted
episode.mp3cannot quietly substitute itself for the library's (running from inside the media directory itself is one file, not two, and says nothing). Write./nameto mean the local file with no note. A bare name whose local match is a symlink is refused outright —./nameopts into that explicitly. -onever writes through a symlink. The destination, the staging file, andpodcast rundown -oall open withO_NOFOLLOW, so a symlink pre-planted at the output path is an error (exit 2) rather than a write to whatever it points at — decided before anything is generated or billed,rundownincluded. When the refusal happens after the tool has already written (a relocation onto a planted link), the error names where the artifact still is and carries it asfile.path. The staging name is random rather than derived from the output name, so it cannot be predicted and swapped mid-run. The direct-write case (no relocation) is checked at plan time here and closed against the race by the local storage backend's ownO_NOFOLLOWopener.
Prompts, TTS text, and podcast scripts accept three forms: inline string, @file, or -
(stdin). A literal leading @ escapes as @@.
sanzaru audio speak @chapter1.txt --voice nova -o ch1.mp3
sanzaru podcast generate - < episode.json -o ./out/episode.mp3Requires HF_KEY (a Higgsfield API key, key_id:key_secret). The API is prepaid and billed
separately from a Higgsfield app/CLI subscription, and exposes no balance endpoint — so every
submit is estimated first and the cost is in the envelope; --max-cost refuses (exit 2,
nothing uploaded or charged) and --dry-run prices without submitting.
HIGGSFIELD_BASE_URL overrides the API endpoint, for sandboxes that reach the API through a
credential proxy holding the real key. It is read from the process environment only (a .env
cannot set it), and it must be https, or plain http to 127.0.0.1, ::1 or localhost; anything
else is a config error (exit 3). Presigned uploads and output downloads never use it and never
carry the key.
| Command | Purpose |
|---|---|
create PROMPT |
Text-to-video, or image-to-video with --image. --model (curated id or any catalog slug; default seedance-2.5), --duration N, --aspect-ratio, --resolution 480p|720p, --audio/--no-audio, --image PATH|hf_id, --end-image PATH, --arg KEY=JSON (repeatable), --args @file.json, --max-cost USD, --dry-run, --retry-busy DURATION, one-shot flags |
edit SOURCE PROMPT |
Re-render a clip (Seedance 2.5); SOURCE is a local .mp4 or an hf_… id. --ref PATH (repeatable) guide images. Bills source + output |
extend SOURCE PROMPT --duration N |
Continue a clip by N seconds (4–30). Bills source + added seconds |
status ID |
Peek at status (never blocks) |
wait ID... |
Block until terminal; concurrent multi-ID, --download, JSONL output |
download ID |
Save the output (-o); Higgsfield keeps outputs ~7 days |
cancel ID |
Cancel a job that is still queued (refunded); a started job cannot be canceled — Higgsfield usually starts within seconds, so price with --dry-run before submitting |
models [--catalog] |
The curated models with ranges and price notes; --catalog lists every video model the API offers |
files |
Locally downloaded videos (--pattern/--type/--sort/--order/--limit) |
--retry-busy DURATION retries only the account-concurrency rejection (4 jobs in flight,
shared with the Higgsfield app), which guarantees no job was created — never a failed submit that
might have gone through. An hf_… id works anywhere a source or start frame is expected, reusing
that job's output without a download.
| Model | Durations | Price (2026-09-28, before discounts) |
|---|---|---|
seedance-2.5 (default) |
4–30 s, 480p/720p, 6 aspect ratios | ~$0.46/s @720p, ~$0.21/s @480p; edit/extend ~$0.28/s of source+output @720p |
kling-3.0-std / -pro / -4k |
3–15 s, 16:9/9:16/1:1, end frame | ~$0.35 / $0.46 / $1.16 per 5 s |
kling-3.0-turbo |
3–15 s | ~$0.31 per 5 s |
With --image, keep the prompt motion-only — the image already carries character, setting, and
style — and crop it to the shape you want first (image prepare --aspect-ratio), since
image-to-video framing follows the image. See docs/video-prompting-guide.md.
| Command | Purpose |
|---|---|
generate PROMPT... |
Synchronous Images API — returns file + token usage. RECOMMENDED for one-off images. Multi-prompt × --count fan-out with --concurrency |
edit PROMPT |
Synchronous edit/composition of existing images (--input-image, --mask) |
create PROMPT |
Async Responses job — for refinement chains (--previous-id) and parallel jobs. --image-model (default gpt-image-2.5-flare), --input-image, --mask, one-shot flags |
status ID / wait ID... / download ID |
The async job trio |
prepare INPUT |
Crop/pad/rescale to a video frame: --aspect-ratio 16:9|4:3|1:1|3:4|9:16|21:9 or --size WxH, --mode crop|pad|rescale |
files |
Images in the media dir |
Iterative refinement:
R1=$(sanzaru image create "a cyberpunk courier, full body" --size 1024x1536 --wait | jq -r .result.id)
sanzaru image create "add neon rain and a cityscape" --previous-id "$R1" -o ./art/courier_v2.pnggenerate/create default to gpt-image-2.5-flare and edit to gpt-image-2.5-sunburst; both accept
--background transparent (png/webp output) and the extra --quality xhigh|max levels. Passing
--model gpt-image-2 with either raises a clear usage error before any request is made.
| Command | Purpose |
|---|---|
transcribe FILE... |
Whisper/GPT-4o transcription; --enhance detailed|storytelling|professional|analytical, --format, --timestamps; multi-file fan-out; files over 8 min are auto-windowed (result.chunked) |
chat FILE |
Ask questions about audio content (--prompt, --system) |
speak TEXT |
TTS (--provider, --model, --voice, --instructions, --speed, --voice-settings); long text auto-chunks; -o FILE without an extension gets .mp3 |
convert FILE |
To mp3/wav (--to); -o FILE without an extension gets .<to> |
compress FILE |
Fit a size budget (--max-mb, default 25); -o FILE must carry an audio extension — the result is mp3 when re-encoded but keeps the input's format when it was already small enough |
files |
List with filters (--pattern/--format/--min-duration/...); --latest prints only the newest. --pattern is a case-insensitive substring or glob ("*.mp3", "ep0?"), not a regex — `^ $ |
Audio names are checked before any work is done. An input to convert/compress must be a
self-contained audio container (mp3 wav flac aac ogg oga opus m4a m4b mp4 mov aiff aif aifc wma mka webm amr au caf mpeg mpga); playlist formats such as hls, m3u8, concat and dash are refused
because their content names other local files for ffmpeg to read. A caller-named output of
convert, compress or speak must carry one of the narrower output extensions (flac m4a mp3 mp4 mpeg mpga ogg wav webm) — none of these commands can create a .json or .html.
audio speak and podcast generate both take --provider openai|elevenlabs (default openai).
openai |
elevenlabs |
|
|---|---|---|
--model |
gpt-4o-mini-tts (default), tts-1, tts-1-hd |
eleven_v4 (default), eleven_v4_turbo, eleven_v3, eleven_multilingual_v2, eleven_flash_v2_5, eleven_turbo_v2_5 |
--voice |
named voice (default alloy) |
a voice id from your library — required |
--instructions |
style direction | ignored; use inline [audio tags] in the text (eleven_v4 performs stacked tags in order) |
--speed |
0.25–4.0 | 0.7–1.2; eleven_v4, eleven_v4_turbo and eleven_v3 refuse any change (the API accepts it there and ignores it) |
--voice-settings |
rejected | JSON: stability, similarity_boost, style, use_speaker_boost, speed |
ElevenLabs needs ELEVENLABS_API_KEY and uv pip install 'sanzaru[elevenlabs]'; either missing is
a config error (exit 3). Set SANZARU_ELEVENLABS_MAX_CONCURRENCY if you hit HTTP 429 — their cap
is per subscription tier. ELEVENLABS_BASE_URL overrides the API endpoint (the ElevenLabs
counterpart to OPENAI_BASE_URL), for sandboxes that reach the API through a credential proxy.
ElevenLabs bills characters submitted, inline audio tags included, against a monthly allowance that can be small (the free tier was 10,000 characters/month as of 2026-08). Every render now reports what it spent, so you no longer have to count the script by hand first:
The count is computed from the text actually submitted, so it is right even when a request later fails, and it reflects chunking (long text is split into several requests).
To check the allowance before spending any of it:
sanzaru capabilities --quota | jq .result.elevenlabs_quota
# {"available": true, "tier": "free", "characters_used": 1754,
# "character_limit": 10000, "characters_remaining": 8246, "resets_at_unix": ...}--quota is the one part of capabilities that makes a network call and needs a key — the rest
stays safe as an agent's first command. A failed lookup is reported as
{"available": false, "reason": "..."} rather than failing the report.
sanzaru audio speak "[excited] You will not believe this." \
--provider elevenlabs --voice 21m00Tcm4TlvDq8ikWAM \
--voice-settings '{"stability":0.4,"similarity_boost":0.85}' -o hook.mp3Three verbs, in the order you use them:
| verb | what it does | cost |
|---|---|---|
rundown PREMISE |
plans an episode into acts; emits editable JSON | one text call |
simulate BRIEF |
experimental: records realtime agents actually conversing | real; see below |
generate SCRIPT |
speaks a script you wrote (multi-voice TTS); --verify checks the audio says it |
TTS rates |
generate --provider elevenlabs --render-mode dialogue --verify is the recommended way to make a
podcast — from a script, or from a topic once you have written the script. Use generate
(segments) when you need exact control over gaps and per-segment retry. rundown + simulate are
experimental: reach for them only when an unscripted conversation is explicitly wanted.
rundown PREMISE (inline text, @file, or -) expands a premise into acts and writes JSON you
can hand-edit before spending anything. Options: --acts, -m/--target-minutes, --title,
--style, --host NAME[:VOICE[:PERSONA]] (repeatable), --turn-seconds, --model (a text
model, not a realtime one), -o.
It exists as its own command because planning is cheap and recording is not. Acts record in
parallel and cannot hear each other, so each carries prior_context (what earlier acts covered),
upcoming (what later acts own), and handoff (where to leave off) — that wiring is the whole
point of planning first, and it is easier to fix in an editor than in a prompt.
sanzaru podcast rundown "why TTS providers drop sentence tails" \
--acts 3 -m 6 \
--host "Avery::You host and translate jargon." \
--host "Rory:cedar:You chased the bug. Dry, specific." \
-o rundown.jsonsimulate [BRIEF] records the episode. BRIEF is a rundown or a full SimulationBrief (inline JSON,
@file, -); flags override it. Or skip BRIEF and pass --premise to plan and record in one go.
Nothing is scripted: each host is a gpt-realtime session with a persona, and one host's audio is
played into the others' ears. The transcript comes back in the envelope as an output.
Always --dry-run first. It plans, projects turns/duration/tokens/dollars, and records
nothing:
sanzaru podcast simulate @rundown.json --dry-runsanzaru: dry run — 'The Hard Part Isn't the Model': 3 acts, up to 27 turns
sanzaru: act1: The Model Is the Easy Part to Demo — 120s, up to 9 turns
sanzaru: projected ~6 min audio, 16,593 input / 10,800 output tokens
sanzaru: projected cost ~$0.20 (estimate, not a quote)
sanzaru: nothing was recorded; drop --dry-run to record
A dry run always projects, --max-cost or not. When a billable model — the episode's, or a
per-host model override in the rundown — has no known price, the projection reports it under
cost.unpriced_models and leaves cost.usd empty instead of quoting a figure it cannot stand
behind. That is also where you learn that the recording will be refused: a ceiling over a model
whose spend cannot be counted is not enforced silently, it exits 2 before anything is billed
(before the planner call, when only a premise was given). Set
SANZARU_REALTIME_PRICE_<MODEL> (text_in,cached_text_in,audio_in,cached_audio_in,audio_out,text_out
per 1M tokens, plus an optional seventh per_minute in USD per session-minute) or record without
a ceiling. If an unpriced model slips past that check and is
charged mid-run anyway, the run stops with exit 6 and the envelope names unpriced_model and
price_env — its resume command carries no --max-cost, because raising the cap cannot help.
Then record with a ceiling. --max-cost is checked after every turn across every parallel act:
sanzaru podcast simulate @rundown.json --model gpt-realtime-2.1-mini \
--max-cost 2.00 --stems -o ./out/ep1.mp3Progress is one greppable stderr line per turn and per act, each carrying elapsed wall clock — during a multi-minute blocking run that is the only signal it is alive:
sanzaru: run d33730ea — resume with: sanzaru podcast simulate --resume d33730ea
sanzaru: act 1/3 turn 4 [Rory] 14.2s t=26s
sanzaru: act 1/3 recorded 9 turns, 122s audio (complete) t=28s
sanzaru: qc: transcribing 3 acts with gpt-transcribe t=32s
sanzaru: episode d33730ea: 3 acts, 26 turns, 6.8 min
sanzaru: spend $0.21
sanzaru: qc warn: act2 — see result.qc for why (--qc-retry re-records just those)
Directing it. A producer inside the tool gives one host the floor at a time, pushes talking
points across each act, and steers the last turns to a landing — but those are defaults. Each act
in the rundown takes direction (free text, how to play it), turn_notes ({"0": "..."} by turn
index, replacing the generated note) and speaking_order (host ids, cycled, instead of strict
alternation). turn_notes is the strongest lever here: it is the difference between "move onto
the next point" and "object to what they just said". Edit them in the rundown JSON — the tool
blocks while acts record in parallel, so there is no live steering.
Recovery. The run id prints before recording starts, and every act is checkpointed to the
audio dir the moment it finishes. An interrupt, a crash, or a --max-cost abort never loses audio
you paid for:
sanzaru podcast simulate --resume d33730ea # records only the missing actsThe printed id goes to stderr, so choose it yourself when a harness parses only stdout:
--run-id ep1, or a top-level "run_id" in the rundown JSON (the flag wins). Then --resume ep1
is predictable even if the shell died before you read anything.
--qc-retry re-records flagged acts, and the take it replaces is preserved beside it as
<slug>_<run>_<act>_take1.mp3 (_take2, … on later retries) — never read by --resume, so the
run keeps exactly one truth while you stay free to assemble the best cut per act. QC verdicts do
disagree run-to-run, so a retry is not automatically the better take.
This composes with -o: the episode and stems go where you asked, while the manifest and the act
checkpoints always stay in the media dir, so the printed resume command works verbatim with no
-o of its own. A resume also reinstates the run's settings from the manifest — including
--max-cost, so following the ceiling abort's hint does not re-run uncapped. Anything you pass on
the resume itself wins. Because the restored ceiling also counts the spend replayed from the
checkpoints, the ceiling abort prints a resume command with a raised --max-cost, and a resume
that cannot fit under the restored one stops before it records anything.
A resume trusts only bookkeeping written for that run. The manifest must carry the run id it is
being resumed as, and each act checkpoint carries its run id and a digest of its mp3 — a pair copied
in from another run, or an mp3 swapped under its sidecar, is re-recorded rather than replayed. On a
shared media directory set SANZARU_RUN_SECRET as well: manifests and checkpoints are then
HMAC-signed and an edited or foreign one is refused (not signed by this installation, exit 2 for a
manifest; a checkpoint is re-recorded). The signature covers a fixed, versioned set of fields, so
upgrading sanzaru does not invalidate signed runs in flight; enabling the secret does invalidate
the unsigned files that already exist, so switch it on between runs.
Options: -p/--premise, --acts, -m/--target-minutes, --title, --style, --host,
--model, --live-mode, --planner-model, --turn-seconds, --turn-tokens, --max-cost,
--max-sessions, --resume RUN_ID, --run-id RUN_ID, --stems, --qc/--no-qc, --qc-retry, --dry-run,
--act-gap, --format, --bitrate, -o.
--model gpt-live-1 (experimental) records on the full-duplex Live API instead, billed
$0.05 per session-minute per host with no tokens (the dry run prints session-minutes). The act
runs in real time (a 90-second act takes about 90 seconds; acts still parallel) because a Live
session only advances while input audio streams. --live-mode duplex (default) lets the hosts
hear each other continuously and take turns themselves while the producer steers with silent
notes on the act clock; the act is a live mix and the envelope reports collision_seconds per
act. --live-mode cued is the one-host-at-a-time fallback, used automatically when the table
mixes Realtime and Live hosts. --turn-tokens has no effect on Live hosts. See
docs/audio/simulated-podcasts.md.
Exit codes are the usual contract plus one: 6 means the cost ceiling stopped the run — the
envelope carries spent_usd, suggested_limit_usd, completed_acts, and a resume command.
Every other failure after recording starts carries run_id and a resume command too.
Full rationale, measured numbers, and tuning notes:
docs/audio/simulated-podcasts.md.
generate SCRIPT renders a multi-voice podcast from a PodcastScript JSON; segments TTS in
parallel internally, bounded per provider. Only speakers and segments are required — the
smallest script that renders is
{"speakers": [{"name": "Alex", "voice": "ash"}], "segments": [{"speaker": "Alex", "text": "Hi."}]}.
A speaker's id defaults to its name (so segments can reference it by name) and speed to
1.0; instructions is optional and OpenAI-only. title defaults, and config is optional
in full: default_pause_ms (600), normalize_loudness (true), output_format ("mp3"),
plus intro_silence_ms, outro_silence_ms, output_bitrate, provider, max_concurrency,
render_mode, dialogue_stability. An invalid script reports every problem at once rather
than one per run. Speakers accept optional provider, model, and voice_settings, resolved
as speaker.provider > config.provider > --provider — so one episode can mix OpenAI and ElevenLabs
voices. The envelope includes the full transcript — pipe to a file for long episodes.
A script is bounded before the first TTS request, because every one of these numbers is an
allocation or a connection: at most 2000 segments of 40000 characters each; pause_after,
default_pause_ms, intro_silence_ms and outro_silence_ms each 0–60000 ms; and no more than
an hour of silence in total (summed as if every segment's pause were inserted — the final
segment's is not, so the bound is conservative). A JSON null on any of those means "not set",
the same as leaving the key out. Segments fan out at most 32-wide per provider by default —
config.max_concurrency raises or lowers that, SANZARU_OPENAI_MAX_CONCURRENCY and ElevenLabs'
tier caps still win when set. The -o name must be a bare filename with an audio extension
(mp3, wav, m4a, flac, ogg, …) and may not be a simulated run's bookkeeping
(simrun_<id>.json) or the audio of a recorded act (a <name>.json act sidecar beside it); all
three refusals are usage errors (exit 2) and cost nothing.
TTS drops the tail of a segment, and occasionally a whole short segment, at random and with
no error. transcript in the envelope is just an echo of the script you sent, so it is no
evidence the audio contains the words — which is why callers built an external QC discipline
around this tool, budgeting a median of three renders per episode.
--verify transcribes each rendered unit before stitching and checks it against the script:
a fuzzy match on the last 8 words (the tail is where drops happen), or presence anywhere in the
audio for segments of 4 words or fewer. Anything that fails is re-rendered once and
re-checked. The episode is written either way.
$ sanzaru podcast generate @episode.json --verify -o ep.mp3
sanzaru: verified: all 28 segments present in the audio
sanzaru: (1 re-rendered to get there)The envelope carries verified, verify_retries, and a segment_verdicts list. Each verdict
has ok (no problem was found), checked (the audio was actually transcribed and compared),
a reason and a similarity. Reasons: tail_missing, segment_missing, diverged (found
wrong, ok: false), or not_transcribed / too_large_to_verify (never looked at — ok: true
but checked: false). verified is null when you did not ask for it, true only when every
segment was both checked and found, and false when any segment was missing or could not be
checked — so a false is read from the verdicts, not assumed to mean "missing". The episode is
written in every case. On stderr the two show up as separate blocks: verified: N of M segments NOT found after a retry for the first, NOT verified: N of M segments could not be checked for
the second.
Transcription is retried three times with a short back-off before a unit is given up as
not_transcribed, since those failures are mostly transient 429/5xx and the check is cheap and
idempotent. The render is still retried exactly once — that is the expensive half. A unit over
the 25 MB transcription upload limit is reported too_large_to_verify without a call: split the
segment to make it checkable.
Costs one transcription per unit, so it is off by default. Two things it does not do: drops are per-render random rather than per-segment sticky, so a segment failing twice wants its tail rewritten to be grammatically part of a longer sentence rather than a third render — the tool says so and stops. And a dialogue-mode run is one request or nothing, so a failure anywhere in a batched run re-renders the whole run.
--render-mode segments|dialogue (or config.render_mode; default segments).
Recommended engine for any multi-voice podcast (write the script first if you only have a topic): --provider elevenlabs (default model
eleven_v4), --render-mode dialogue, --verify. Use --model eleven_v4_turbo for drafts — half
the character cost and roughly half the render time. Stay in segments for exact gaps, per-speaker
voice_settings, or cheap single-line retry. OpenAI stays the default provider because it needs no
extra key or extra. Measured numbers: docs/audio/README.md.
segments— one TTS request per turn, joined with your configured silence gaps. Full control, and every segment is independent so a single bad render can be retried on its own.dialogue— consecutive turns sharing a dialogue-capable provider and model (ElevenLabseleven_v4,eleven_v4_turbooreleven_v3) are sent as one request, so the model paces the exchange itself. Noticeably more natural back-and-forth.
Grouping is per-run, not per-episode: turns that can't join a run — OpenAI speakers, other models,
a lone turn, a stretch in one voice, a turn that alone fills the request budget — still render per segment,
so mixed episodes keep working. On an 11-segment demo with an OpenAI host and two eleven_v3
guests, 8 segments batched into 3 dialogue requests while the 3 host turns rendered individually.
Inside a dialogue run, pause_after is ignored (the model owns pacing) and per-speaker
voice_settings/speed don't apply — the endpoint takes a single config.dialogue_stability
(0–1) for the whole request.
The trade: dialogue buys pacing and sells partial retry. A run is one request, so it is
all-or-nothing. If a single line comes out wrong there is no way to re-render just that line —
fixing it re-spends every character in the batch. segments renders each turn independently, so a
bad one costs only itself.
That bites hardest on ElevenLabs, where quota is drawn down by the characters you submit and tiers
can be small (the free tier was 10,000 characters/month as of 2026-08; check your account rather
than trusting this number). Compounding it: inside a batched run all your direction has to live in
inline audio tags ([whispers]), since pause_after and per-speaker voice_settings are inert
there — and tags count too. So the expressive mode is also the one where a retry costs the most. The
production run behind this note was a single 11-turn dialogue request totalling 1,730 characters;
one bad line would have cost all 1,730 again. (Per-run, not per-episode — an episode split across
three requests only re-spends the one containing the bad line.)
Watch total run length, not just turn length. The 2000-character budget is per request —
the sum of consecutive turns — so where a run splits decides what actually gets batched. The
planner never emits an over-budget request, so nothing fails; the consequence is quieter than a
failure. A turn left alone in its own voice after a split renders as an ordinary segment: exactly as
segments mode would, so it regains its pause_after and per-speaker voice_settings/speed,
but without the model-paced turn-taking you chose dialogue for. It costs no extra characters —
what you lose is pacing, not quota.
Two ways to land there, and the first is both likelier and quieter:
- A split strands the tail. Three 900-character turns (
a, b, a) batch as one request of turns 1–2, leaving turn 3 alone as a segment — with no turn anywhere near the ceiling. - One over-long turn takes its neighbours with it.
a, b, awith a 2500-character middle renders all three as segments, because the flush leaves each short turn single-voice too. This one at least announces itself: with no run left to batch, the render logs a warning.
So "keep every turn short" is not sufficient advice on its own — plan the running total, and expect the tail of a split run to fall back when it lands single-voice.
You don't have to work it out in your head. Every dialogue render logs
Dialogue mode: N/M segments batched into K conversation request(s)
on stderr, where M - N is how many turns did not batch. In an all-ElevenLabs episode those are
exactly your stranded turns: the first example above reports 2/3 segments batched into 1 conversation request(s), and the 1 is turn 3. In a mixed episode the gap also counts turns that
were never eligible — OpenAI speakers, models without dialogue support — so treat it as an upper bound there
and read the shortfall against the turns you expected to batch.
(The ceiling exists because an over-budget request can terminate the stream mid-conversation, indistinguishable from a complete take, so the provider layer refuses one outright rather than return a short one.)
Rules of thumb: segments for exact gap control, per-speaker tuning, cheap retry, or a tight
character budget; dialogue for natural conversation on a script you're confident in.
sanzaru podcast generate @episode.json --render-mode dialogue -o ep.mp3wait ID... (mixed-type poller) · capabilities (version, per-feature availability with
reasons, configured paths, storage backend, API-key presence, command map) · serve (MCP
server).
# Reference image → video pipeline (price first, then render and continue the shot)
IMG=$(sanzaru image generate "futuristic pilot in mech cockpit" --size 1536x1024 -o ./work/ | jq -r .result.file.path)
REF=$(sanzaru image prepare "$IMG" --aspect-ratio 16:9 --mode crop | jq -r .result.file.path)
sanzaru video create "the pilot glances up, takes a breath" --image "$REF" --duration 5 --dry-run | jq .result.cost
ID=$(sanzaru video create "the pilot glances up, takes a breath" --image "$REF" --duration 5 \
--max-cost 3 -o ./out/pilot.mp4 | jq -r .result.id)
sanzaru video extend "$ID" "he reaches for the throttle" --duration 5 --max-cost 3 -o ./out/pilot2.mp4
# Batch assets with partial-failure retry
sanzaru image generate "app icon" "hero banner" "404 art" --quality high -o ./art/ > results.jsonl
jq -r 'select(.ok | not) | .input.prompt' results.jsonl # exit 6 → retry just these
# Background one-shot for agent harnesses (progress lands in the log)
sanzaru video create "..." -o ./out/clip.mp4 --timeout 25m 2> progress.log &- One
AsyncOpenAIclient per invocation is shared across every call (a 10-minute poll loop reuses one connection pool instead of a TLS handshake per poll). - The polling loops live in
sanzaru/polling.py(wait_for_video/wait_for_image) — pure async, adaptive backoff with jitter, transient errors retried until the deadline (a per-provideris_transientpredicate), 404 fails fast. - Video uses a thin in-house Higgsfield client (
sanzaru/higgsfield/client.py), not the official SDK: that SDK resends the generation POST on 5xx/429 — a duplicate charged job. sanzaru never resends a submit that may have left; the key never reaches the presigned-upload or output hosts. -oworks by installing a per-invocationLocalStorageBackend(path_overrides=...)viasanzaru.storage.set_storage_backend(); the tool layer still validates basenames. The MCP server never touches these overrides.- All commands are registered regardless of installed extras — a missing extra returns a
configenvelope (exit 3) with the exact install command, so--helpoutput is stable everywhere.