Skip to content

Latest commit

 

History

History
642 lines (518 loc) · 38.9 KB

cli.md

File metadata and controls

642 lines (518 loc) · 38.9 KB

Sanzaru Agent CLI

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 here

The contract

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

Exit codes

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.

Global flags

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

Async jobs: create → wait → download

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

While 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 100s

sanzaru 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.jsonl

A pre-0.13 Sora id (video_…) is a usage error (exit 2) explaining that OpenAI retired the Videos API on 2026-09-24.

Output paths and inputs

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

Constraints: 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.mp3 cannot quietly substitute itself for the library's (running from inside the media directory itself is one file, not two, and says nothing). Write ./name to mean the local file with no note. A bare name whose local match is a symlink is refused outright — ./name opts into that explicitly.
  • -o never writes through a symlink. The destination, the staging file, and podcast rundown -o all open with O_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, rundown included. 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 as file.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 own O_NOFOLLOW opener.

Long content

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

Command reference

sanzaru video — Higgsfield jobs

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

sanzaru image — two generation paths

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

generate/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.

sanzaru audio — synchronous audio ops (requires sanzaru[audio])

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.

TTS providers

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.

Character usage and quota

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:

// audio speak
"result": {"output_file": "...", "provider": "elevenlabs", "model": "eleven_v4",
           "characters": 1730, "requests": 1}
// podcast generate — a list, since one episode can mix providers
"result": {"usage": [{"provider": "elevenlabs", "model": "eleven_v4",
                      "characters": 1730, "requests": 1}]}

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

sanzaru podcast

Three 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

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

simulate

simulate [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-run
sanzaru: 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.mp3

Progress 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 acts

The 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

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.

--verify

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 modes

--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 (ElevenLabs eleven_v4, eleven_v4_turbo or eleven_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, a with 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.mp3

Top-level

wait ID... (mixed-type poller) · capabilities (version, per-feature availability with reasons, configured paths, storage backend, API-key presence, command map) · serve (MCP server).

Recipes

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

Implementation notes

  • One AsyncOpenAI client 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-provider is_transient predicate), 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.
  • -o works by installing a per-invocation LocalStorageBackend(path_overrides=...) via sanzaru.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 config envelope (exit 3) with the exact install command, so --help output is stable everywhere.