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.
| Deployment | Where it runs | Endpoint your code talks to |
|---|---|---|
| Hosted | Our infrastructure | wss://<api-host>/v1/realtime |
| Self-hosted | Your server, your VPC, an air-gapped rack | ws://<your-host>:8791/v1/realtime |
| A developer laptop | The same container, one machine | ws://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:
| Job | Where it is used | If you skip it |
|---|---|---|
| Pull the image | docker login | The pull fails. Obvious, easy to fix. |
| Fetch model artifacts | Passed into the running container | The 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 entitlement | Checked once at container start | Nothing 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.
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>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.
docker logs voice-pipeline 2>&1 | grep -A2 "generated one"Then open a WebSocket, in whatever you are already writing:
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 panel | What it controls |
|---|---|
| Voice and language | Which synthesized voice speaks, and the language it listens in. |
| Personality and rules | Who 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 listens | How long a pause means "your turn", and whether a caller can interrupt mid-sentence. |
| Greeting | What it says first, before the caller has said anything. |
| Try it | A 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.
{
"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 see | What it means |
|---|---|
unauthorized on docker login | The key is wrong, revoked, or expired. Check it on the API keys page. |
| Container starts, sessions fail immediately | Usually still initializing: weights downloading or engines compiling. Check docker logs before assuming a fault. |
| Connects, then closes with 1008 | Wrong gateway token. It is in the container logs, or set it yourself with LOCAL_GATEWAY_TOKEN. |
| Connects, and nothing happens when you speak | Almost always the wrong sample rate. It must be 24 kHz mono PCM16. |
| Restart is slow every time | No cache volume mounted, so it recompiles engines on every start. Mount it. |
Where to go next
- Self-hosted in the console for the exact image and commands for this environment.
- WebSocket protocol for the full event catalogue.
- Production checklist before real users hit it.