Onepin
Drive a Onepin voice-workflow workspace through the onepin CLI. The CLI is the only
integration surface — this skill teaches its contract, not a frozen command list.
What this product is
Onepin turns scripts into speech. A workflow is a graph of nodes that normalizes text, synthesizes audio, translates, and validates the result; runs are asynchronous — start one, then poll it. Three rules follow from that and they outrank the mechanics below:
- The deliverable is audio — play it out loud. Every
sample_url/language_sample_url/playback_urlthe CLI returns is a plain audio file on an ordinary presigned URL (no auth header, good for about an hour). Playing is the default, not one of two equal options:--playandafplayare already in your hands, so a reply that hands over links while sitting at a terminal that can make noise has made the user do the last step themselves. A titled markdown link is the fallback for a shell with no speakers — and a bare URL is never either. See Audio: the part you must not skip. - Announce, then play — don't ask for permission to play. Say whose voice or which line is coming, then play it in the same turn; sound arriving with no warning — especially at the end of a long run the user stopped watching — is startling. "Announce" means one line naming what is about to be heard, not a question. Stopping at "here are the samples, have a listen and tell me which" is the failure this rule exists to prevent: you were holding the audio and handed back homework. Ask first only when playing is itself the imposition — a long export, or audio they did not ask for. A finished run whose last message is a file path and a metrics table has failed the same way. See Ending a run.
- Reuse before you build, and make it the user's choice. Before authoring a workflow, look at what the workspace already has and offer both paths. See Reuse before you build.
The flow
"Make me some speech" is not one command — it is a short conversation with decisions that belong to the user, not to you. Walk it in order and stop at each question.
- Which workflow?
onepin --json workflows listandtemplates list. Put the existing workflows (named with their languages and voices), the gallery templates, and "build a new one" into a single question. → Reuse before you build - Show it before it costs anything.
workflows show <workflow_id>→ render the pipeline, the voice per language, and the quality gates. → Show the workflow before you run it - The voice — keep it, hear it, or change it? Ask; don't assume the saved one is wanted.
- hear it:
voices list --language <locale> --buildable --search <name>to build the shortlist, then announce it andvoices sample <id>... --language <locale> --play— every candidate in one call, not just the one whose name you liked. The shortlist and the audio go out in the same turn: name them 1..N, play them 1..N, ask for a number. → Audio: the part you must not skip - change it: ask what they want it to sound like, then hand that description to the server —
voices list --language <locale> --buildable --search "<their words>"comes back ranked and already narrowed. → Let the server pick the shortlist. Then stop:workflows set-voiceis one command, but a voice change is not run-scoped — it rewrites the saved workflow, and it needs a yes of its own before you touch anything. → Changing a voice is not run-scoped
- hear it:
- The script. Their exact text on
workflows run --script, with--source-languagewhen it isn't the saved one. → The script is the user's - Price, then permission.
workflows preview-run <workflow_id>with the same--script/--source-languageyou are about to run → show the expected credits → get an explicit yes →workflows run. Pollruns status, or run with--watch --timeout 300. Every run, however small; ifpreview-runfails, make it work — never turn the script's length into a credit figure. → Running a workflow - Hand over the audio.
runs data→ announce → play every line (link it only when this shell cannot play). Your closing message carries the sound, or a one-line offer to play it — a path and a table is neither. A run that finished and was only described in text is not finished. → Ending a run
If they chose to build a new one, step 2 becomes its own set of questions — ask, don't pick for
them, lead with a recommendation rather than a menu, and confirm every slug against nodes list
first. → Designing a new workflow
- Source —
source_script(their text or an upload). - Operators — a
operator_normalizer(numbers, dates, abbreviations → spoken form)? anoperator_translator(target_languages) if they want other languages? anoperator_phoneme_injectorif pronunciation matters? - Generator —
operator_generator, onevoice_mapentry per locale. Build each entry from avoices list --language <locale> --buildablerow (voice_idis the row'sprovider_voice_id, not itsid— see reference.md). Avoice_mapentry is pinned: it is what every future run of this workflow uses, so it is the last place to offer a voice the API cannot synthesize. → Pin a voice you can actually build - Validators — which checks, and at what bar: word accuracy, naturalness, clarity,
pronunciation. Each has a
thresholdandmax_retries, and the defaults differ per validator — read the real one out ofnodes list(.config_schema.threshold.default) and quote that number to the user instead of saying "the default". Leaving validators out is itself a decision, and one only the user gets to make. → Designing a new workflow - Sink —
sink_preview(format:wavormp3).
Then workflows definition-schema → workflows create --definition @wf.json → and you are back at
step 3. There is no separate validate command: create is what rejects an invalid graph.
Golden rules
- Discover, don't guess.
onepin schemaprints a JSON manifest of every command (path,args,options, and adestructiveflag). Look a command up before building it; never invent flags from memory. - Put
--jsonimmediately afteronepin— e.g.onepin --json workflows list. This global position works for every command; a trailing--json(e.g.onepin whoami --json) fails on some commands. Parse stdout as JSON. - The exit code is the success signal, not stdout. With
--json, success prints the JSON payload to stdout; a failure prints a{"error": {"code": "...", "message": "..."}}envelope to stderr and exits non-zero. So: exit0→ parse stdout; non-zero exit → read the error envelope from stderr. Don't expect anerrorkey on stdout (on failure stdout is empty). - Exit codes:
0ok ·1API/runtime error ·2usage error ·130interrupted. - Never run a destructive command without explicit confirmation (see below). Don't add
--yesto silence a prompt. - Never start a run without explicit confirmation.
workflows runspends the user's credits and is not--yes-gated, so nothing stops you but the procedure in Running a workflow. There is no cheap-enough, small-enough or obvious-enough exemption — ask every time.
Preflight (do this first, once)
onepin --version— if "command not found", tell the user topip install onepin, then stop.onepin --json whoami— confirms authentication. On success noteworkspace_id,scopes.- Unauthenticated (
NOT_LOGGED_IN/INVALID_API_KEY) → tell the user to runonepin login(mint a key at https://app.onepin.ai/workspace/~/settings/api), then stop. - Pass
--workspace <uuid>(or setONEPIN_WORKSPACE_ID) only if the user has more than one workspace and the wrong one is active.
- Unauthenticated (
- If
onepin schemaerrors or the command is missing, the CLI is old — suggestpip install -U onepin, or fall back toonepin <group> --help. - Offer upgrades (advisory, never block the task). Run
onepin upgrade-check(it prints nothing when up to date, snoozed, offline, or disabled viaONEPIN_NO_UPDATE_CHECK).- On
UPGRADE_AVAILABLE <current> <latest>: ask with AskUserQuestion — Upgrade now (runonepin upgrade-check --mark-upgrading && pip install --upgrade onepin— the marker lets the next run confirm the new version), Not now (runonepin upgrade-check --snooze— an escalating quiet period: 24h, then 48h, then 7d), or Don't ask again (runonepin upgrade-check --disable). Continue with the task either way. - On
JUST_UPGRADED <old> <new>: tell the user they're now on v<new>and continue. Separately, if any command fails withUPGRADE_REQUIRED(or an HTTP 426), the SDK is too old to talk to the API — surface the message and itspip install --upgradecommand, and stop.
- On
Discover, don't guess
onepin schema → {name, version, commands: [{path, args, options:[{flag,type,required,default,help}], destructive}]}.
Look up the exact command before building it. destructive: true ⇒ it has --yes ⇒ apply the
confirmation rule below.
Core flows
Lead every command with --json (onepin --json <command> …); summarize results for the user
rather than dumping raw JSON. Two things are not summaries: the audio (play it or link it —
see below) and the workflow you are about to run (show its shape before it costs anything).
Workflows
- List:
onepin --json workflows list— filters--status,--search,--sort,--order,--limit,--offset. One page is not the result set: default--limitis 50 and the max page is ~100 (larger values return422 VALIDATION_ERROR), so walk a bigger set by stepping--offsetby the--limityou passed —--limit 100 --offset 100,--limit 100 --offset 200, … A stride wider than the page silently skips the rows in between. Without--jsonthe footer printsShowing X of N—Nis how many matched, so it is the number that tells you whether you are holding all of them; it already accounts for--offset, so page until it says no more. Filters still beat paging: narrow with--searchfirst and page only when the user genuinely wants the whole set. - Inspect:
onepin --json workflows show <workflow_id> - Estimate cost before running:
onepin --json workflows preview-run <workflow_id>— takes the same--script/--source-languageasrun, and pass them whenever the run will, because the estimate prices the body it is given, not the one you intend to send. No run, no credits. - Run (starts a real, billable execution):
onepin --json workflows run <workflow_id>. A run consumes credits and acts on the live workspace. It is not--yes-gated — confirm with the user first, every time, by the procedure in Running a workflow. - Run and wait:
onepin --json workflows run <workflow_id> --watch --timeout 300— polls to a terminal state (completed/failed/cancelled) and returns the final status inline (no separate status call). Exit is non-zero if the run failed or timed out. - Run one-off text without editing the workflow:
--script "<text>"overrides the saved script for that run only; add--source-language <bcp-47>(e.g.en-us) when the text isn't in the workflow's saved language. The workflow itself is left untouched. - Build/design a workflow: check Reuse before you build first — then ask the catalog
(
nodes list— slugs, ports, config keys, plan gating) and read the Designing a workflow topology rules (sources → processing → generators → validators → sinks; validator pass/fail pins and retries) in reference.md. Discover slugs withonepin nodes list— never invent them.
Runs
- List / inspect:
onepin --json workflows runs list <workflow_id>·runs show <workflow_id> <run_id>(the full record) ·runs status <workflow_id> <run_id>(just the state — the one to poll). - Diagnose a run (why it failed, what each node did):
runs overview <workflow_id> <run_id>for the per-node rollup, thenruns steps <workflow_id> <run_id>for the individual steps. Step results are lightweight by default — pass--include-resultfor full result payloads, and narrow with--node-type <type>/--node-id <id>(combinable) instead of pulling everything. - Output rows:
onepin --json workflows runs data <workflow_id> <run_id>—--search,--language, and, unusually for this CLI, real paging via--limit/--offset. - Stats over a window:
onepin --json workflows runs summary <workflow_id> --from <iso> --to <iso>. - Download outputs to a file:
onepin workflows runs download <workflow_id> <run_id> --out export.zip(atomic; refuses to overwrite without--force). For a single node's output:onepin workflows runs download-node <workflow_id> <run_id> <node_id> --out <path>.
Voices
onepin --json voices list— every filter is applied server-side:--search,--language,--gender,--age,--category,--accent,--source,--provider,--model,--favorites-only, plus--sort/--order. Filters AND across fields; a comma-separated value ORs within one. Ask the server for the shortlist instead of paging the catalog → Let the server pick the shortlist.--buildablenarrows the list to voices this API can actually synthesize for--language: measured above the quality floors, on an enabled and currently-routable provider/model you can be billed for (or hold your own key for). Without it,voices listis a raw catalogue browse that can hand back a voice a run would refuse. It requires--language(quality is measured per locale, so the question has no answer without one) and the CLI rejects the pair before sending. Multiple locales OR: a voice is returned if it is buildable for at least one of them. → Pin a voice you can actually buildonepin --json voices facetsanswers "what can I filter by, and how much is left?" — one call returns every provider, model, language, gender, age, category and accent that exists, each with a match count, and it takes the same filters so the counts narrow as you add them. Itsvalues are exactly whatvoices listaccepts, so use it instead of guessing a locale code into a422or offering the user a filter that would return nothing. It does not take--buildable, so its counts are ungated and can be larger than a--buildablepage — read a chip as "exists", never as "buildable".--languageaccepts only specific comma-separated codes (e.g.en-us,en-gb,en); an unsupported code returns422— don't guess regions, and note a voice's ownsupported_languagesmay be broader than the filter codes.- Same paging as above (
--limitdefault 50, ~100 max,--offsetstepped by that same--limitto walk) — but a voice catalog is one of the sets you should not walk by hand: an unfiltered page is an arbitrary slice, and the point of the filters is to make the first page the right one. onepin --json voices show <voice_id>·onepin --json voices similar <voice_id>·voices favorite/unfavorite <voice_id>(andvoices list --favorites-only). None of these take--buildable— the gate is alistfilter, and these already have the id.- Audition them:
onepin voices sample <voice_id>... --language <locale> --playtakes as many ids as you have candidates, mints a fresh sample URL for each, and plays them in order, naming each voice just before its clip.--playis the normal form;--out-dir <dir>writes files instead, and passing neither prints name / locale / model / URL to hand over when there is no way to play. Sample URLs expire after about an hour — re-run the command to re-sign rather than reusing an old link. - Voices are chosen by ear. Don't stop at the names — see Audio: the part you must not skip for which sample URL to play and how to announce it.
Templates
onepin --json templates list— filters--category,--sort,--searchonepin --json templates show <template_id>
Let the server pick the shortlist
A voice catalog is far bigger than one page — voices list returns at most ~100 rows, and one
locale can hold several pages of them. --offset will walk the whole thing, but reading a few
hundred rows of near-identical tags is not how anyone picks a voice, and whatever you pick off an
arbitrary page was not really chosen. The fix is not to page harder: every filter on
voices list runs server-side, so state the requirement and let the server hand back the
shortlist.
- Describe the voice —
--searchtakes words, not just names. The server matches the query against a voice's meaning as well as its name, tags and descriptor, and returns one relevance-ranked list, so the user's own phrasing is the query:--search "warm, unhurried documentary narrator"surfaces calm, measured voices that share no literal word with it. Pass what the user said. - Pin every axis you actually know — there is a flag for each.
--language <locale>(which also fillslanguage_sample_urlon every row, so the shortlist is auditionable without a call per voice),--gender,--age,--category(the delivery style:narration,podcast,news, …),--accent,--source(platformvs. this workspace's own),--provider,--model, and--favorites-onlyfor what this workspace already liked. Don't pull rows you could have excluded in the request. Once you have a locale, add--buildable— a voice the API cannot synthesize is a row you could have excluded, and the most expensive kind to leave in. → Pin a voice you can actually build - Ask
voices facetswhen you don't know what to ask for. It reports the values that exist with a count each, under the filters you already have — so "is there even a Korean conversational voice?" is one call, not a search that comes back empty and tells you nothing. Reach for it before guessing a value, and after an empty result to see which axis emptied it. - Refine on the returned rows only for what has no flag, and say that you did:
tags,description,uses_count. Anything with a flag belongs in the request instead. And a row-level refinement is never a catalog-wide answer — it only sorted the page you were handed. - "More like that one" is also a server call.
voices similar <voice_id> --language <locale>when the user liked a voice but not quite — better than re-listing and re-reading names.
Empty means widen, not enumerate. A --search plus three filters can legitimately match
nothing. Relax --search first (it is the fuzziest constraint), then one filter at a time, and tell
the user what you dropped — or run voices facets with the same filters to see which axis is the
one at zero. The wrong recovery is an unfiltered voices list read by eye.
Zero under --buildable is a different sentence. The gate narrows pagination.total as well
as the page, so a Showing 0 of 0 says nothing buildable here, not no voices here — and
voices facets will still report a non-zero count for the same locale, because its chips are
ungated. Don't report the locale as empty. Say the gate emptied it, and offer the ungated list
(voices list --language <locale>, no --buildable) so the user can decide whether to wait for
the provider or pick another locale. The CLI prints this distinction on stderr; pass it on rather
than reading total: 0 off stdout and drawing your own conclusion.
Read the count, and check the row is usable. The row count is bounded by --limit and is never
"how many matched" — the match count is the N in the Showing X of N footer (text output; under
--json you get the rows alone, so page to find the end). And a row can be listed but unusable:
check is_active / availability before offering a voice, and check that the model you mean to
wire appears in its model_capabilities[] for that locale.
Then hand the shortlist over by ear, not by name.
Pin a voice you can actually build
voices list is a catalogue browse. Being listed means the voice exists; it does not mean this
API can synthesize it for the locale you care about — the measured quality of a
(provider, model, locale) triple, whether that provider and model are enabled and routable right
now, and whether there is a rate you can be billed for are all separate questions, and the first
place they get asked is the moment a run starts. --buildable asks them up front instead.
Use it wherever the voice is about to be pinned. A voice_map entry and workflows set-voice
both write the choice into the saved workflow, so a voice that cannot be built is not a failed
call — it is a workflow that fails on every future run until someone edits it back.
onepin --json voices list --language ko-kr --buildable --search "warm, unhurried narrator"
onepin --json voices list --language ko-kr --language en-us --buildable # buildable for EITHER
Three rules to keep it honest:
- It needs
--language, always. Quality is measured per locale, so "buildable" has no meaning until you say buildable for what. The CLI refuses the pair with a usage error (exit2) rather than sending it. A name lookup with no locale in hand stays ungated — that is a lookup, not a shortlist. - It is not a guarantee. A provider outage is deliberately outside the gate: excluding a vendor whose circuit breaker just tripped would empty a whole locale on a blip, so buildable voices keep listing through one. Offer a buildable voice as "nothing structural rules this out", never as "this will work".
- It is not a lasting fact about a voice. The answer moves with provider routing and can differ between two calls a minute apart with no catalogue change at all. Don't cache it, don't write it into notes, and re-run the query rather than reusing yesterday's shortlist.
If the CLI says it could not confirm --buildable, the API it is pointed at may not know the
parameter — an older deployment drops an unknown query parameter and answers with the full
catalogue. Treat that list as unfiltered and say so; do not tell the user it was gated.
Audio: the part you must not skip
The user paid credits for sound. A reply that lists voice names, or reports "the run completed", and never puts audio in front of them has not delivered the thing they asked for.
Hearing a voice. Have the server narrow it first (Let the server pick the shortlist), then
audition what comes back. onepin --json voices list --language <code> --buildable --search "<what they asked for>" — each row's
language_sample_url is the clip in the language you filtered for, and language_sample_locale
is the region it actually came from: report that, not the code you asked for (a bare family like
en expands to en-us or en-gb, and only that field says which one they heard). A row's plain
sample_url — also on voices show <voice_id> — is playable but does not follow --language,
so it may be the wrong language; use it only as a fallback when language_sample_url is null.
Tags are a filter, not an audition. bright, clear, friendly are how you got the
shortlist; they are not how anyone picks a voice. One locale can carry dozens of voices whose tags
are near-identical — a user reading that list is choosing adjectives, not sound. Hand over the whole
shortlist by ear instead, in one call:
onepin voices sample <id-1> <id-2> <id-3> --language ko-kr --play # the default: announce, then play
onepin voices sample <id-1> <id-2> <id-3> --language ko-kr # name / locale / model / URL
--play is the first form for a reason. It fetches, writes and plays in order, naming each
voice on its own line before that clip starts, so a shortlist played in one call is still
followable by ear. Announce the list in your own reply too — same numbering as the command's
argument order — and then let it run. The bare second form is for a shell that cannot play: it
prints one row per candidate to turn into titled links.
Asking "which of these names do you want?" while holding all of their samples turns a decision the
user could have made in thirty seconds into a guess. So does "I've downloaded them, have a
listen" — the fetch is not the deliverable, the sound is, and --play is one flag away.
Report the locale you were served, not the one you asked for. voices sample prints it per row,
and says so explicitly when a voice had no preview in the locale you requested and it fell back to
that voice's default sample — which may be another language entirely. Pass that on; a user
comparing Korean voices needs to know when one of them spoke English.
Hearing a run. onepin --json workflows runs data <workflow_id> <run_id> → rows[].cards[],
one card per line per locale, each carrying script, locale_code, voice, validations[],
retry_count and audio.playback_url (plus audio.status, duration_ms, provider, model).
That is the per-line audio. A card whose audio.status is not ready carries no playback_url —
say so instead of reporting that line as delivered, and the same for dropped / rejected cards.
If you narrowed with --limit / --offset, tell the user the page is partial. For files on disk
instead of URLs: runs download (whole run) or runs download-node (one node).
Playing it. For voices, voices sample <id>... --play does the whole thing — fetch, write,
play, in the right order for the platform. For a run's lines you still hold a URL, and every one of
these URLs is a plain audio file with no auth header valid for about an hour — re-run the command to
re-sign rather than caching it.
Whether this shell can make noise is a question with an answer — settle it once, up front, instead
of assuming it cannot and reaching for links. Don't assume afplay either: it is macOS-only, and
your shell may be Linux, WSL, a container, or someone else's box entirely.
[ -n "$SSH_TTY$SSH_CONNECTION" ] && echo "remote shell — link instead"
for c in afplay ffplay mpg123 mpv cvlc paplay aplay powershell.exe; do command -v "$c"; done
A player on a local shell means play; an empty second line means this shell cannot make sound, so say that rather than reporting a clip as played. Neither line is a reason to ask the user's permission first — they are the only reasons to fall back to links. Run it early, before you have audio in hand, so a missing player changes the plan instead of stranding a finished run.
Whatever survives takes different flags, and only some accept a URL, so wrap the answer once and stop thinking about it:
play() { # $1 = presigned URL or local file
case $1 in
http*) # these three stream the presigned link as-is
command -v ffplay >/dev/null && { ffplay -nodisp -autoexit -nostats -loglevel quiet "$1"; return; }
command -v mpv >/dev/null && { mpv --no-video --really-quiet "$1"; return; }
command -v mpg123 >/dev/null && { mpg123 -q "$1"; return; }
f=${TMPDIR:-/tmp}/onepin-clip # the rest need a file on disk
curl -fsSL "$1" -o "$f" || return 1
play "$f"; return ;;
esac
for c in afplay ffplay mpg123 mpv cvlc paplay aplay; do
command -v "$c" >/dev/null || continue
case $c in
ffplay) ffplay -nodisp -autoexit -nostats -loglevel quiet "$1" ;;
mpv) mpv --no-video --really-quiet "$1" ;;
mpg123) mpg123 -q "$1" ;;
cvlc) cvlc --play-and-exit -q "$1" ;;
*) "$c" "$1" ;; # afplay · paplay · aplay: file in, no flags, blocks
esac
return
done
return 127 # nothing POSIX here — try *Windows* below, then links
}
afplay, paplay and aplay take a file, not a URL — hence the curl — and paplay / aplay
are WAV-only, so on Linux let the streamers win. All of them block for the clip's whole duration:
right for a sample or one line, wrong for a whole export, which is what runs download is for.
Windows. The shell there is PowerShell, not bash, so none of the above applies. voices sample --play already covers itself on Windows from inside the CLI (ffplay → mpg123 → the built-in
player); this is for the run-line URLs you play yourself — same idea, one language over, and one
trap that silently eats the whole thing:
function Play($src) { # URL or local path; $true only if it really played
if (Get-Command ffplay -ErrorAction SilentlyContinue) {
ffplay -nodisp -autoexit -nostats -loglevel quiet $src
return ($LASTEXITCODE -eq 0)
}
Add-Type -AssemblyName PresentationCore
$uri = if ($src -match '^https?://') { [uri]$src } else { [uri](Resolve-Path $src).Path }
$p = New-Object System.Windows.Media.MediaPlayer
$p.Open($uri); $p.Play()
$n = 0
while (-not $p.NaturalDuration.HasTimeSpan -and $n -lt 50) { Start-Sleep -Milliseconds 100; $n++ }
if (-not $p.NaturalDuration.HasTimeSpan) { $p.Close(); return $false } # never opened
Start-Sleep -Seconds $p.NaturalDuration.TimeSpan.TotalSeconds
$p.Close(); return $true
}
function Announce($label) { # in the box, blocking, nothing to install
Add-Type -AssemblyName System.Speech
(New-Object System.Speech.Synthesis.SpeechSynthesizer).Speak($label)
}
Play()returns before the sound does.MediaPlayeris asynchronous: drop the duration wait and eight clips "play" in a blink of silence, every exit code0. That wait is the only reason this is longer thanafplay "$1"— and$falsecoming back means it never opened, so hand over the link instead of reporting a clip the user never heard.- Give it an absolute path.
[uri]"clip.mp3"is a relative URI andMediaPlayerresolves it against somewhere that is not your working directory, then plays nothing at all — hence theResolve-Path. A URL goes in untouched. System.Media.SoundPlayeris WAV-only. It is what every snippet on the web reaches for first, and Onepin hands you mp3 most of the time — it will throw, or play nothing. That is why the CLI keeps it last.MediaPlayergoes through Media Foundation, takes both, and opens an http URL directly, so a presigned link needs no download at all.start "" "<file>"is not playback. It hands the clip to whatever app owns mp3, returns instantly, and leaves a window open on their screen. Last resort — and say that is what you did.- Git Bash and WSL are POSIX shells on a Windows box. WSL2 with WSLg has real audio, so probe
inside Linux first —
ffplayorpaplaythere is one moving part instead of two. Otherwise cross over withpowershell.exe -NoProfile -Command, converting the path first (wslpath -wunder WSL,cygpath -wunder Git Bash):/mnt/c/...and/c/...mean nothing to Windows.
Three things this cannot tell you:
- Exit
0does not mean they heard it. The player returns success whether the output device is headphones, a muted monitor, or something else entirely. Say what you just played, and offer the link if they say they heard nothing. - There may be no audio device at all, and a player on
PATHis not one. Over SSH, in a container, in CI, in a cloud session the binary runs happily and the sound goes nowhere — that is what the check above is for, and then you go straight to the links below. - Playing makes noise on someone's desk. That is why the announcement rule above is not optional — but announcing is a sentence, not a request. Say what is coming and play it.
runs download is not delivery. It writes an export the user asked to keep. Saving the clips
to a folder and telling them to open it hands back homework, not audio — if a player is available,
the sound leaves their speakers before your message ends.
Playing a run's lines. A shortlist of voices is voices sample --play's job; don't hand-roll
that. A run's lines are the case you still play yourself, and a question between every clip destroys
the comparison — so play them in one blocking loop, and speak each label, because someone
listening is not reading your terminal:
announce() { # speak the label, or print it where nothing can speak
for c in say spd-say espeak-ng espeak; do
command -v "$c" >/dev/null || continue
"$c" "$1"; return # macOS `say -v <voice>` if the label is not English:
done # `say -v '?'` lists them (Yuna = ko-KR, Kyoko = ja-JP, ...)
printf '> %s\n' "$1"
}
while IFS=$'\t' read -r idx url; do # line_index + playback_url in order — recipe in reference.md
announce "line $idx"
play "$url"
done
Take the label from the response's own line_index, never from an array you index by hand — that is
how clips get announced under their neighbour's name (zsh indexes from 1, bash from 0), and a
mislabelled audition is worse than none: the user picks the wrong take and never finds out. Where
nothing can speak, announce prints instead — so state the running order in your message before the
first clip, because a printed label scrolls past someone who is listening.
Announce it first — whose voice, or which line. If the check above says you cannot play sound, hand over one titled markdown link per clip, each on its own line:
[▶ Line <line_index>](<playback_url>)— use the response's ownline_index, never the script text, which can contain brackets that close the link early and send the click elsewhere.[▶ <voice name> (<locale>)](<language_sample_url>)— dropping any brackets or parentheses from the name, for the same reason.
Never paste the bare URL. It is a presigned link hundreds of characters long, and the title is how the user knows whose voice, or which line, they are about to open.
Ending a run. This is where the whole flow leaks. The credits are spent, the gates passed, and the easiest message to write is a file path, a validation table and "opened the folder" — a report about audio containing none of it. Exactly two endings count as delivered:
- You played it. One line naming what is about to play, then the clip. This is the default. Seventeen seconds of audio answers "how did it turn out?" better than any table you can build.
- You offered — one closed question, as the last line of your message. "Want to hear it?" Reach for this when the run took long enough that they stopped watching, or is long enough that unprompted sound would be a nuisance. Then stop, and play it the moment they say yes.
"Opened the folder" is neither. Nor is a titled link, unless you genuinely cannot play — and you know which you are, because you probed. Retry counts, MOS scores and credits are context around the deliverable. The sound is the deliverable.
The script is the user's
Whatever reaches a run is the user's bytes: pass their exact text to workflows run --script, from
the message or file they gave you. Do not quietly normalize, re-punctuate, translate, or "clean up"
a script on the way in — TTS output changes audibly when the text does. If a change would help
(expanding numerals, fixing an obvious typo), show the rewrite and get agreement first, then run it
as its own --script. --source-language is BCP-47 and a bare family resolves to a region, so
report the region that was actually used rather than the code you passed.
Changing a voice is not run-scoped
--script and --source-language are the only run-scoped overrides workflows run has. There is
no --voice flag, so "use a different voice just for this one" — an entirely ordinary thing to want
— is not something the CLI can do. Every route to a new voice is an edit to the saved workflow:
workflows set-voice <id> --locale <locale> --voice <catalog_voice_id>— the one to use. It moves a singlevoice_mapentry, checks the voice can actually speak that locale and that the model covers it, and prints the assignment it replaced so you can put it back. Add--node-idwhen the graph has more than one generator; it refuses to guess rather than picking one.workflows update <id> --definition @wf.json— the whole-definition path. Only for changesset-voicecannot express, because it rewrites every node to move one field.workflows duplicate <id> --name "<name>"first, then set the voice on the copy — leaves the original exactly as it was, at the price of one more workflow in their list. Without--nameevery copy is called<original> (Copy), indistinguishable a week later; name it for the change being made, and report the id you ended up on.
set-voice being one command does not make it a small change. It overwrites the saved workflow
for every future run, and the output says so on purpose. That is also why the candidate handed to
--voice should come off a voices list --language <locale> --buildable row: set-voice checks
the voice can speak the locale, not that this API can currently synthesize it, so an
un-buildable pick passes the check here and fails at every run afterwards.
→ Pin a voice you can actually build
Say which of the two you are proposing, and get a yes for it before the run gate and separately from it — this is a permanent change to something the user built, not a run parameter. Never describe a voice change as leaving the saved definition untouched. A user who is told their choice applies to this run only, and whose workflow is then rewritten, agreed to a run and got an edit.
Reuse before you build
workflows create is reachable without ever looking at what the workspace already has — which is
how a user ends up with a fifth Korean-dub workflow beside four that already did the job. So before
authoring anything:
onepin --json workflows listandonepin --json templates list(--searchto narrow).- Offer both paths in one question. Name each candidate with its languages and voices, and put "build a new one" alongside them as a choice — not as something the user has to know to ask for. Reuse beats authoring when something already does the job, but which it is is the user's call.
- If they pick an existing workflow →
workflows showit (see below), thenpreview-run. If they want something new → clone from the gallery first (templates clone <template_id>); cloning is faster and cannot produce an invalid graph. Author from scratch withnodes list/nodes showonly when nothing in the gallery fits.
Finding a match is not permission to use it. A workflow whose name looks like what the user asked for is a candidate. That distinction is the whole point: what you are about to adopt carries a saved voice, a saved language set and saved quality gates the user has not seen yet. Put the three real options to them — reuse it as is, duplicate it and change the copy, build a new one — and stop on the question. Don't find it, inspect it and adopt it inside one turn: "there's already a workflow for this, let me pull up its definition" reads as a progress report while it is in fact the decision, taken without them.
What counts as "already exists" is narrow and exact: the same (normalized) name, or an identical language set and an identical multiset of node types. Nothing fuzzy. A near-miss reported as a duplicate is worse than no check at all — it teaches the user to wave the question
…(truncated)