How it works
You open a WebSocket towss://api.opper.ai/v3/realtime, send a session.start frame that picks the model and configures the agent, then stream microphone audio in and play assistant audio out as it arrives. Opper resolves the provider from the model id, handles the upstream handshake (including provider quirks like xAI’s client-secret exchange), meters usage as you go, and gives you one event vocabulary so your client code stays the same no matter which model you target.
Just trying it out?
The Realtime quickstart is the fastest way to a working session. It has one runnable code block per environment (server or browser), and you’ll be live in under five minutes. For a production-shaped example with browser UI, microphone capture, audio playback, tool calls, and provider switching, see the brainstorm-time cookbook.Authentication
There are two patterns, depending on whether your client runs server-side or in a browser.Server-side: bearer token
Server-to-server clients (Node, Python, Go, anything that runs in your backend) connect directly with a project-scoped runtime API key in theAuthorization header. The quickstart uses this pattern.
op-mak-…) are rejected. Only project-scoped runtime keys can open realtime sessions.
Browser: ephemeral tickets
Browsers can’t set anAuthorization header on the native WebSocket constructor. To open a realtime session directly from a browser, your backend mints a single-use ticket and the browser redeems it on the upgrade request.
Step 1: server-side mint. Your backend hits POST /v3/realtime-sessions with its normal API key. The response carries a short-lived client_secret and an expires_at timestamp.
Sec-WebSocket-Protocol subprotocol header. Prefer this form. Credentials in the URL query string end up in access logs, browser history, and Referer headers, while the subprotocol header is request-only and stays out of those places.
WebSocket constructor argument):
- Single-use. Replays return
401. - Default TTL 60 seconds, max 5 minutes.
Pre-binding for security
Any field you populate inconfig at mint time is locked for that ticket. The browser can’t override it on session.start, and it can’t change it later with session.update either. This is the main safety guarantee. A leaked ticket can only open the session your backend authorized. It can’t pivot to a more expensive model or a different system prompt, and it can’t unlock the field by updating it after start.
At minimum, bind model. Tighter setups also bind instructions, tools, and voice. Fields left zero stay open for the browser to set in session.start.
To force a boolean off (for example, to stop the browser from enabling output transcription), list the field in locked_fields. Without it, a zero value in config doesn’t lock anything, so the browser could still flip the bool on.
A ticket with locked_fields but no config is also valid: the listed fields are force-set to their zero value regardless of what the browser sends. Unknown field names are rejected at mint time with 400 Bad Request.
Preflight rejections
Before the WebSocket upgrade, Opper checks balance, spend caps, whether a card is on file, and concurrency caps. Failures come back as HTTP status codes rather than opaque WS closes, so you can react to them:Session lifecycle
A session goes through four phases. Most of the time you only care about the middle two. 1. Connect. WebSocket upgrade. A successful response is101 Switching Protocols. After this the connection is open, but the agent isn’t running yet.
2. Configure (session.start). Send this once as the first message after upgrade. The config selects the model, voice, system instructions, tool list, VAD parameters, and optional transcription toggles. Opper validates the config against the resolved model’s capabilities and rejects anything unsupported before dialing the upstream. The same validation runs on every later session.update, so unsupported modalities, voice, or reasoning_effort values return an error mid-stream.
3. Interact. Stream audio in (audio.append + audio.commit if not using server VAD), send text turns (text.input), receive audio.delta / text.delta frames, handle tool.call events, return tool.result.
4. Close. Either side can close the WebSocket. On a client-initiated close, Opper performs a final billing flush and tears down the upstream connection. On a server-initiated close (caps, idle timeout, balance exhaustion), you’ll see a closing event (session.terminating) with a structured reason before the connection ends.
Configuring the session
Everything goes inconfig on the first session.start frame:
Event vocabulary
All events are JSON over text WebSocket frames.Client → server
Server → client
Termination reasons on
session.terminating.error.code: session_timeout, idle_timeout, balance_exhausted, project_spend_cap_hit, org_spend_cap_hit, billing_not_supported.
Live transcription (speech-to-text)
The same WebSocket also powers transcription-only models: audio in, streaming text out, no audio response. Use it for live captions, dictation, call transcription, or as the ears of a voice agent that runs its own LLM and TTS.Pick a model
List them withGET /v3/models?type=realtime. Transcription models have output_modalities of text only. Each model’s params.realtime says what it takes, so read it from there instead of hard-coding it:
Start a session
Request text output and send the settings onsession.start. Most transcription models fix their settings when the session connects, so a session.update that changes them is refused:
audio.append exactly as you would for a voice session, and send audio.commit when your own VAD hears silence to get the final transcript without waiting.
Read the transcript
Depending on the model, the in-flight text arrives astranscript.partial (the whole hypothesis so far, which you replace) or as text.delta (fragments you append). Either way, transcript.committed finalizes each segment:
audio.delta (these models don’t speak), and voice, tools and the audio-output fields don’t apply. Requesting modalities: ["audio"] returns an error.
Turn detection for voice agents
On models withparams.realtime.semantic_vad (such as Voxtral 3), turn_detection: { "type": "semantic_vad" } judges the end of a turn from what was said as well as from pauses:
speech.started: the user started a turn.speech.eager_end: the user has probably finished. Start generating a reply, but hold it.speech.resumed: they carried on after all. Drop the held reply. Some models send nospeech.resumedand signal it with a newtranscript.partialinstead; treat either as “keep listening”.transcript.committedthenspeech.stopped: the turn is over. Play the reply.
eagerness tunes how soon a turn ends where the model supports it; on Voxtral 3, "high" ends turns sooner. Models with their own thresholds list them in params.realtime.parameters.
Speaker labels
diarize: true labels each final segment with its speaker, sent as a text.delta with speaker (speaker_0, speaker_1, …) next to the transcript.committed. It needs a model with params.realtime.diarize (such as Voxtral 3), and can’t be combined with turn detection. A model that can cap the number of speakers lists the setting in params.realtime.parameters.
Phone audio
For audio from a phone line (Twilio, most SIP trunks), setinput_audio_format to g711_ulaw or g711_alaw and send the raw 8 kHz mono bytes as they arrive, with no resampling. session.started confirms input_sample_rate: 8000, and billing counts the audio at that rate. Send G.711 only to a model whose params.realtime.input_audio_formats lists it; the others take 16 kHz PCM16.
Word timings
Models that can time each word expose it as aconfig.parameters setting (look for include_words and include_confidence in params.realtime.parameters). With it on, each transcript.committed carries words, in the same shape /v3/audio/transcriptions returns: start and end are seconds from the start of the session’s audio, and confidence (between 0 and 1) is present when asked for. Partials carry no words.
Billing
Live transcription bills per minute of input audio (audio_input_per_minute on GET /v3/models); there is no output charge. A session that fails to start costs nothing.
Tool calls
Tools follow the same round trip as gateway tool calling: declare them inconfig.tools with a JSON Schema for parameters. When the model decides to call one, you get a tool.call event. Reply with tool.result and the model continues the conversation. Opper handles the provider wire format internally, so your handler is the same across upstreams.
Per-provider notes
The protocol is the same across providers, but each upstream has quirks worth knowing.- OpenAI
- xAI
- Gemini
- Mistral (transcription)
Models:
openai/gpt-realtime-2, openai/gpt-realtime-1.5, openai/gpt-realtime, openai/gpt-realtime-mini.Voices: alloy, ash, ballad, coral, echo, sage, shimmer, verse, marin, cedar (latest models).Sample rate: 24 kHz symmetric (in/out).Reasoning effort: gpt-realtime-2 accepts reasoning_effort and bills reasoning tokens. Other models ignore it.Transcription: input transcription runs in a parallel pipeline upstream, so the user transcript can arrive after the assistant has started responding. Enabling input_transcription adds a separate per-minute bill line (input_transcription_ms) at the rate of the transcription model.Transcription is set-at-start. input_transcription and input_transcription_model cannot be toggled via session.update on OpenAI. Mid-session changes return an error. Request transcription at session.start or not at all. xAI and Gemini fold transcription into their per-minute audio rate and don’t have this restriction.Session limits
Billing
Realtime sessions bill incrementally as the conversation runs. Per-model rates are published onGET /v3/models. Look for audio_input, audio_output, audio_input_per_minute, audio_output_per_minute, and input_transcription_per_minute on the relevant model.
Tracing
Each session is listed in Logs with its usage and cost. With tracing on (set in Opper retention), its generation records also appear in the Trace explorer, filterable bymodel or session_id.
See also
- Realtime protocol reference: event vocabulary quick-ref for the API reference section.
- brainstorm-time cookbook: full working voice app with browser UI, tools, and provider switching.
- Models: the full list of supported realtime model ids.