Self-hosted

Run the pipeline on your own hardware

One container holds the whole voice pipeline: turn detection, barge-in, echo defence, streaming transcription, the language model and synthesis. Pull it with a key from the console and it serves the same realtime WebSocket the hosted API does, inside your network. Audio never leaves the machine you run it on.

What you get

The container is the product. There is nothing to install into your application, no library to keep in step with a server version, and no language you have to be writing in. Your code speaks a WebSocket, which every language already does.

DeploymentWhere it runsEndpoint your code talks to
HostedOur infrastructurewss://<api-host>/v1/realtime
Self-hostedYour server, your VPC, an air-gapped rackws://<your-host>:8791/v1/realtime
A developer laptopThe same container, one machinews://localhost:8791/v1/realtime

Same protocol in all three rows. Moving between them is a URL, which is the property the whole design exists to protect.

1. Create a key

Go to API keys, give the key a label, choose how long it should live, and tick the services it may reach. The key is shown once; only a hash is stored, so it cannot be recovered later.

Your key does three separate jobs, and it is worth knowing which is which:

JobWhere it is usedIf you skip it
Pull the imagedocker loginThe pull fails. Obvious, easy to fix.
Fetch model artifactsPassed into the running containerThe image pulls perfectly and the container never gets a model. Nothing in the failure mentions authentication. This is the one that costs an afternoon.
Identify your entitlementChecked once at container startNothing visibly. It is verified offline against a signed manifest, so an air-gapped install works and no call is ever made to us from the audio path.

2. Pull and run

The exact registry, image and tag for this environment are on the console's Self-hosted page, with copy buttons. They are not printed here on purpose: they differ between our production registry, a staging mirror, and a customer's own air-gapped one, so a literal in a doc is wrong for somebody.

shell
echo $VOICEORCH_API_KEY | docker login <registry> \
  --username '$token' --password-stdin

docker run -d --name voice-pipeline \
  --gpus all \
  -e VOICEORCH_API_KEY=$VOICEORCH_API_KEY \
  -p 8791:8791 \
  -v voiceorch-cache:/opt/voiceorch/.cache \
  -v voiceorch-state:/state \
  <registry>/<image>:<tag>
The two volumes are not optional. The cache holds downloaded weights and the inference engines compiled for your specific GPU; without it, every restart pays the first-run cost again. The state volume holds the generated gateway token, so a restart does not invalidate the credential your code is already using.

First start takes a while and prints little. It is downloading weights and compiling engines for your GPU, not hanging. Later starts reuse the cache and come up in seconds.

3. Connect

The container generates a gateway token on first start unless you set LOCAL_GATEWAY_TOKEN yourself, and logs it once. Localhost is not a trust boundary, so the token is mandatory: any web page a developer visits can open ws://127.0.0.1:8791 from their browser.

shell
docker logs voice-pipeline 2>&1 | grep -A2 "generated one"

Then open a WebSocket, in whatever you are already writing:

javascript
const ws = new WebSocket('ws://localhost:8791/v1/realtime?token=<gateway token>');

ws.onmessage = (message) => {
  const event = JSON.parse(message.data);
  if (event.type === 'response.output_audio.delta') {
    // base64 PCM16 at 24 kHz. Play it.
  }
};

Server-side clients should send Authorization: Bearer <token> instead. Browsers cannot set WebSocket headers, which is why the query parameter exists.

The full event catalogue is in the WebSocket protocol reference.

4. Set the voice up

Open http://localhost:8791/ in a browser. The container serves its own control panel: pick the voice, write what it should say and refuse to say, decide how patient it is before it replies, then press the microphone and talk to it. Adjust, talk again, save when it sounds right.

This is deliberately not a config file. The person who knows what the assistant should sound like is usually not the person who deployed the container, and asking them to restart a service to change a greeting is how a voice product ends up never being tuned.

In the panelWhat it controls
Voice and languageWhich synthesized voice speaks, and the language it listens in.
Personality and rulesWho it is, how long its answers run, and what it must not do. This is the system prompt, written as prose rather than presented as one.
How it listensHow long a pause means "your turn", and whether a caller can interrupt mid-sentence.
GreetingWhat it says first, before the caller has said anything.
Try itA microphone button and a live transcript, in the same page.

Saved settings live on the state volume, so they survive restarts and travel with the deployment.

The same settings, from code

Whatever is saved in the panel is the default for every session. An application can override any of it per session, exactly the way OpenAI Realtime and Gemini Live work, so one container serves an operator tuning a voice and a product using it at the same time.

json
{
  "type": "session.update",
  "session": {
    "instructions": "You are Priya from Acme Support. Keep answers short.",
    "voice": "<one of the voices in the panel>",
    "turn_detection": { "silence_duration_ms": 480 }
  }
}

Send it once, right after connecting and before the first turn. Anything you leave out falls back to the saved configuration.

Two rules for whatever you build against it

  • Send audio continuously, including while the assistant is speaking. Do not gate the microphone on playback. Echo is handled inside the pipeline.
  • Never send synthetic silence to keep the stream alive. Injected zeros corrupt the streaming recognizer's carried cache, and it surfaces several turns later as empty transcripts, which is a miserable thing to debug.

Audio is 24 kHz mono PCM16 in both directions, base64 inside the JSON events. Nothing in the protocol carries a sample rate, so sending 16 kHz produces a confident transcript of the wrong thing rather than an error.

When it does not work

What you seeWhat it means
unauthorized on docker loginThe key is wrong, revoked, or expired. Check it on the API keys page.
Container starts, sessions fail immediatelyUsually still initializing: weights downloading or engines compiling. Check docker logs before assuming a fault.
Connects, then closes with 1008Wrong gateway token. It is in the container logs, or set it yourself with LOCAL_GATEWAY_TOKEN.
Connects, and nothing happens when you speakAlmost always the wrong sample rate. It must be 24 kHz mono PCM16.
Restart is slow every timeNo cache volume mounted, so it recompiles engines on every start. Mount it.

Where to go next