The worker protocol and the capability document
The contract, written before the code.
Rhapsode is a multi-engine speech server: one HTTP contract, many TTS models behind it, each in
its own process. MaroonedSoftware/rhapsode, published as ghcr.io/maroonedsoftware/rhapsode, with
the engine SDK as the Python package rhapsode-worker. A rhapsode was a performer who recited written verse
aloud, which is the job description.
Two audiences read this file and they need different things. A client author needs the public API and the capability document, and should never learn that workers exist. An engine author needs the worker protocol and the Python SDK, and should never learn that the core is TypeScript. The seam between them is the point of the design, so each half is written as if the other were somebody else's project.
Every rule below that looks arbitrary is one somebody already paid for, and says so.
1. The one shape
A worker speaks the engine-scoped subset of the public API. There is no second protocol.
| Public API | Worker |
|---|---|
GET /engines | (the core aggregates) |
GET /engines/{id}/capabilities | GET /capabilities |
GET /engines/{id}/voices | GET /voices |
POST /speak (with engine) | POST /speak |
POST /engines/{id}/dialogue (§ 6) | POST /dialogue |
GET /health | GET /health |
| (core policy) | POST /load, POST /unload, POST /terminate |
POST /engines/{id}/pull (§ 10) | POST /fetch |
The core also answers routes no worker has, for installing and removing engines. They are about the box rather than about speech, and § 10 describes them.
This is the single decision everything else falls out of, and it buys four things:
- An engine author develops and tests against the worker alone, with curl. The core is not a dependency of writing an adapter.
- A remote worker is a URL. Local and remote are the same code path, not two, so "engines on another box" needs no new protocol and no new bugs.
- The core is a router, a registry and a supervisor. It holds no engine knowledge, which is what keeps the "core never imports torch" rule structural rather than aspirational.
- One shape to learn, document and version.
Transport: HTTP over a Unix domain socket by default, TCP when configured. Same protocol either way. The socket avoids port allocation in the common case and dies with its directory; TCP is what a remote worker uses. Chunked transfer encoding is what makes streaming audio work without inventing a framing layer, which is the reason this is HTTP and not a pipe with length-prefixed messages.
2. Worker lifecycle
Spawn
The core starts a worker with three environment variables and nothing else:
RHAPSODE_WORKER_LISTEN=unix:/run/rhapsode/workers/chatterbox.sock # or tcp:127.0.0.1:0
RHAPSODE_WORKER_ENGINE=chatterbox
RHAPSODE_WORKER_CONTRACT=1
RHAPSODE_WORKER_CONTRACT is the highest contract the core speaks, not a demand. The worker answers
at the highest version it supports that is not above it, and says which in the handshake. §9 has the
rest.
Handshake
The worker prints exactly one JSON line to stdout when it is bound and ready, and nothing else to stdout ever. Logs go to stderr as JSON lines, and the core forwards them into its own logger with the engine id attached.
{"ready":true,"contract":1,"engine":"chatterbox","listen":"unix:/run/rhapsode/workers/chatterbox.sock"}
A line rather than a poll loop because polling is how you end up with TTS-WebUI's 120-second socket wait: a worker that died on an import error is indistinguishable from one that is slow to start, and the operator waits two minutes to find out. A worker that exits before printing the line has failed to start, and its stderr is the error message.
Stdout is reserved for this one line so that a print statement in somebody's adapter cannot corrupt
the handshake. Say so loudly in the SDK docs, and have the SDK capture sys.stdout and redirect it
to the log stream after the handshake, so the rule cannot be broken by accident.
Stop
SIGTERM means drain: refuse new requests as overloaded, finish what is in flight, exit 0. The
error table in § 6 is what fixes the status, and it says 429 and retryable, which is the answer
this case wants: a request refused by a draining worker is one the core should send somewhere else
or send again, not one it should write off. (This paragraph said 503 and the table said 429. The
table wins, because a caller reads the code and the retryable flag rather than the prose.) The
core sends SIGKILL after a grace period it owns. POST /terminate (§3) is the same sequence asked
for over HTTP rather than signalled, and is what reclaims a card from a worker the core cannot
signal.
Which one the core reaches for, and in what order. An eviction sends /terminate first and
signals only if the verb does not land, because that is the one reclaim a remote worker can also be
given: a core that only signals degrades to unload over TCP and loses the 30% an unload strands
(§ 3). A shutdown signals without asking, which is the same sequence by the paragraph above and one
round trip shorter on a path where every worker is going anyway. Either way the grace period is the
core's and ends in SIGKILL: a worker that took the verb and then hung is not a worker to wait on
forever. A remote worker gets the verb and keeps its connection, because the process restarting is
somebody else's supervisor's business and the core will want to talk to its replacement.
An exit the core did not ask for is a crash, and the core restarts it with backoff, holding the
restart count against it. An exit that follows a SIGTERM or a /terminate is not, however it is
timed: a worker that finishes draining a second after the core stopped waiting has done its job.
3. Process residency and model residency are different things
This distinction is load-bearing and is the thing most servers in this space get wrong.
- A worker process costs tens of megabytes. Starting one is cheap.
- A loaded model costs gigabytes of VRAM. Loading one is expensive and exclusive.
So the state machine is per worker:
down → starting → up(unloaded) → loading → up(loaded)
↑ |
+------- unloading ------+
GET /health reports both:
{"process":"up","model":"loaded","variant":"turbo","device":"cuda:0","vramBytes":4831838208,"modelBytes":3355443200}
vramBytes is what the card holds and modelBytes is what this model took of it, measured across
the load by the SDK and reported while it stays loaded. They are different questions and only the
second one helps a core decide what to evict: a budget counted in models assumes every model is the
same size, and Kokoro is 82M parameters where Dia is 1.6B.
modelBytes is optional, because no measurement covers every device, and it is approximate where it
is given: it is a card-wide delta, so it counts the runtime's own context and anything else that
allocated during the load, which is the right answer for "can another model fit" and the wrong one
for "how big are these weights". An adapter that knows the real figure returns it from
Engine.memory_bytes and is believed. What a worker must never report is 0 or a negative: a core
adding up a card reads those as a model that is free, so a measurement that failed is left out
instead.
Three rules, each paid for
POST /speak loads on demand. If the model is not resident, /speak loads it and then speaks.
It does not fail with "no model loaded" and it does not require the client to call /load first.
The alternative was measured on a running station: the client ended up calling a model-info endpoint
before every single synthesis to find out whether the previous request's unload had emptied the
server, because nothing else would notice. That is a round trip per utterance to ask a question the
server already knows the answer to.
A failed load unloads before retrying, exactly once. A CUDA OOM strands its own partial allocations. 3.5 GiB was measured stranded on a 16 GiB card, so an immediate retry throws itself at a GPU it just filled. Unload-then-load-once covers the common case of a card that was briefly full and has since freed up. This belongs in the SDK so every adapter gets it without knowing about it.
Unloading does not reclaim everything; terminating does. An unload reclaims roughly 70% of what
the model held, because the graphics runtime keeps the rest until the process exits. So the core
needs both verbs and must know they differ: unload for "I may want this again shortly",
terminate for "I need the card back". A residency manager with only unload will slowly lose a
card to nothing.
Which is why terminate is a worker verb and not a core one. The obvious implementation is for
the core to signal the process it spawned, and that works for exactly as long as every worker is
local. A remote worker (§1) is a URL on somebody else's box, so a core that terminates by signalling
silently degrades to unload over TCP, and the 30% it cannot reclaim goes unreported.
POST /terminate means drain and exit 0: the worker ends its own process, and whatever supervises
it locally restarts it. The core still sends the signal to a local worker that has stopped
answering, but the verb is what it reaches for first, and it is the only thing that works in both
places.
Core policy, not worker policy
The worker obeys; the core decides. Default policy, all of it settable, and without a restart (§ 10, "Settings"):
maxResidentModels(default 1, which is the right answer for one GPU)- LRU eviction when a
/speakneeds a model and the budget is full keepAliveSeconds(default 300): how long a model with nothing left to do stays on the card.-1keeps it until something else needs the room,0frees it as the last request lets go. Settable per engine as well as server-wide, because the cold start it trades against is per engine: a model that takes forty seconds to load has earned a longer deadline than one that takes two.
An expiry terminates rather than unloads, by rule 3 above: an unload leaves roughly 30% behind, and
a deadline that runs every few minutes would give a card away 30% at a time. This replaces the
idleUnloadSeconds and idleTerminateSeconds pair, which was two deadlines for two verbs and is
one deadline now that there is one verb.
The default is on, which is a reversal. Off was defensible while an expiry was something an operator opted into: it trades a cold start for memory nobody asked for. It stops being defensible as the default on a box with one card, because the memory is being asked for, by whatever wants to run next and finds the card held by a model idle since this morning. A cold start is a cost the request that pays it can see; a card held by nothing is a cost that lands on somebody else.
Putting this in the core rather than in each worker is what stops every adapter author reinventing an idle timer, and it is the only component that can see the whole card.
GET /residency, for the operator asking where the memory went
{
"resident": 1, "max": 1, "waiting": 0,
"models": [{
"engine": "dia", "variant": "full", "leases": 0,
"lastUsedAt": "2026-09-20T11:04:02.118Z", "expiresAt": "2026-09-20T11:09:02.118Z",
"keepAliveSeconds": 300, "sizeBytes": 3355443200
}]
}
expiresAt is absent while a model is speaking, because the deadline starts when the last request
lets go, and absent when its keep-alive says never. keepAliveSeconds is the one actually in force
after the precedence below, not the server's default. sizeBytes is what the worker measured and is
absent where it could not.
It reads the core's own state: it spawns no worker, loads nothing and never blocks on one, which is
the promise /health and /engines already make. Asking each worker instead would make listing
what is loaded a reason to start processes that are not, which is the opposite of what somebody
looking at a full card wants.
It is separate from /health because they answer different questions to different readers: a
monitor polls /health and gets every engine's licence with it. And it is not a route to consult
before speaking. /speak loads on demand, and a client that reads this first to decide whether it
needs to has rebuilt the round trip per utterance that rule 1 exists to remove.
POST /engines/{engine}/unload, for giving the memory back now
?mode=terminate by default, or ?mode=unload to keep the process for a faster next load. It is a
management route (§ 10), behind the same guard as install: somebody who can empty a card can make
every synthesis on the box pay a cold start.
It is idempotent, like the worker verb it reaches for, and answers 200 with the engine's summary.
terminate ends the process even when no model is loaded, which is how an operator reclaims what a
variant switch left stranded (rule 3: an unload keeps roughly 30% until the process exits).
It is refused with 409 while the engine is speaking, for the reason § 7 gives about uninstall:
cutting off a stream in progress hands that caller a truncated file for something they could not
have predicted. On a busy box this can refuse for as long as the box is busy, which is the honest
answer. Draining instead, unloading once the current request finishes, was considered and left out:
the next request may set a new keep-alive, so a promise to unload afterwards is one the core cannot
keep, and a 202 that silently becomes nothing is worse than a 409 that says what is true now.
A request may say how long it wants its model kept
POST /speak and POST /engines/{engine}/dialogue take keepAliveSeconds, meaning the same thing
it means in configuration, for the model that request loads. It is a hint about what happens next,
which is the one thing the server cannot know and the caller often does: a batch about to send
another two hundred lines says -1, and a page that just wants one sentence read aloud says 0 and
gives the card back.
Precedence is request, then engine, then server. Three rules, because each answers a different question: the server knows what the box is for, the engine knows what its own cold start costs, and only the request knows whether there is more coming.
The last request to take a lease wins. Two requests speaking the same model cannot both be right
about how long it stays, and the newer one is the one whose caller is still waiting. A request that
says nothing puts the engine's own answer back rather than leaving the previous request's in place,
so a -1 from a batch that has finished does not outlive it.
A keep-alive is not a reservation. -1 means "do not expire this", not "do not evict this": a
/speak for another engine with the budget full still takes it by LRU (rule 1 above would otherwise
fail for everybody else the moment one caller pinned a card). That is also why the field is safe on
a public route: the worst a stranger can do with -1 is what this server did by default before
there was a deadline at all.
The worker is never told. keepAliveSeconds is on the public request shapes and not on the ones
the core forwards, because an adapter that can see a keep-alive is an adapter that will eventually
act on one, and then there are two idle timers disagreeing about the same card.
4. The capability document
This is the product. Everything else is plumbing that three other projects already have.
GET /capabilities
{
"contract": 1,
"engine": {
"id": "chatterbox",
"displayName": "Chatterbox",
"adapterVersion": "0.3.1",
"upstreamVersion": "0.1.4"
},
"license": {
"code": "MIT",
"weights": "MIT",
"weightsCommercialUse": true,
"notes": "https://github.com/resemble-ai/chatterbox"
},
"device": { "type": "cuda", "name": "NVIDIA GeForce RTX 4090", "vramBytes": 25757220864 },
"current": {
"variant": "turbo",
"cues": ["laugh", "chuckle", "sigh", "gasp", "cough", "clear throat", "sniff", "groan"],
"deliveries": [],
"dials": {},
"languages": ["en"],
"maxCharacters": 4096,
"segmentation": { "supported": true, "segmentCharacters": 300 },
"cloning": { "supported": true, "referenceSeconds": [5, 10], "formats": ["wav", "mp3", "flac", "ogg"] },
"blending": { "supported": false },
"streaming": { "supported": true, "granularity": "chunk" },
"nativeFormat": { "encoding": "pcm_s16le", "sampleRate": 24000, "channels": 1 }
},
"variants": {
"turbo": { "cues": ["laugh", "..."], "deliveries": [], "dials": {},
"cloning": { "supported": true, "referenceSeconds": [5, 10] } },
"original": { "cues": [], "deliveries": ["hushed", "frantic"],
"dials": { "exaggeration": {"min":0,"max":2,"default":0.5},
"cfgWeight": {"min":0,"max":1,"default":0.5} } },
"multilingual": { "cues": [], "deliveries": ["hushed", "frantic"], "dials": { "...": {} },
"languages": ["en","fr","de","es","..."] }
},
"formats": ["wav", "mp3", "opus", "flac", "pcm"]
}
Why current and variants are separate, and why this is the hardest part
An engine's capabilities can depend on which build is loaded. Chatterbox is the proof: the
turbo build performs the paralinguistic tags and silently discards the expressiveness dials
(upstream logs that it is ignoring them), while original and multilingual are the other way
round. So on that engine you get cues or dials and never both, and which one is a fact about the
weights that are resident right now.
A single flat capability list cannot express that, and a client that assumes one will send dials that vanish without a word. The two-level document is what lets a client ask "what can I do with what is loaded" and "what would I get if I asked for a different variant" as separate questions.
Engines with one build report a single variant and current mirrors it. The shape costs them
nothing.
variants lists what this worker can load, not everything the engine has. Orpheus's full
build runs on vLLM, which needs a CUDA card; on a Mac, or on a box without vLLM installed, the worker
does not list it. Listing it there would be a variant that every load fails, which a client cannot tell
from a variant that is only slow to load, and the first sign would be an error on a real request.
current is absent when no model is resident, because a worker in up(unloaded) has nothing to
describe and an invented answer is worse than no answer. variants is always present, so the
question "what could this engine do" is always answerable and only "what can it do right now" goes
away. A client that reads current unconditionally will find this on the first request after a
restart, which is the cheapest possible time to find it.
So nothing in the core may depend on current. It resolves the effective variant first, from
the request's variant if it named one, from the resident variant if one is loaded, and from the
engine's default otherwise, then reads variants[effective]. That rule is total, and it gives the
same answer as current in every case where current means anything. It also covers the case the
two-level document exists for: a request that names a variant which is not the one loaded, where
current describes weights that are about to be evicted.
Each variant says whether it clones, as cloning, the same shape current carries. Cloning
needs no model (§ 7), so whether it is offered cannot wait on one being resident: a client reading
only current could not tell an engine that cannot clone from one that is idle, and offering a clone
form that every submission fails is the first sign it would get. It is per variant rather than per
engine because nothing makes it engine-wide: a build that conditions on reference audio and a build
that only knows its trained speakers can be the same engine, as Orpheus's base model and its
finetune are. cloning is optional, since contract 1 shipped without it; a reader that finds it
absent does not know, and falls back to current, then to offering it and letting the worker refuse.
blending sits beside it on every variant and in current, for the same reason: a blend reads the
voices it mixes and never the model (§ 7). Absent means no, because an engine blends only if its
adapter says so, and a worker that predates the field cannot.
dialogue is declared per variant, and absent where it is not performed. Dia's 1.6b says
"dialogue": { "maxSpeakers": 2 }: the variant answers POST /dialogue (§ 6), and one request may
have up to two speakers. It is on the variant rather than the engine for the reason everything else
is: nothing in the core may depend on current, and a second build of the same engine can differ. A
client that does not know the field ignores it (§ 9), which is the whole of what an engine without
dialogue asks of anybody.
license carries code and weights separately
The weights licence is the one that package metadata never reveals, and it is the one that decides whether a commercial user may ship the thing. Apache-2.0 inference code over research-only weights is a real and common combination; Breeze TTS 2 is exactly that. A licence scanner reads the package and reports Apache-2.0, and it is wrong in the way that matters.
code is the licence of what the worker process runs, not of the adapter package. A scanner
makes the same mistake one layer down: kokoro-onnx is MIT, and turning text into the phonemes its
model takes goes through phonemizer and eSpeak NG, which are GPL-3.0-or-later. An adapter reports
the most restrictive licence among what it loads, and says in notes which dependency is
responsible, because that is the one a commercial user will ask about.
So the adapter declares both, by hand, and GET /engines surfaces them. Nobody else in this space
does this, and for anyone using the server commercially it is the most useful field in the document.
The catalog entry for an engine carries the same two fields, so the licence is visible before
install, not after, and an install of weights that may not be used commercially has to name the
licence it accepts (§ 10).
5. The standard vocabulary
The whole reason a client can stay engine-agnostic.
Cues
Things a speaker does that are not words. They ride inside the text, written [laugh], because
a laugh happens at a place in a sentence and a separate field would have to invent a way to say
where.
laugh chuckle sigh gasp cough clear throat sniff groan
A closed set. The engine translates: (laughs) for Dia, [laugh] for Chatterbox turbo, <laugh>
for Orpheus, nothing at all for Piper.
An engine claims the cues it performs and not the set: Orpheus has a tag for seven of these and none
for clear throat, so it claims seven, and a [clear throat] sent to it is stripped like any other
cue it does not claim.
The vocabulary is the only way in. An adapter removes its engine's own tag syntax from the text
before it translates, so <yawn> sent to Orpheus is dropped, not performed. The alternative is a
client that learns an engine's private tags and is then tied to that engine, which is the thing this
section exists to prevent. A tag worth having on two engines is a word for this list.
The core strips every cue the loaded variant does not claim, before dispatch. This rule is worth more than it looks: it means an engine that implements no cues never sees one, and the failure where an engine reads the word "laugh" out loud cannot happen. Claiming a cue you cannot perform is the only way to break it, which makes the honesty requirement on adapters exactly one line long.
A removal closes the space it leaves behind, so word [laugh] word does not render with a double
space that every consumer would have to know to tidy.
Deliveries
How a whole line is read. Two, and there is deliberately no word for "ordinary":
hushed frantic
A request with no delivery is the voice's own ordinary reading, which is what nearly every line should be. A third word meaning "ordinary" would be a second way to ask for nothing, and would key a second cache entry for identical audio.
A word and not a number. exaggeration: 0.9 means something to one family of models and nothing
to the next, so a client that sent it would be tied to the engine it was written against, which is
the thing this whole document exists to prevent. The numbers live in dials, per engine, and the
adapter is what turns a word into them.
Translate relative to the voice, not to a fixed point. A voice that is intense at rest should
still be more intense than its neighbours when hushed. An adapter that hard-codes exaggeration = 0.2 for hushed has thrown away the voice's own character.
Widen this list only when two engines can both perform the new word. One engine's feature is a
dial, not a delivery.
6. Speaking
POST /speak
Content-Type: application/json
{
"text": "Right, that was The Verve Pipe. [laugh] Nobody warned me about that intro.",
"voice": "narrator_02",
"variant": "turbo",
"format": "opus",
"delivery": "hushed",
"params": { "exaggeration": 0.7 },
"seed": 20260917,
"stream": true
}
| Field | Notes |
|---|---|
text | Required. Cues already stripped by the core to what this variant claims. |
voice | Engine-scoped id from GET /voices. Absent means the engine default. |
variant | Absent means whatever is loaded, or the engine's default if nothing is. |
format | From capabilities.formats. The response's Content-Type is authoritative. |
language | From the effective variant's languages. Absent means the first one it lists. |
delivery | Only ever one the effective variant claimed; the core drops the rest. |
params | Against the effective variant's dials (§4). Unknown keys are refused, not ignored. |
seed | Optional. Reproducibility for engines that can. |
keepAliveSeconds | How long to keep this model once the request is done. §3. Not sent to the worker. |
stream | true streams chunked; false buffers and sets Content-Length. |
Response: 200, Content-Type: audio/opus, chunked.
X-Rhapsode-Duration-Ms is an ordinary header when stream: false, because the duration is
known before the headers go out, and a trailer when stream: true, because it is not. Treat the
trailer as best effort and never require it: fetch exposes no trailer API in Node or in a browser,
so only a client built on a raw HTTP library can read one. A client that needs the duration on every
response should ask for stream: false.
One note on the opus label, because it will look like a bug to somebody: what goes on the wire is
Ogg-encapsulated Opus, for which audio/ogg; codecs=opus is the precise answer. audio/opus is
what this contract says, it is what every client in this space already sends and accepts, and the
response's own Content-Type is authoritative in any case.
language selects among what the variant declares
variants[...].languages has been in the capability document from the start and there was no way to
ask for one, which made a multilingual build indistinguishable from an English one. A variant that
lists a single language ignores this field; one that lists several reads it, and a language it does
not list is unsupported.
It is a separate field rather than something inferred from the text because detection is a guess, and a guess that silently picks the wrong language produces a whole take in the wrong accent with nothing in the response to say so.
Unknown keys in params are refused
The tempting alternative is to ignore them, and it is wrong for the same reason a silently discarded
dial is wrong: the client believes it asked for something. A 400 naming the key is a bug report
delivered to the right person in under a second.
Failing after the headers have gone
This is the one that bites, and it needs saying in the spec rather than being discovered per client.
Once a 200 and a Content-Type are on the wire, the status cannot be taken back. A worker that
fails mid-stream must abort the connection rather than close it cleanly, so the core sees a
truncated body rather than a short successful one.
The core additionally applies a floor: a body under 256 bytes is not audio, whatever the status
said. This exists because a server answering 200 with a JSON complaint about an unknown voice
produces a segment that airs as a click, and the only place to notice is at the end of the stream.
The check belongs at the end, not on the first chunk, because a short error body can arrive in
several pieces and "was any of this plausibly audio" is only answerable once it stops.
The floor behaves differently in the two modes, and that is deliberate. With stream: true the
headers are long gone by the time the count is known, so a body under the floor is an abort and the
client cannot be told why. With stream: false the core has the whole body before it writes
anything, so the same failure is an ordinary error envelope with a code and a retryable flag.
Which is the general rule and worth stating once: stream: false reports failures strictly better
than stream: true does, because every failure is still a pre-headers failure. Streaming buys
first-byte latency and pays for it in diagnosis. A caller rendering a file rather than feeding a
player should ask for stream: false and will get a better error the day something breaks.
Errors
{ "error": { "code": "unknown_voice", "message": "no voice \"narrator_99\"", "retryable": false } }
| Code | HTTP | Retryable | Meaning |
|---|---|---|---|
bad_request | 400 | no | Malformed, or a params key the variant does not have |
unknown_engine | 404 | no | Not in GET /engines. Core only; a worker is one engine |
unknown_voice | 404 | no | Not in GET /voices |
unsupported | 422 | no | A format, language or feature this variant does not do |
model_unavailable | 503 | yes | Loading, evicted, or a load that ran out of time |
oom | 503 | yes | Out of device memory |
overloaded | 429 | yes | Draining, or at the concurrency limit |
internal | 500 | no | The adapter threw |
forbidden | 403 | no | A management route (§ 10) called from somewhere it does not answer |
conflict | 409 | no | A management route (§ 10) asked for something the current state rules out |
retryable is a field and not something the client infers from the status. The distinction that
matters is between "this request was wrong" and "this request was fine and the server was not", and
a caller that conflates them either retries a permanent failure forever or discards work that would
have succeeded on the next pass. A cold start that ran out of its budget is model_unavailable, and
a client that understands that keeps the job rather than writing it off.
Dialogue
POST /engines/{engine}/dialogue (worker: POST /dialogue)
Content-Type: application/json
{
"turns": [
{ "speaker": "a", "text": "Did you hear that? [gasp]" },
{ "speaker": "b", "text": "[laugh] It's only the cat." },
{ "speaker": "a", "text": "It is never only the cat." }
],
"voices": { "a": "narrator_02" },
"variant": "1.6b",
"format": "wav",
"seed": 7,
"stream": false
}
Some engines make a conversation in one pass, with the timing, the overlaps and the reactions of two
people in one room, which stitching separate takes together cannot make. Dia is the first. That does
not fit /speak, whose one voice is one reader, and it is a second route rather than a second shape
of text so that no engine without it has a request shape it must refuse half of. A variant that
performs it says so in dialogue (§ 4); one that does not answers the route unsupported.
A turn names a speaker, not a voice. Speakers are labels the request makes up, and voices maps
the ones that should sound like a particular voice to its id. A speaker with no voice is read in one
the model picks, which a seed holds fixed, exactly as /speak with no voice is. A voice on each
turn could not say "the same person as turn one, whoever that is", which is the ordinary case for an
engine with no voices of its own. A voice the engine does not have is unknown_voice, as in § 7.
| Field | Notes |
|---|---|
turns | Required, at least one. Each is { speaker, text }, and text has cues stripped per turn to what the variant claims. |
voices | Optional. Speaker label to voice id. A label no turn uses is bad_request. |
variant, format, language, params, seed, stream | As for /speak. |
- More distinct speakers than
maxSpeakersisunsupported, since the variant said it cannot. - The ceiling is
maxCharactersover the sum of every turn's text. A dialogue is one take, so it is one budget. Splitting it into turns does not buy a longer one. - There is no
delivery. A delivery reads a whole line one way, and a dialogue has more than one reader. It is left out rather than half-defined; a delivery per turn waits for an engine that can perform one. - Everything about the response is
/speak's: the formats, the 256-byte floor, aborting rather than closing when a stream fails after its headers, the duration header or trailer, and the errors.
7. Voices
GET /voices
[
{
"id": "narrator_02",
"label": "Narrator 02",
"description": "Cloned from narrator_02.wav",
"spec": "narrator_02@turbo",
"tags": ["cloned", "en"],
"previewUrl": "/voices/narrator_02/preview"
}
]
spec is an opaque token that changes whenever the rendering would. Clients key cached previews
on it. The reason it exists rather than clients keying on id: the id is exactly the part that does
not change when somebody edits what is under it, so a remapped voice served its old preview
forever, and the only fix that works is a token the engine mints. Treat it as opaque; never parse it.
previewUrl is worker-scoped, and the core rewrites it. The worker knows nothing about engine
ids in paths, so it answers /voices/{id}/preview, which is correct on its own socket and a 404
to anybody who followed it from the public API. The core rewrites each one to
/engines/{engine}/voices/{id}/preview on the way out. This is the only field the core edits while
proxying, and the only reason it does is that the worker cannot know its own prefix. A client should
follow the URL it was given and never build one.
A voice id is a name: letters, digits, - and _, starting with a letter or digit, at most 64
characters. Anything else is bad_request, checked before an adapter sees it. An adapter makes a
file of an id, and before the rule an id of ../../x wrote a clone outside the voice directory and
an id of * matched whichever voice sorted first.
A voice the engine does not have is unknown_voice, never a substitute. An engine that falls
back to its default voice for an id it does not know hands the caller audio in a voice they did not
ask for, with a 200, which is the silent discard this document exists to prevent.
Creating a voice, where the engine supports it:
POST /engines/{engine}/voices (worker: POST /voices)
Content-Type: multipart/form-data
id=narrator_03 label="Narrator 03" reference=@clip.wav transcript="What the clip says."
id=host label="Host" blend="af_bella(2)+af_sky(1)"
DELETE /engines/{engine}/voices/{id} (worker: DELETE /voices/{id})
transcript is optional: the words spoken in the reference. An engine that clones by continuing
from the clip, as Dia does, has to be told what was said in it as well as how it sounded, and one
that is given the wrong words clones worse without any error, so there is nothing to infer it from
safely. Such an engine refuses a create without one as bad_request, naming the field; the capability
document has no way to say in advance that a transcript is needed, and a refusal naming the field is
the whole of the discovery. An engine that does not read it ignores it, which is the one exception to
unknown input being refused: every client can send it to every engine, so a client does not have to
know which engine reads it.
The answer to a create is the new voice, as GET /voices would list it. Creating an id that exists
replaces it, which is how a voice is re-recorded; its spec changes, so a cached preview does too.
A create carries exactly one of reference or blend, and both or neither is bad_request.
reference is whatever this engine makes a voice from, and current.cloning.formats says which
file types that is. For Chatterbox it is a clip of somebody speaking. For Kokoro it is a style
vector, the thing a Kokoro voice actually is: a .npy array, or a .pt voicepack as Kokoro-FastAPI
ships them, of shape 510 x 1 x 256. That is how a voicepack outside the engine's own set arrives,
Kokoro-FastAPI's v0 voices included. A reference whose filename names a type the engine does not
list is unsupported, as any format the engine does not do is (§ 6), and one of the right type and
the wrong contents is bad_request. A voicepack is read as data and
never unpickled: a .pt file is a pickle, and unpickling an upload runs whatever the uploader wrote.
blend is a mix of voices the engine already has, where current.blending.supported says so.
The recipe is name(weight)+name(weight), each weight optional and 1 when absent, positive, and
normalised so they sum to 1. The syntax is Kokoro-FastAPI's, because that is what an operator coming
from it already has typed into a config. Every name is a voice id (above) and must be one the
engine lists, built in or created, else unknown_voice.
A blend is resolved when it is created, into a stored voice like any other. So deleting or
re-recording a component later does not change a blend made from it, and its spec is minted from
the result rather than from the recipe. The alternative, a recipe resolved at every speak, is a
voice that changes under a caller who never touched it, which is the failure spec exists to catch
and would here be invisible to it.
Why a created voice and not a recipe written into voice at speak time: a recipe is not a name,
and the id rule above is what stops a voice id from being a path or a pattern. A named blend also
gets what every other voice has, a place in GET /voices, a spec, and a preview.
An engine that makes no voices answers a create with unsupported, and one that makes voices one
way and not the other answers the other with unsupported too.
The worker owns its voice store. The core keeps no shadow registry, because two registries is one
more than the number that can be right. It streams the upload to the worker as it arrives and keeps
no copy, so a clip costs the core no memory and no disk. It refuses a body over 25 MB as
bad_request: a usable reference is 5 to 20 seconds, which is under 1 MB of 24 kHz mono WAV and
under 6 MB of 48 kHz stereo at 24 bits, so the cap is generous for any clip and small enough that a
single request cannot fill a disk.
Creating and deleting a voice are management routes (§ 10): they write files on the box, so the same guard applies and a page from another machine is refused. Listing voices and hearing a preview stay open, like speaking.
Creating a voice loads no model and takes no residency slot. An adapter's create_voice stores
the reference and returns. Work that needs the model, such as computing a speaker embedding, happens
at the first speak in that voice, under the lease speak already holds. The alternative is a clone
that evicts whatever another caller is using, from a route nobody would expect to touch the GPU, and
Chatterbox, which clones from the reference on every call anyway, has nothing to do early. A Kokoro
blend holds to the same rule: it reads the voices file, 28 MB, and never the graph, which is up to
325 MB.
8. The Python worker SDK
An adapter author installs one package, writes one class, and never learns what the core is written in. If that is not true, the extension model is decoration and you will write every engine yourself.
from rhapsode_worker import Engine, Capabilities, Variant, Voice, SpeakRequest, NativeFormat, serve
class ChatterboxEngine(Engine):
id = "chatterbox"
display_name = "Chatterbox"
license = dict(code="MIT", weights="MIT", weights_commercial_use=True)
# The SDK encodes. Yield PCM and stop thinking about mp3.
native_format = NativeFormat(encoding="pcm_s16le", sample_rate=24_000, channels=1)
def variants(self) -> dict[str, Variant]:
return {
"turbo": Variant(cues=ALL_CUES, deliveries=[], dials={}),
"original": Variant(
cues=[],
deliveries=["hushed", "frantic"],
dials={"exaggeration": (0.0, 2.0, 0.5), "cfgWeight": (0.0, 1.0, 0.5)},
),
}
def load(self, variant: str) -> None:
# Each build is its own class upstream rather than an argument, which is a fact about that
# engine and not about this protocol. A variant name is whatever the adapter says it is.
self.model = BUILDS[variant].from_pretrained(device=self.device.torch())
def unload(self) -> None:
del self.model
def voices(self) -> list[Voice]:
return [Voice(id=p.stem, label=p.stem, spec=f"{p.stem}@{self.variant}")
for p in self.voice_dir.glob("*.wav")]
def speak(self, req: SpeakRequest) -> Iterator[bytes]:
# An engine that streams yields as it goes. One that does not, and chatterbox does not,
# synthesises whole and chunks the result: the SDK's contract is an iterator of PCM, not a
# promise that the model is incremental.
yield from self.pcm_of(self.model.generate(req.text,
audio_prompt_path=self.path_for(req.voice),
**self.dials_for(req)))
serve(ChatterboxEngine())
What the SDK does so no adapter has to
- Binds the socket, prints the handshake line, captures stray
stdout, frames logs as JSON on stderr. - Serves
/health,/capabilities,/voices,/load,/unload,/terminate,/fetch,/speakand/dialogue.capabilities()is assembled fromvariants(),native_format,licenseand the detected device, andcurrentis omitted entirely while nothing is loaded. - Encodes. The engine yields its native PCM; the SDK produces wav, mp3, opus, flac or raw through ffmpeg. Without this, every adapter reimplements format conversion and they all do it differently. This is the single largest reduction in adapter burden in the design.
- Applies the unload-then-load-once retry around
load(), so the OOM lesson is free. - Measures what a load cost, as a device-wide delta across
load(), and reports it asmodelBytes(§ 3). An adapter that knows better overridesmemory_bytes(); one that does not gets a figure for free, and one on a device nothing can measure reports nothing rather than zero. - Maps exceptions onto the error taxonomy, with
retryableset correctly, and aborts the connection rather than closing it cleanly whenspeak()raises mid-stream. - Enforces
maxCharactersand validatesparamsagainst the declared dials beforespeak()is called, so an adapter never receives a request it did not declare support for. - Splits long text where a reader would pause, so
maxCharactersstops being the client's problem. The subsection below is why this is the SDK's job and not each adapter's. - Serialises requests by default. One model, one utterance at a time is the correct default for a
GPU; an engine that can genuinely batch sets
concurrency > 1and takes responsibility. - Waits for an abandoned synthesis to end before it loads, unloads or starts another. A model's
generate()is one blocking call, so a client that hangs up stops the stream only when that call returns, and until then the model is still on the device. Measured with Dia on Metal: a load that started under an abandoned generation killed the process with "failed assertion _status < MTLCommandBufferStatusCommitted".
Long text, and who splits it
maxCharacters is the most text a request may carry. It is not the most text one generation
gets. Those were the same number until three adapters had each written the code that makes them
different, and the fourth had not.
- Kokoro splits on sentences and packs them back up. A 4096-character request is about four and a half minutes of audio, and at the 2.5x realtime measured on int8 that is 110 seconds before the first byte, against the core's 120-second wait for headers. Speaking a group at a time makes the wait the length of the first group instead.
- Dia and Orpheus split on sentences, then clauses, then words, and pack the pieces back greedily so that a run of short sentences is one generation rather than several. Dia also carries an audio prompt from one piece into the next, because its voice drifts across generations that have nothing in common.
- Chatterbox did none of this, and paid for it. Every build stops at 1000 speech tokens at 25 a
second, so one
generatecannot say more than 40 seconds whatever it is handed. A 1,060-character news bulletin sent to turbo whole came back as 26.7 seconds of skipped and crammed speech, where a 488-character one read cleanly in 30.8.
_fit and _pack in rhapsode_engine_dia.prompt and rhapsode_engine_orpheus.prompt are the same
twenty lines, character for character, and their sentence and clause patterns are the same two
regular expressions. Kokoro has a third copy, weaker in two ways nobody chose: no clause fallback,
so a sentence longer than the limit reaches the model whole, and a sentence pattern that misses the
ellipsis the other two match. Three copies of one idea, diverging quietly, is the shape of something
that belongs one level down.
So the SDK splits, and speak() receives one segment at a time. Breaking text where a reader
would pause is text handling, not engine knowledge: it is the same argument that put encoding in the
SDK, and it is the single largest thing left that every adapter still pays for itself.
- The algorithm is fixed and shared. Sentences, then clauses, then words, packed back greedily. A single word longer than the limit stays whole, because splitting it has the model read two halves of a word.
- The size is the adapter's. 180 characters for Orpheus, 250 for Dia, 200 for Kokoro, 300 for Chatterbox. The number is a fact about the model's attention and its generation budget; the splitting is not. An adapter that declares no size receives the whole text.
- The joint is the adapter's too. Kokoro puts 250 ms of silence between groups. Dia puts none, because its next piece continues from the audio of the last. An adapter that needs to carry state across a boundary keeps its own loop and calls the shared splitter for the text alone.
The capability document declares the split, because a client cannot see it and is affected by
it. segmentation.segmentCharacters is what one generation gets, beside maxCharacters for what
the request may carry. supported: false means text over maxCharacters is refused rather than
split: where every engine stood before this rule, and where an engine whose take is indivisible
stays. Each size was arrived at on the engine that declares it. Chatterbox's was the last, held back
until the 40-second ceiling above had cost a bulletin, because guessing it would have been the kind
of unpaid-for rule this document does not keep. 300 is about 19 seconds at the 16 characters a second
that bulletin's voice read at: under half the ceiling, so a slower voice still fits.
A client needs this for two reasons and § 4 is the place that owes it to them. A seed reproduces
a generation, so a request split four ways is four seeded generations rather than one. And prosody
does not carry across a joint unless the engine carries it, which is a thing engines differ on and
therefore a thing this document says out loud.
fetch, the one optional verb
fetch(variant) downloads a variant's weights to wherever the engine keeps them, without putting
them on the device. The SDK serves it as POST /fetch with { "variant": "turbo" }, and an adapter
that does not override it answers unsupported. It exists because the alternative is the first
/speak doing the download: Chatterbox's turbo is 3.8 GB and took about 75 seconds on a first
load, and a caller waiting on one utterance cannot tell that from a hang. An engine whose weights
ship inside its package, or that has none, leaves it alone.
dialogue, which is declared rather than discovered
dialogue(request) speaks a conversation (§ 6) and yields PCM as speak does. An adapter that
overrides it has every variant declare dialogue with its max_speakers, and one that does not
answers /dialogue with unsupported and declares nothing. Whether a variant has it is derived from
the override, as whether an engine clones is derived from create_voice, so an adapter cannot claim
dialogue it did not write. The SDK checks the speakers, the voices, the ceiling and everything
/speak checks before dialogue() is called.
Unlike fetch, a client needs to know before it asks, because the answer decides what it offers:
the web page shows a conversation editor only where a variant declares one.
An ONNX engine is an adapter like any other
Piper, Kokoro and Supertonic ship ONNX weights, and onnxruntime-node could run them inside the
core with no Python at all. They run as workers instead, through onnxruntime in their own
virtualenv, and the core has one kind of engine. This was open until v1 (§ 14) and was decided on
what running them in-process would actually have cost:
- The model is the easy half. These models take phonemes, not text, and every maintained route from one to the other goes through eSpeak NG, which is GPL-3.0-or-later, in Python and in Node alike. In a worker it runs in a process and a virtualenv of its own, behind a catalog entry that shows the licence before install (§ 4). In the core it would be loaded into an MIT server that holds no engine knowledge, which is the rule § 1 rests on.
onnxruntime-nodeis 301 MB unpacked, and would be a dependency of every core, including the ones that never run an ONNX engine.- The benefit was a first run with no Python, and the installer (§ 10) already builds each
engine its own virtualenv with
uv. The gain that remains is a first engine that needs no GPU and a download measured in megabytes, and a CPU worker delivers that.
What this asks of the SDK is only what it already promises: it must never depend on torch, or an
ONNX adapter could not install it. device is cpu for such an engine and means it; nothing in
this document treats CPU as a degraded case.
What an adapter must do honestly, and it is only one thing
Claim only what the loaded variant can actually perform. Every guarantee in this document rests on that and nothing else checks it.
9. Versioning
contract is an integer in the handshake, in /capabilities, and in the public API.
- Changes are additive only within a major. New optional fields, new enum members at the end.
- The core offers its own maximum in
RHAPSODE_WORKER_CONTRACTat spawn. - The worker answers at the highest version it supports that is not greater than the offer, and
reports that number as
contractin its handshake line and in/capabilities. - If the offer is below everything the worker supports, the worker exits non-zero before printing a handshake, naming both numbers on stderr.
- The core refuses a worker whose reported
contractexceeds its own, with a message naming both. - Clients ignore fields they do not know, and must not fail on an unrecognised cue or dial name.
The negotiation is one-sided on purpose: the core states a ceiling and the worker picks under it. The alternative, both sides declaring a range and meeting in the middle, needs a range on the wire, and there is nowhere to put one that an old core would understand. A single integer in each direction is enough because the only question that has ever needed answering is "can you speak the version I speak", and additive-only evolution means the answer is yes for every version at or below the offer.
The core and its workers ship separately, so they will disagree in the field. Designing for that on day one costs about forty lines. Retrofitting it costs a client that sniffs your OpenAPI document to work out what you accept, which is a real thing a real client had to do against a real server, and is the specific future this section exists to prevent.
Package versions are not the contract
Every released package carries one version, and they release together. The server ships as a
Docker image tagged with it, and @maroonedsoftware/rhapsode-sdk is published to npm at it. The rest carry it
without being published: the workspace packages (@rhapsode/contract, @rhapsode/core and
rhapsode), because the core reads its own to pin engines, below, and the Python packages
(rhapsode-worker, rhapsode-conform and every engine in the catalog), because every way to run the
core today installs engines from the sources it ships with, so nothing reads the package index yet.
Versioning them anyway means publishing one later is a release like any other, not a renumbering.
The first release is 0.1.0.
That version and contract answer different questions. contract says what a core and a worker
can say to each other, and moves only by the rules above. The package version says which code this
is. A release that changes no shape leaves contract where it was; a new contract major always
comes with a new package version, and the reverse is almost never true. A client reads contract
to decide what it may send and never parses a package version to find out.
The core reports its own package version as version on GET /health, beside contract, so that a
page can show a person which release this box runs without fetching and parsing /openapi.json. It
is for display. Every question that would need two versions compared is answered by the core:
whether an engine is behind it (outdated, below) and whether a newer release exists (GET /update,
below). A client that compared version strings itself would need its own ordering of them, and two
clients would disagree about a prerelease.
One version rather than one per package because § 14.5 keeps the protocol and both of its implementations in one repository so that they move in one commit, and that holds for a user only if they also move in one release. Separate versions would need a hand-kept table of which core goes with which engine, and the catalog would have to consult it on every install.
An engine installed from the package index is pinned to the core's own version. A core at
0.3.0 installs rhapsode-engine-kokoro==0.3.0, and every engine pins rhapsode-worker to its own
version exactly. Unpinned, pip resolves the newest engine on the index, which may have been built
for a core this box does not have yet. Negotiation would refuse it, but only at the first spawn,
after the virtualenv, the install and possibly gigabytes of weights were already paid for. The cost
of the pin is that an engine fix reaches a server only with a core upgrade, which under one version
is how every other fix reaches it too.
None of this narrows what the rest of this section allows. A remote worker, or an engine the operator configured by hand, is whatever version it is, and negotiation is still what decides whether the core will speak to it.
The pin is made at install and broken by an upgrade
An install pins, and nothing re-pins. The virtualenvs outlive the core that made them, deliberately: they are gigabytes, they sit on the volume an upgrade is careful to keep, and rebuilding them on every release would make upgrading cost what installing cost. So a core that has been upgraded is talking to workers pinned to the version it replaced, and that is the ordinary state of an upgraded box, not an error.
Negotiation does not catch it and is not supposed to. It refuses a worker whose contract is too
new, because that one cannot be spoken to at all; a worker that is too old speaks a contract
this core still supports, which is exactly what additive-only evolution promises. What the operator
loses is quieter: every field added inside the contract major since that worker was built is a field
it never sends, and an optional field nobody sends is indistinguishable from one nobody could answer.
A core two releases ahead of its worker reported no modelBytes (§ 3) and read as a card that could
not be measured.
So the core reports, per engine, the version of rhapsode-worker installed in that engine's
virtualenv, as workerVersion on the engine summary of GET /engines and GET /health.
- It is a diagnostic and never an input to negotiation. Nothing in the core or in a client may
branch on it, and the paragraph above stands unchanged: a client reads
contractto decide what it may send and still never parses a package version to find out. This field exists so that a human, or a doctor acting for one, can see a pin that an upgrade has broken. - It is read from the virtualenv, not asked of the worker. A worker would have to be running to
answer, and the normal state of an engine is
down— a model loads when something speaks and leaves when nothing does (§ 3). A version visible only while an engine happens to be loaded would be absent exactly when the question is asked. Reading the installed distribution's metadata needs no process, so it answers for adownengine, which is the case that matters. - It is absent rather than guessed. A remote engine has no virtualenv here, an operator-configured
commandmay point anywhere, and a virtualenv whose metadata cannot be read is one the core should not invent a number for. Absent means "not something this core can say", the same way a missingmodelBytesmeans "nothing measured it".
A workerVersion that differs from the core's own version is the signal, and the core says so
itself: the same summaries, and each entry of GET /catalog, carry outdated: true for an
engine whose workerVersion is not this core's version, false for one that matches, and nothing
where workerVersion is absent. It is "differs", not "is older": a core rolled back to an earlier
release breaks the pin the same way, and the remedy is the same. Like workerVersion, it is
computed when the engine is declared and not per request, so it adds nothing to the path a
healthcheck polls.
The remedy is to reinstall that engine (§ 10), which builds a new virtualenv beside the old one and does not take the engine away while it does. It is worth a warning and never a refusal, and it is never done automatically: the worker is contract-legal, it works, a reinstall costs a cold load and the network, and an operator who has reasons to run an engine at another version is inside what this section allows.
The core asks whether it is current
GET /update answers whether a newer release of rhapsode exists. It is open to every caller, like
/health and /catalog: the version it reports is already info.version on /openapi.json, the
latest release is public, and the only other thing it says is whether this box checks at all.
{
"version": "0.1.9",
"check": "ok",
"latest": "0.2.0",
"updateAvailable": true,
"releaseUrl": "https://github.com/maroonedsoftware/rhapsode/releases/tag/v0.2.0",
"checkedAt": "2026-09-21T09:14:02.000Z",
"distribution": "docker"
}
checkisoffwhen the operator turned it off,pendingbefore the first answer has come back,okonce one has, andfailedwhen the last attempt did not get one.latest,updateAvailable,releaseUrlandcheckedAtare present only withok.- The core asks GitHub's API for the repository's latest release, which by GitHub's own
definition is neither a draft nor a prerelease, so a prerelease is never offered. The request
carries a
User-Agentnaming rhapsode and its version, and nothing else about the box: no token, no engine list, no identifier. - It asks at most once a day, and only when asked. A request finding no answer, or one older than
a day, starts one check in the background and answers at once with what it has, so a first call
after boot reads
pending. A failed check is retried after six hours. There is no timer: a box nobody asks never phones out, and there is nothing to stop at shutdown. The route never waits on the network, and nothing about the check reaches/health, whose answer a container's healthcheck turns into a restart. updateAvailableis the core's comparison, ofx.y.zrelease versions. A core newer than the latest release (a checkout, or an image built locally) is not offered a downgrade, and alatestit cannot parse offers nothing.distributionisdockerin the published image andsourceeverywhere else. It says which instructions to show, because the core cannot follow them itself.
It is on by default, because the failure it exists to prevent, a box that quietly stays several
releases behind, is invisible from inside the box, and a check that has to be turned on is one only
the operators who already watch releases will turn on. update.check: false in the config or the
settings (§ 10), or RHAPSODE_UPDATE_CHECK=0 in the server's environment, turns it off, and the
route then answers check: off without ever having asked. The environment can only turn it off:
a setting of true does not override RHAPSODE_UPDATE_CHECK=0, because whoever set the
environment decided the box would not phone out, and a page is not the place to overrule them.
POST /update/check asks now. A day is the right wait for a box nobody is looking at and the
wrong one for a person who has just read that a release is out: on the box this was written for,
0.1.10 was published at 11:50 and the answer the core held from before it said 0.1.9 until the next
morning. So a caller can ask for a check, which answers with the same document as GET /update
once GitHub has, waiting at most the check's own ten-second timeout. It takes no body.
- It is open to every caller, like
GET /update, because the person asking is often at a page opened from another machine, and the answer is public. - It cannot make the box phone out more than once in five minutes. An answer less than five minutes old is returned as it is, and a request arriving while a check is in flight waits for that one. That is what lets an open route start outbound traffic: pressed as fast as anyone can, it asks GitHub twelve times an hour, a fifth of the sixty GitHub allows an address without a token, so a box behind a shared address cannot spend its neighbours' allowance.
- It does not override
off. With the check turned off it answerscheck: offwithout asking, for the reason the environment cannot turn it back on: somebody decided this box does not phone out, and a button is not the place to overrule them. - A failure is an answer, not an error. GitHub unreachable is
check: failedwith a200, exactly asGET /updatereports it, and the next background check follows the six-hour retry as before. An answer from here stands for a day like any other.
The core does not update itself. In the image it could only do so with the Docker socket, which
is control of the host, and the one mechanism every operator already trusts is docker compose pull.
So the core says that a release exists, and once the new image is running, outdated says which
engines to reinstall.
The core describes its own API
GET /openapi.json answers with the public API as an OpenAPI 3.1 document, generated from
contracts/ like every other shape. It is open to every caller, like /health and /catalog: it
says nothing a caller could not learn by trying, and withholding it only sends people to a copy on
the web that describes some other version.
- Public routes only: § 6 and § 7, the management routes of § 10, and the OpenAI shim of § 11.
Never a worker route. A client author should not learn that workers exist, and one document for
both cannot even be written, because
/healthand/speakare different operations on the same paths. The first combined document had the worker's in both places and described a/speakthat takes noengine. info.versionis the running core's package version, set when the document is served, so it describes this box rather than whichever build published the docs. It is still not the contract. A client readscontractfrom/healthto decide what it may send, and reads this document to learn the shapes, whether it is a person or a generator building a client. Nothing negotiates from it.- The worker protocol is described by
docs/openapi.worker.yaml, committed and served by nobody. An engine author reads it next torhapsode.worker.ck, and a worker is not required to describe itself:rhapsode-conformis what says whether it speaks the protocol.
10. Managing engines
Installing an engine is part of the API rather than a set of instructions, so that a terminal
client and a web page can both drive it and neither holds logic the other lacks. By hand it was five
steps: a virtualenv, a pip install, an edit to the config, a restart, and a first /speak that
silently downloaded 3.8 GB. The core still ships no interface of its own (§ 13). These routes are
what one is built on.
| Route | Does |
|---|---|
GET /catalog | Every engine that exists, installed or not, with both licences |
POST /engines/{id}/install | Starts an install job; 202 with the job. ?pull=turbo fetches that variant too, and ?accept= names the weights licence where it must be accepted |
POST /engines/{id}/reinstall | Starts a job that rebuilds an installed engine beside itself and swaps it in; 202 with the job. ?accept= as for install |
POST /installs/outdated | Starts a reinstall for every outdated engine this API installed; 202 with the jobs and what it skipped |
DELETE /engines/{id} | Stops and removes an engine this API installed |
POST /engines/{id}/pull | Starts a job that downloads a variant's weights; body { "variant": "turbo" } |
POST /engines/{id}/unload | Frees the model now (§ 3); ?mode=unload keeps the process |
GET /installs | Every job this process knows about, newest first |
GET /installs/{job} | One job |
GET /installs/{job}/events | The job's progress as server-sent events |
GET /settings | Every setting: the value in use, where it came from, and whether a change to it applies now |
PATCH /settings | Changes some of them; those that can apply now do, the rest wait for the next start |
Who may call them
Management routes answer loopback callers, and nobody else unless a token is configured. With
management.token set, a caller presenting Authorization: Bearer <token> is admitted from
anywhere. Everything else gets forbidden. GET /catalog is the exception and answers everybody,
because it only reads and the licences in it are the thing § 4 promises before install.
The reason is that the server has no other authentication and binds every interface by default, and an install runs pip. A management route open to the LAN is remote code execution for anyone on it. The speech routes stay open because the worst a stranger can do with them is make it talk.
A web page counts as where it came from, not where it is running. Any page the operator visits
can make their browser send a request to localhost, and the browser is a loopback caller: a
cross-site POST /engines/tone/install from Origin: https://evil.example was measured answering
202 and installing. So a request carrying an Origin is refused unless that origin is this
machine's own (localhost, 127.0.0.1, [::1], any port) or is listed in management.origins,
and that holds with a valid token too. A browser attaches Origin to every cross-site request and
every POST, and a page cannot forge it, which is also what defeats DNS rebinding: a rebound page's
origin is still the attacker's hostname. Clients that are not browsers send no Origin and are
judged on the rest.
A proxy on this machine does not make its clients local. The web app's dev server and nginx
both connect from loopback, and without this rule a web app served to the LAN would hand the LAN
the install routes. A request from a loopback peer carrying X-Forwarded-For is local only if
every address in that header is. The header is believed only from a loopback peer, and it can only
make a caller less trusted: leaving it out gains a local caller nothing it did not already have.
The guard runs before the request body is read and before any stream opens, so a refused caller is refused cheaply and cannot hold a connection.
Settings
Everything in the config file can also be changed through the API, so that a person can configure the box from a page or a terminal without editing a file and restarting. Configuring used to mean finding the file (inside a container, on a volume), editing JSON by hand with no check that a value was allowed until the next boot refused it, and a restart that dropped every model on the card whether or not the change needed one.
Where a value comes from. Three layers, each over the one before: the defaults, the operator's
config file, and the settings in the state database (below). The database wins. The file is what
a box starts with, the database is what it has been told since, and PATCH /settings writes only
the database. A setting cleared from the database falls back to the file's value, and then to the
default. The file is still never written by the core, for the reasons under "The state database",
and it still has to exist first: it is what says where the database is.
The reverse order, the file over the database, was the rule for engines and was considered here. It makes every value the file names read-only on the page, and the file is exactly where a first boot puts values (the Docker image seeds four), so the settings a person would most want to change are the ones a page could not.
GET /settings answers the whole document:
{
"values": {
"server": { "port": 8080, "host": "::", "shutdownGraceMs": 20000 },
"log": { "level": "info" },
"residency": { "maxResidentModels": 1, "evictionWaitSeconds": 30, "keepAliveSeconds": 300 },
"workers": { "socketDir": "/tmp/rhapsode", "voiceDir": "/config/voices", "startupTimeoutSeconds": 60, "drainGraceMs": 10000, "maxRestarts": 5, "restartDecaySeconds": 300 },
"install": { "venvDir": "/data/.rhapsode/venvs", "sourceDir": "/app/python", "python": "3.12" },
"management": { "tokenSet": true, "origins": [] },
"update": { "check": true },
"engines": { "kokoro": { "keepAliveSeconds": 900 } }
},
"fields": [
{ "key": "residency.keepAliveSeconds", "source": "default", "applies": "live" },
{ "key": "server.port", "source": "config", "applies": "restart", "saved": 9090 },
{ "key": "engines.kokoro.keepAliveSeconds", "source": "database", "applies": "live" }
]
}
valuesis what the running process is using, not what is saved. A setting that waits for a restart shows its old value here until the restart.fieldshas one entry per setting, by dotted key.sourceis the layer the value came from:default,configordatabase.appliesisliveorrestart.savedis present only when the database holds a value the process is not using yet, which is a change waiting for a restart, and a client says so.- The token is never in it.
management.tokenSetsays whether one is configured, and a new token waiting for a restart issaved: trueon its field rather than the token. The token is a root password for the box (above), and a document read to draw a form is not a place to carry one. engineshas an entry for each engine in the registry, holding the settings that are per engine. The engine's entry itself (its virtualenv, its module, itsenv) is not a setting: it is what the install recorded or the operator wrote, and it is changed by reinstalling or by editing the file.
PATCH /settings takes any part of values, and answers the whole document as it stands after
the write. null clears a setting from the database. management.token is written as a string,
management.tokenSet is never written, and a key the document does not have is bad_request, so
a misspelt setting is refused rather than saved and ignored. An array replaces the one below it
rather than adding to it: management.origins set in the database is the whole list. The write is
one transaction, so a patch that is refused changes nothing, even where some of its keys were
allowed.
What applies now, and what waits. Five settings apply as soon as they are written:
residency.maxResidentModels, residency.evictionWaitSeconds, residency.keepAliveSeconds, each
engine's keepAliveSeconds, and update.check. They are the ones the core reads when it uses them
rather than once at boot, and they are the ones a person tunes while watching a box run: a
keep-alive is set by seeing how long a model sits idle on the card, and a restart to try a value
would drop the model being measured. A change to either keep-alive moves the deadline of every model
idle now, rather than from its next request, because a deadline nobody sees move reads as a setting
that did nothing. A lower maxResidentModels evicts nothing on its own: the next load that needs the
room evicts, by § 3's rules, exactly as it would have.
Everything else waits for the next start, for a reason each:
server.portandserver.hostare the socket the request that changed them arrived on.workers.*andinstall.*are read by a worker or an install as it starts, and several are directories that engines and voices already live in. Changing one moves nothing: an engine installed under the oldinstall.venvDirruns from there until it is reinstalled, and voices under the oldworkers.voiceDirare not found under the new one. Move them first.management.tokenandmanagement.originsare what the Docker image's proxy is built from when the container starts (it presents the token for the page). Applied live, the core would refuse the page its own proxy serves until the next start, which is the same restart later with a broken page in between.log.levelandserver.shutdownGraceMsare read once, when the server is built.
Refusals. A value out of range is bad_request, naming the setting: a port outside 1 to 65535,
a keep-alive below -1, a maxResidentModels below 1, a negative wait or grace, a log.level
outside error, warn, info, debug, trace, an origin that is not an absolute http or https origin
with nothing after the host and port. A per-engine setting for an engine that is not in the registry
is unknown_engine. The contract's numbers are coerced, as its booleans are (§ 10, "Installing"),
so "8080" is read as 8080; null is not a number and always means "clear".
A server started without a state database (a core embedded as a library can be) answers
unsupported, because it has nowhere to keep a setting, as it has nowhere to record an install.
A change that would lock its caller out is conflict. Two, and both are checked before
anything is written:
- A caller that is not local may not clear the token or set it empty. It could only have been
admitted by the token, and without one the management routes answer loopback callers only, so
its next request, and every one after, would be
forbidden. - A caller whose
Originis not this machine's own may not leave that origin out ofmanagement.origins. Its page would be refused from the next start on. A page on this machine is admitted by its origin whatever the list says, so it is never refused this.
Neither rule stops an operator: at the machine, or from a page they have listed, both changes are allowed, and the file and the database are theirs to edit by hand.
Both routes are management routes, behind the guard above, reading included. GET /settings
names directories on the box, the origins it trusts and whether it has a token, which is a map for
somebody deciding where to push next, and a page that cannot change settings has no use for it
either. The web page and pnpm wizard settings are the two clients, and the page has nothing the
wizard does not (§ 13).
The catalog
[
{
"id": "chatterbox",
"displayName": "Chatterbox",
"license": { "code": "MIT", "weights": "MIT", "weightsCommercialUse": true },
"package": "rhapsode-engine-chatterbox",
"defaultVariant": "turbo",
"installed": "yes",
"managed": true
}
]
installed is no, installing or yes. managed says whether this API installed it and can
therefore remove it; an engine the operator configured by hand is installed: yes, managed: false.
GET /engines is unchanged and still lists only what is configured: the catalog is what exists,
and /engines is what this box has. An installed engine's entry also carries workerVersion and
outdated as its engine summary does (§ 9), so a page listing the catalog can say which engines an
upgrade left behind without a second request.
Installing
An install is four steps, and a job reports which one it is on:
venv: create<install.venvDir>/<id>, withuv venvwhen uv is on the path and the interpreter's ownvenvmodule otherwise. A directory already there that no registered engine points at is the remains of an install that did not finish, and is removed first, and so is<id>.alt, the second slot a reinstall uses (below). An engine whose dependencies install on a narrower range of Python than the SDK's says so in its catalog record. uv is handed the range and finds or fetches an interpreter inside it; without uv, the configured interpreter is asked first and the job fails naming both, because pip given an interpreter outside the range does not fail. It resolves the newest release that still claims to support it:kokoro-onnx0.6.1 declares<3.14, and on 3.14 pip quietly installed 0.4.7.packages: pip install the adapter. If<install.sourceDir>/<package>exists it is installed from there, whatever version the directory holds; otherwise it is installed by name from the package index, pinned to the core's own version (§ 9). The first is how a checkout and the server image work, and the second is how an installed core works once adapters are published.verify: import the engine's module with the new interpreter. That is the command the core will spawn, minus serving, and it is the check that a virtualenv pip abandoned halfway fails, although itsbin/pythonruns perfectly well.register: record the engine in the state database, with the weights licence the install accepted, and add it to the running registry. No restart: the next/speakfor it spawns a worker.weights, only when asked: fetch a variant, exactly as a pull does (below).
An install can fetch its weights. POST /engines/chatterbox/install?pull=turbo adds step 5 for
the variant named; a client that wants the default reads it from the catalog's defaultVariant.
Without it an install stops at register, as it always has. A pull that is empty or repeated is
bad_request. It is one job rather than a client queueing a pull behind its install, because a
client that goes away between the two (a closed browser tab, a terminal that lost its connection)
leaves an engine whose first /speak sits through the whole download: 3.8 GB for Chatterbox's
turbo.
It is a query parameter rather than a body so that a bare POST, which is every install so far,
stays one. ServerKit refuses a request without a body on a route that declares one, with a 411,
so a body here could not have been optional. And it names a variant rather than being a flag,
because the contract's booleans are coerced and ?pull=false would have read as true.
The engine is registered before step 5 starts, so a failed download leaves it installed: the
job fails at weights, and the way on is a pull, not another install. An engine whose worker does
not implement fetch has nothing to do in step 5; the job says so in its output and succeeds, and
the weights arrive on first load as they would have.
RHAPSODE_PIP_TRUSTED_HOSTS is honoured from the server's own environment and cannot be set
through the API. Weakening certificate verification stays a decision made on the box.
Installing an engine that is already installed or already installing is conflict, and so is
installing one the operator's config names, even disabled: that entry would win over whatever the
install recorded. An id that is not in the catalog is unknown_engine. A server started without a
state database (a core embedded as a library can be) answers unsupported, because it has nowhere to
record the result.
Accepting a weights licence
An install of weights that may not be used commercially names the licence it accepts. Where the
catalog says weightsCommercialUse: false, the install carries ?accept= with the catalog's
weights string exactly, ?accept=CC-BY-NC-4.0, and without it is refused as bad_request before
a job exists, in a message naming the licence and the query that accepts it. An accept that is
empty, repeated, or names anything else is refused the same way. An engine whose weights are
commercial needs none, and an accept sent to one must still name its licence, so a client can send
it on every install without first working out which kind of engine it is.
It is enforced here rather than left to the clients because § 4 put the licence in the catalog to be read before install, and a promise kept only by the clients that happen to show it is not kept for curl, the SDK, or the next client somebody writes. The web page and the wizard showed both licences and asked; a script calling this route installed whatever it named and never saw either.
It names the licence rather than being a flag, for the reason pull names a variant: the contract's
booleans are coerced, and ?accept=false would have read as true. Naming it also makes the
acceptance of the licence the client displayed. A catalog that changed between the read and the
install, because the server was upgraded and the weights relicensed, refuses the install rather
than accepting terms nobody was shown.
It is asked only where the weights are not for commercial use. A condition on every install is one
a client learns to satisfy without reading, and the refusal is worth something only while it is
rare. A pull asks nothing: the engine it fetches for was installed, and its licence accepted, or it
was configured by the operator, whose own file is their decision. A client still does not accept for
a person. The web page and the wizard send accept only once somebody has seen both licences and
said yes, and the wizard with nobody at the terminal accepts only when given --yes.
The state database
The core keeps what it has been told in rhapsode.db, a SQLite database beside the config file:
the engines it installed, and the settings written through PATCH /settings. The core never
writes the operator's file. That file may carry comments that a rewrite would lose, may sit on a
read-only path, and is somebody's hand-kept record of the box. One writer per file is the rule that
keeps both honest.
It is a database rather than the JSON file it replaces, rhapsode.engines.json, because there are
now two writers, installs and settings, and a file rewritten whole by each needs both to hold one
lock to avoid losing the other's write. SQLite gives each a transaction, and it ships with Node, so
it costs no dependency and no build step. It is not a database server: rhapsode is one process on
one box, and a second service to install, upgrade and back up would buy nothing that one file does
not.
- It keeps its journal only while writing. A backup of the config directory taken while the core runs is then the database as of its last write. Write-ahead logging would leave the latest writes in a second file beside it, which a copy of the one file misses.
- It is opened, and written to once, at boot, so a database the core cannot write to fails the start naming its path, rather than the first install hours later.
- A core that finds
rhapsode.engines.jsonbeside the config imports it into the database in one transaction and renames itrhapsode.engines.json.imported. It is left there rather than deleted so that a downgrade has something to go back to.
Engines keep the rule the file had: where the operator's config and the database name the same engine, the operator's config wins. It is the opposite of the rule for settings, on purpose. An engine entry is what makes an engine exist, and an operator who configured one by hand is the only party who can remove it (an uninstall refuses theirs, below). Were the database's entry to win, an install over an operator's engine would replace it until somebody opened the database, and removing the engine from the file would no longer remove it. A setting only tunes something that exists.
An engine's entry is an engine entry like one in the config, plus one field the core keeps for
itself: accepted, the weights licence the last install or reinstall accepted, as the accept
query named it, and absent when none was needed. Boot ignores it. A reinstall reads it (below).
Uninstalling
DELETE /engines/{id} terminates the worker, removes the engine from the registry and from the
state database, along with its per-engine settings, and deletes its virtualenv, and the other slot's
if one is there, but only a virtualenv inside install.venvDir. An engine
the operator configured is conflict: it is theirs to remove, by editing their file. So is an
engine that is speaking, because an uninstall that cut a stream off would hand that caller a
truncated file for a reason it could not have predicted; the removal happens under the same lock a
load takes, so nothing can start one in between. An engine that is not installed is
unknown_engine.
Weights are left alone. They live in the engine's own cache (for Chatterbox, the Hugging Face cache in the user's home), which other tools on the box share, and 9.7 GB is not something to delete as a side effect.
Reinstalling
POST /engines/{id}/reinstall rebuilds an engine this API installed, from what this core ships, and
swaps the new virtualenv in once it works. It is how an engine left behind by an upgrade (§ 9)
catches up, and how a virtualenv somebody broke is mended. It answers 202 with a job whose kind
is reinstall and whose steps are an install's first four; there is no weights step, because the
weights are in a cache the old virtualenv never owned and are still there.
It is its own route rather than an install over the top because the two refuse opposite things: an install refuses an engine that is installed, a reinstall one that is not. Before this route the remedy was an uninstall followed by an install, two calls with a stretch between them where the engine did not exist and a failed install left nothing at all.
The new virtualenv is built beside the old one, and the engine keeps working until it is ready.
An engine has two slots, <install.venvDir>/<id> and <install.venvDir>/<id>.alt, and the venv
in its state database entry says which one it runs from. A reinstall removes whatever is in the other slot (the
remains of one that did not finish), builds there, and verifies there, so the interpreter it imports
the engine with is the one the core will spawn. A virtualenv is never moved: it is not relocatable,
and every script in its bin names its own path in its first line, so a renamed one runs
bin/python and breaks bin/pip. Two fixed slots rather than a directory per version, because a
reinstall at the version already installed (every retry, and every checkout) would otherwise build
over the virtualenv the engine is running from.
A failure in any step before register fails the job and changes nothing: the engine, its
virtualenv and its workerVersion are as they were.
register waits for the engine to stop speaking, rather than refusing. It takes the lock a load
takes, as an uninstall does, and while the engine holds a lease it waits for the lease to end, for
up to ten minutes; past that the job fails with conflict and nothing has changed. An uninstall
refuses instead, because its caller is present to be told; a reinstall's caller has usually gone,
and on a busy box a reinstall that refused whenever the engine was speaking would never finish.
Under the lock it records the new slot in the state database, stops the old worker, and declares
the engine again, which reads the new workerVersion and outdated. The database is committed
first, so a core that dies at any point after it boots on the new virtualenv, and one that dies
before it boots on the old. The old slot is deleted last.
What it costs, and what a client should say before asking for it:
- A cold load. The old worker is stopped, so the model it held leaves the card, and the next
/speakfor the engine loads it again. - Anything installed into the virtualenv by hand. A reinstall builds from the catalog record,
so a package an operator added to the old virtualenv with its
pipis not in the new one.
The refusals are an install's, reversed, and all of them come before a job exists: an engine that is
not installed, or is not in the catalog, is unknown_engine; one the operator's config names is
conflict, as for uninstall; and one with a job queued or running is conflict. A server without a
state database has installed nothing, so every engine it has is the operator's.
A reinstall asks for the licence only when what was accepted no longer covers it. Where the
catalog says weightsCommercialUse: false, a reinstall needs no accept if the engine's recorded
accepted is the catalog's weights exactly, because somebody accepted those terms and nothing
since has changed them. It needs one, and is refused as an install is without it, when the entry has
no accepted (an engine installed by a core that did not record one) or names another licence (the
catalog relicensed the weights, which is the case the rule under "Accepting a weights licence"
exists for: an upgrade is how a catalog changes). An accept that is sent is checked as an
install's is, and recorded.
POST /installs/outdated reinstalls everything behind. It queues one reinstall for each engine
this API installed whose outdated is true, in id order, and answers 202 with them:
{
"jobs": [{ "id": "01J8Z6Q4B7", "engine": "kokoro", "kind": "reinstall", "state": "queued", "createdAt": "2026-09-21T09:20:00.000Z" }],
"skipped": [{ "engine": "research", "reason": "licence" }]
}
It never refuses as a whole. An engine it cannot reinstall without asking somebody is skipped
with a reason: licence where a single reinstall would need accept, busy where a job for it is
already queued or running, uncatalogued where the catalog no longer has it. Nothing behind is
jobs: [], which is a success, so the call can sit at the end of an unattended upgrade and be run
every night. It is a route rather than a loop in each client because the question of which engines
it covers is the core's to answer, and the one client that can always reach an upgraded container
is curl.
Pulling weights
POST /engines/{id}/pull asks the engine's worker to fetch a variant (§ 8): download the weights
without loading them. The worker process is started if it is not running, which costs tens of
megabytes, and no model is loaded, so it takes no residency slot and evicts nothing. An engine that
does not implement fetch answers unsupported, and its weights arrive on first load as before.
Jobs
{
"id": "01J8Z6Q4B7",
"engine": "chatterbox",
"kind": "install",
"state": "running",
"step": "packages",
"createdAt": "2026-09-18T14:02:11.000Z",
"startedAt": "2026-09-18T14:02:11.004Z"
}
kind is install, pull or reinstall. variant is the variant a pull fetches, or an install fetches in
step 5; an install without it stops at register. state is queued, running, succeeded or failed; a failed job
carries error, an ordinary error envelope body. Its message names the command and the line of
its output that says why it failed, not the line it printed last: pip ends a failed build with a
footer naming the package, and an Orpheus install whose error was ╰─> llama-cpp-python had its
reason, a missing C compiler, only in the event feed. One job runs at a time and the rest queue,
because two pip installs racing for one disk and one network connection finish later than the same
two in a line, and a failure in one is easier to read without the other interleaved.
An id GET /installs/{job} does not know is bad_request with a 404, as an unknown route is:
the request named something that is not there, and a finished job is only kept until fifty newer
ones have finished after it.
Jobs live in memory. A restart forgets them, and a job running at shutdown is stopped and marked
failed. That loses nothing that matters: a half-built virtualenv is caught by step 1 of the next
install.
GET /installs/{job}/events streams the job's events: step changes as progress, each line pip
and the interpreter print as log, and the outcome as status or error. A client that
reconnects with Last-Event-ID resumes where it left off, and one whose resume point has already
fallen out of the replay buffer is sent a resync event and should re-read the job. The stream does
not end when the job does: a job's last event is a progress whose status is done or
failed, and a client closes the stream when it sees one. Ending it from the server would make a
browser's EventSource reconnect to a finished job over and over. The filter is pinned to the job
in the path, so a query parameter cannot widen it to another job's output. The frame
format is ServerKit's server feed, so the same client code reads it wherever ServerKit is used.
11. The OpenAI shim
The native API is the contract, and adoption runs through other people's clients. Most of those
speak OpenAI's POST /v1/audio/speech, and pointing one at this server should need a base URL and
nothing else.
POST /v1/audio/speech
Content-Type: application/json
{ "model": "chatterbox:turbo", "input": "Right, that was The Verve Pipe. [laugh]", "voice": "narrator_02", "response_format": "opus" }
The shim is a translation into /speak, not a second implementation of it. It builds a native
request and hands it to the same code, so the ceiling, the cue stripping, the dial check, the
residency lease and the rules for failing after the headers (§ 6) are the ones /speak applies, and
cannot drift from them. The /v1 in the path is OpenAI's and says nothing about this contract's
version, which is § 9's business.
| Field | Becomes |
|---|---|
model | Required. An engine id, or engine:variant. An exact engine id is tried first, so an id with a colon in it is still reachable. |
input | text. Cues in it are handled as /speak handles them. |
voice | voice. Absent means the engine's default, although OpenAI's own API requires it. |
response_format | format. Absent means mp3, because that is OpenAI's default. aac is unsupported. |
speed | A dial named speed on the effective variant. Absent or 1 sends nothing. |
instructions | unsupported unless absent or empty. |
stream_format | audio or absent. sse is unsupported. |
The response always streams, as OpenAI's does. A caller that wants the duration header or the better
error that stream: false gives (§ 6) wants the native API.
model names an engine, and tts-1 is not one
A model that is not an installed engine is unknown_engine, and the message lists the ones that
are. There is no alias that maps tts-1 to a default engine, for the reason § 7 gives for voices: a
substitute is audio nobody asked for, delivered with a 200. A client hardcoded to tts-1 is
misconfigured, and the refusal is what tells its operator so.
alloy is not a voice either, and is unknown_voice like any other id the engine lacks. An operator
whose client cannot be told another name can clone a voice with the id alloy: voice ids are theirs
to choose.
What has no equivalent is refused, not dropped
The shim could drop instructions and a speed it cannot honour and answer 200. That is the
silent discard § 6 refuses for dials, and for the same reason: the client believes it asked for
something. So a non-empty instructions is unsupported, and so is a speed other than 1 on a
variant with no speed dial. A speed outside the dial's range is bad_request, as any dial is.
A field the shim does not know is bad_request naming it. OpenAI's own API refuses an unrecognised
field with a 400, so strictness holds a client to nothing it was not already held to. delivery,
params, seed, language and keepAliveSeconds are not accepted: the native API takes them, and
adding them here would make a second native API with a worse name. A shim request gets the engine's
keep-alive, or the server's, as anything that does not ask for one does. A multilingual variant speaks the first language
it lists, as /speak does when language is absent.
A cue in input is different. Stripping one the variant does not claim is § 5 making the request
performable, not the shim discarding a field, and it happens silently here exactly as it does in
/speak.
The shim has no dialogue. OpenAI's speech request has one voice, so it translates into /speak
and only ever reaches one reader, on an engine that performs dialogue as on any other.
mp3 by default needs ffmpeg
Because OpenAI's default is mp3, a client that says nothing gets mp3, which needs an ffmpeg with
libmp3lame on the worker's box. Without one the answer is unsupported saying so. The shim does
not fall back to wav: a client that saves the body as speech.mp3 never reads the Content-Type,
and a WAV with the wrong extension is a bug report that arrives weeks later from somebody else.
pcm is the engine's rate, not OpenAI's
OpenAI's pcm is 24 kHz, 16-bit, mono. Here it is the engine's native format, as it is in /speak,
and the Content-Type says which: audio/L16; rate=24000; channels=1. The core does not resample,
because it holds no audio knowledge. Chatterbox is 24 kHz, so it matches; an engine at 22.05 kHz
plays about 9% fast in a client that assumes. A client that cannot read a Content-Type should ask
for wav, whose header carries the rate.
Errors
The envelope is OpenAI's, because that is what the clients parse. The status is § 6's, and the
taxonomy survives in code:
{
"error": {
"message": "no voice \"alloy\"",
"type": "invalid_request_error",
"param": "voice",
"code": "unknown_voice",
"retryable": false
}
}
type is invalid_request_error when the request was wrong (not retryable, and under 500), and
server_error otherwise. param names the request field the failure is about, when there is one.
Every error carries x-should-retry, set from retryable. OpenAI's SDKs retry 408, 409,
429 and every 5xx twice by default, deciding by status, so without the header an internal 500,
which § 6 says is not retryable, is sent three times. Both official SDKs read x-should-retry
before the status, so with it they retry exactly what the taxonomy says to.
Who may call it
Anybody who may call /speak, which is anybody. OpenAI's clients will not start without an API key
and send it as Authorization: Bearer; the shim ignores it. The header is stripped before any
handler runs (§ 10), so a key typed into a client is never logged.
Only this route is served. GET /v1/models and the rest of OpenAI's API wait for a client that
needs them.
12. The MCP server
An agent that can call tools should be able to find an engine, pick a voice and speak a line without anybody writing glue for it. The Model Context Protocol is how those agents are told what a server can do, so the core serves it.
POST /mcp
Content-Type: application/json
Accept: application/json, text/event-stream
MCP is a translation into the public routes, as the OpenAI shim is (§ 11). Every tool builds
a request to a route of § 6 or § 7 and runs it through the core's own router, in process, and
reads the answer. The ceiling, the cue stripping, the dial check, the residency lease, the floor
and the error taxonomy are that route's, so an agent is held to exactly what curl is held to, and
a rule added to a route reaches its tool without anybody remembering that the tool exists.
| Tool | Runs | Takes |
|---|---|---|
list_engines | GET /engines | nothing |
engine_capabilities | GET /engines/{engine}/capabilities | engine |
list_voices | GET /engines/{engine}/voices | engine |
speak | POST /speak with stream: false | /speak's body without stream |
speak_dialogue | POST /engines/{engine}/dialogue with stream: false | the dialogue body with engine and without stream |
A tool's input schema is the request contract's, from contracts/, so the two cannot describe
different requests. The read-only tools say so in their annotations, and answer with structured
content whose outputSchema is the route's response contract, so a client checks a result against
what the route promises rather than against prose. MCP requires structured content to be an
object, so a list goes out as { "items": [...] }. The same JSON goes out as text too, for a client
that predates structured results.
The tools are the public API and nothing more. Nothing in § 10 is a tool. The management
routes run pip and change settings, and they answer loopback callers and a token (§ 10); an agent
that can reach /mcp is not thereby somebody who may install an engine. A client that wants
those routes calls them.
Stateless, over Streamable HTTP
Each POST carries one JSON-RPC message and is answered with one JSON response, or with 202 and
no body for a notification. The server keeps no session and sends no Mcp-Session-Id, so any
request may be the first one, a restart loses nothing, and a load balancer needs no affinity. Every
tool is a request and its answer; nothing here has progress to report or anything to push, so a
session would be state kept for no reader. GET /mcp and DELETE /mcp answer 405, which is
how the transport says a server offers no stream.
stdio is not served. A client that only speaks stdio runs a bridge to the URL; an agent on another machine, which is the case this was built for, cannot spawn the core as a child anyway.
Audio arrives as audio content
speak answers with two content items: the audio as MCP audio content, base64 with the
Content-Type /speak gave it as mimeType, and a line of text naming the engine and voice
asked for, the type, the size and the duration when the worker reported one. speak_dialogue
answers the same way. The route runs buffered because a tool result is one message, which is
the case § 14.2 already allows for: one response, in memory, bounded by the variant's
maxCharacters.
format defaults to wav, not to what /speak would choose. mp3 and opus need ffmpeg on
the worker's box and wav needs nothing, so a box without ffmpeg can still answer the call an
agent makes when it says nothing. pcm keeps its audio/L16; rate=…; channels=1 type verbatim,
for the reason § 11 gives: the rate is the engine's.
Errors are tool results
A route's refusal becomes a tool result with isError: true whose text carries the envelope's
code, message and retryable. That is how MCP reports a tool that ran and failed, as opposed
to a call that was malformed, and it is the form an agent reads: unknown_voice naming the voices
there are is something it can act on, where a JSON-RPC error is something its client reports and
stops at. A call to a tool that does not exist, or a body that is not JSON-RPC, is a protocol error
as the transport defines one.
Who may call it
Anybody who may call /speak, which is anybody, with one refusal. The transport requires a server
to check Origin, because a page the operator visits can make their browser POST to a
server on the LAN as easily as to localhost. /mcp applies the rule the management routes do
(§ 10): a request carrying an Origin is refused as forbidden unless the origin is this machine's
own or is listed in management.origins. An agent is not a browser and sends no Origin, and is
admitted. A bearer token is ignored and, as everywhere, stripped before any handler runs.
/mcp is not in GET /openapi.json (§ 9). It is one JSON-RPC endpoint whose operations are the
tools, and tools/list is where they are described, to the only readers that can use them.
13. Deliberately not in v1
Named so that nobody has to guess whether they were forgotten.
- STT. A different problem wearing a similar hat. Say no once, in the README.
- A delivery in a dialogue. § 6 says why it is left out, and it waits for an engine that can perform one per turn.
- Word timestamps. Wanted, cheap enough as an optional sidecar response, and not worth blocking
v1. Leave room: a
X-Rhapsode-Timings-Urlheader or atimingsfield in a multipart response. - Batching. Adapters declare
concurrencyand that is the whole of it for now. - Engines inside the core. Every engine is a worker, ONNX ones included. § 8 says why.
- A UI in the core. The gap this project fills is that everything else has one, and has put
its API behind it. A web page for installing engines is a client of § 10 like any other, and gets
no route the terminal client does not. Its API reference renders
GET /openapi.json(§ 9), the document every other client can read, rather than a copy of its own.
14. Open questions
- Dialogue. Decided: a second endpoint, declared per variant (§ 4, § 6). A structured
textwould have put a shape into every engine's request that most of them must refuse half of; a second route splits the API only for the engines that have something to put there. Turns name speakers rather than voices, because an unvoiced speaker is the ordinary case for Dia. - Does the core ever hold audio? Decided: only when
stream: falsewas asked for, and only the one response, in memory. § 6 already depends on it: the floor can only become an error envelope if the core has the whole body before it writes a status, andContent-Lengthand the duration header can only be headers if the length is known. Astream: trueresponse goes through at constant memory. The buffer is bounded by the variant'smaxCharacters, not by anything the client sends. Retry-on-truncation and content-addressed caching were the arguments for buffering everything. Neither is in v1, and whichever arrives first reopens this. - Voice namespacing across engines. Decided: not this server's job. A voice id is scoped to
its engine (§ 7) and names one engine's rendering of one reference. The same clip cloned on two
engines gives two voices that sound related, not one voice, and an alias spanning them would
promise a sameness the server cannot deliver and add a second id space for the § 7 rules to
police. A client that means "the same person" on three engines keeps its own table of
(engine, voice)pairs, which is also the only place that knows which of those it thinks are close enough. - In-process ONNX engines. Decided: no. ONNX engines are Python workers like every other engine, for the reasons in § 8, and "engine" in the registry keeps meaning one thing.
- Where the core lives. Decided:
MaroonedSoftware/rhapsode, one repository holding the protocol, the core and the Python worker SDK.MaroonedSoftwarealready publishes ServerKit and ContractKit, which the core builds on. A change to the protocol has to land in both implementations at once or one of them is wrong, and one repository with onepnpm testis what makes that a single commit rather than a coordinated pair of releases. The cost is that the SDK cannot version on its own cadence. Revisit when it has outside users who need it to.