Last updated 2026-05-17
OpenAI Realtime API
The openairealtime provider wraps OpenAI's
gpt-4o-realtime WebSocket API — a full-duplex,
session-based surface for low-latency voice agents. It is
deliberately separate from the
OpenAI provider
because the shape is fundamentally different: a long-lived
session with bidirectional audio and text events, not a
request-response exchange.
Read the
Realtime sessions concept page
first if you have not seen the three realtime patterns
llmrouter models (STT streaming, TTS realtime
context, full-duplex Realtime). This page documents the third.
Import path
import ( "github.com/elloloop/llmrouter" "github.com/elloloop/llmrouter/providers/openairealtime")Endpoint and authentication
The provider targets
wss://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview
by default. The model id is part of the URL, not the request body
— it is taken from SessionConfig.Model at
Connect time. Override the host with
llmrouter.WithBaseURL if you are proxying or
targeting an Azure deployment.
Authentication uses two headers on the WebSocket upgrade:
Authorization: Bearer {api_key}OpenAI-Beta: realtime=v1
Both are set automatically from the API key. There is no per-message auth — the bearer token authorises the entire session.
Construction
p, err := openairealtime.New( llmrouter.WithAPIKey(os.Getenv("OPENAI_API_KEY")),)if err != nil { log.Fatal(err)}
The same options that work on other providers work here:
WithAPIKey, WithBaseURL,
WithHTTPClient (used for the WebSocket upgrade
handshake), and WithTimeout (applied to the upgrade,
not the session lifetime — once the socket is open, the session
runs until Close or
ctx cancellation).
Connect: opening a session
Provider.Connect is the entry point. It performs the
WebSocket upgrade, sends an initial
session.update with the supplied
SessionConfig, and returns a live
*Session handle.
func (p *Provider) Connect(ctx context.Context, cfg SessionConfig) (*Session, error)SessionConfig
type SessionConfig struct { Model string // "gpt-4o-realtime-preview" Voice string // "alloy", "echo", "shimmer", ... Instructions string // system-style guidance for the session InputAudioFormat string // "pcm16" (default), "g711_ulaw", "g711_alaw" OutputAudioFormat string // "pcm16" (default), "g711_ulaw", "g711_alaw" Modalities []string // {"text", "audio"} by default Temperature *float64 // 0.6-1.2 typical Raw json.RawMessage // byte passthrough; typed fields overlay}Model- Realtime model id. Required. Goes into the WebSocket URL query, not the message body.
Voice-
One of OpenAI's realtime voices
(
alloy,echo,shimmer,ash,ballad,coral,sage,verse). Voice is a session property — changing it mid-session requiresUpdateSession. Instructions- Free-form system-style guidance. Plays the role of the system message in the chat API but lives on the session, not on each request.
InputAudioFormat/OutputAudioFormat-
Supported values:
pcm16(16-bit signed PCM at 24 kHz mono — the default and the highest quality),g711_ulawandg711_alaw(8 kHz telephony codecs). Mismatched formats are a server error, not a client-side validation failure. Modalities-
Subset of
{"text", "audio"}. Leave empty for both. Audio-only sessions still receiveresponse.text.deltaevents for transcribed audio. Temperature- Sampling temperature, in the same range as chat. The realtime model is noticeably more sensitive to temperature than the synchronous models.
Raw-
Byte passthrough for fields the typed surface does not model
(turn detection config, tool definitions in v0.5, etc.). The
typed fields overlay
Rawafter marshalling, so setting both is safe — the typed values win.
Session methods
type Session struct { // unexported: conn, events chan, errMu chan, err error, ...}
func (s *Session) SendText(text string) errorfunc (s *Session) SendAudio(pcm []byte) errorfunc (s *Session) Commit() errorfunc (s *Session) CreateResponse() errorfunc (s *Session) UpdateSession(cfg SessionConfig) errorfunc (s *Session) Close() errorfunc (s *Session) Events() <-chan SessionEventfunc (s *Session) Err() errorSendText-
Append a text item to the conversation buffer. Does not
trigger generation by itself — call
CreateResponseafter the lastSendText/SendAudio/Commit. SendAudio-
Append a PCM audio buffer to the input audio stream. The bytes
must match
SessionConfig.InputAudioFormat. Stream microphone capture by callingSendAudioon each frame as it arrives — typical frame sizes are 20-40 ms of 24 kHz PCM (960-1920 samples = 1920-3840 bytes). Commit-
Signal end-of-utterance on the input audio buffer. If server
VAD is enabled (the default), commits happen automatically and
this is a no-op; with VAD disabled you must commit explicitly
before
CreateResponse. CreateResponse-
Tell the server to generate a response from the current
conversation state. The response streams back as
SessionEvents onEvents(). UpdateSession-
Replace the session configuration mid-flight. Typically used
to change
VoiceorInstructions; changingModelis not supported. Close-
Close the WebSocket cleanly. Idempotent. Cancelling the
ctxpassed toConnectis equivalent. Events-
Receive channel of
SessionEventvalues. Single consumer. Closed when the session ends, after whichErr()returns the terminal error (nilon clean close). Err-
Terminal error. Blocks until the session ends. Returns
nilon a clean close,*llmrouter.ErrUpstreamfor a server-reported error event, or a wrappednet/contexterror for transport failures.
SessionEvent
type SessionEvent struct { Type string // OpenAI event type, e.g. "response.text.delta" Text string // populated for text deltas AudioDelta []byte // populated for audio deltas (decoded PCM bytes) Error *llmrouter.ErrUpstream // populated for "error" events Raw json.RawMessage // original wire JSON}
The Type field is the OpenAI event name verbatim.
The library populates the typed convenience fields for the
handful of events most callers actually consume; everything else
is available on Raw.
Event Type | Typed field populated | Meaning |
|---|---|---|
response.text.delta | Text | Incremental text from the assistant. |
response.audio.delta | AudioDelta | Incremental audio bytes (PCM in OutputAudioFormat). |
response.audio_transcript.delta | Text | Server-side transcript of the assistant's audio output. |
response.done | — | End of one response; the session continues. |
error | Error | Server-side error; session usually ends after this. |
session.created, session.updated, etc. | — | Lifecycle / acknowledgement; inspect via Raw. |
Example: text in, audio out
package main
import ( "context" "log" "os"
"github.com/elloloop/llmrouter" "github.com/elloloop/llmrouter/providers/openairealtime")
func main() { p, err := openairealtime.New(llmrouter.WithAPIKey(os.Getenv("OPENAI_API_KEY"))) if err != nil { log.Fatal(err) }
ctx, cancel := context.WithCancel(context.Background()) defer cancel()
sess, err := p.Connect(ctx, openairealtime.SessionConfig{ Model: "gpt-4o-realtime-preview", Voice: "alloy", Instructions: "You are a concise assistant.", OutputAudioFormat: "pcm16", Modalities: []string{"text", "audio"}, }) if err != nil { log.Fatal(err) } defer sess.Close()
if err := sess.SendText("Say hello in five words and play it back."); err != nil { log.Fatal(err) } if err := sess.CreateResponse(); err != nil { log.Fatal(err) }
out, _ := os.Create("hello.pcm") defer out.Close()
for ev := range sess.Events() { switch ev.Type { case "response.text.delta": log.Printf("text: %s", ev.Text) case "response.audio.delta": out.Write(ev.AudioDelta) case "response.done": return case "error": log.Fatalf("server error: %v", ev.Error) } } if err := sess.Err(); err != nil { log.Fatal(err) }}
The resulting hello.pcm file is 24 kHz signed
16-bit mono PCM — play it with
ffplay -f s16le -ar 24000 -ac 1 hello.pcm or wrap
it in a WAV header.
Example: audio in, audio out
For voice-agent use cases you want mic-in / speaker-out. The
llmrouter side is identical to the text example
above plus a producer goroutine that calls
SendAudio for each microphone frame. The mic
capture is platform-specific — the sketch below shows the
library calls; substitute your audio driver of choice
(malgo, portaudio, etc.) for the
pseudocode parts.
package main
import ( "context" "io" "log" "os"
"github.com/elloloop/llmrouter" "github.com/elloloop/llmrouter/providers/openairealtime")
// Pseudocode: replace with your audio capture / playback of choice.type mic interface { Frames() <-chan []byte // 20ms of 24kHz s16le PCM per frame io.Closer}type speaker interface { Play(pcm []byte) error io.Closer}
func openMic() (mic, error) { /* malgo / portaudio / ... */ return nil, nil }func openSpeaker() (speaker, error) { /* malgo / portaudio / ... */ return nil, nil }
func main() { p, err := openairealtime.New(llmrouter.WithAPIKey(os.Getenv("OPENAI_API_KEY"))) if err != nil { log.Fatal(err) }
ctx, cancel := context.WithCancel(context.Background()) defer cancel()
sess, err := p.Connect(ctx, openairealtime.SessionConfig{ Model: "gpt-4o-realtime-preview", Voice: "alloy", Instructions: "You are a friendly voice assistant. Reply in short sentences.", InputAudioFormat: "pcm16", OutputAudioFormat: "pcm16", Modalities: []string{"audio", "text"}, }) if err != nil { log.Fatal(err) } defer sess.Close()
m, err := openMic() if err != nil { log.Fatal(err) } defer m.Close()
sp, err := openSpeaker() if err != nil { log.Fatal(err) } defer sp.Close()
// Pump microphone frames into the session. go func() { for frame := range m.Frames() { if err := sess.SendAudio(frame); err != nil { log.Printf("send audio: %v", err) return } } }()
// Consume server events and play audio deltas as they arrive. for ev := range sess.Events() { switch ev.Type { case "response.audio.delta": if err := sp.Play(ev.AudioDelta); err != nil { log.Printf("play: %v", err) } case "response.audio_transcript.delta": log.Printf("assistant: %s", ev.Text) case "error": log.Fatalf("server error: %v", ev.Error) } } if err := sess.Err(); err != nil { log.Fatal(err) }}
With server-side voice activity detection enabled (the
default), the model picks up end-of-utterance automatically —
you do not need to call Commit or
CreateResponse after each user turn. To disable
server VAD and drive turn-taking yourself, set
{"turn_detection": null} on
SessionConfig.Raw.
Error handling
Three distinct error paths:
- Connect-time errors — HTTP-upgrade failures
(bad API key, network) return from
Connectas*llmrouter.ErrUpstream(for non-2xx upgrade responses) or a wrappedneterror. - In-session server errors — surface as a
SessionEventwithType == "error"and a populatedErrorfield. The session typically ends after an error event; checkErr()for the terminal status. - Transport errors — abrupt socket close,
context cancellation, deadline exceeded. The events channel
closes and
Err()returns the underlying error.
Caveats and current limits
- No tool use in v0.4. Function-calling events
flow through
Rawonly. Typed tool surfaces on Realtime are on the v0.5 roadmap. - One session per connection. Each
Connectopens a new socket. There is no multiplexing — for parallel sessions, callConnectmultiple times. - Instructions live on the session, not on
each turn. To change the system prompt mid-conversation, call
UpdateSessionwith newInstructions. - Audio formats are limited to
pcm16,g711_ulaw, andg711_alawby OpenAI. Opus and MP3 are not supported on the input or output side. - No reconnect. If the socket dies, the session is dead — your application is responsible for reconnecting and replaying conversation state.
Roadmap
- v0.5 — typed tool use on
SessionEvent, function-calling end-to-end with the sameToolDef/ToolCallshapes used by chat. - v0.5 — streaming
UpdateSessiondeltas so config changes don't require a full re-marshal. - v0.5 — Gemini Live wrapped through the same
openairealtime-shaped surface for cross-vendor parity.
See also
- Realtime sessions concept — the three patterns and when to use which.
- OpenAI provider — synchronous chat / TTS / Whisper STT.
- Deepgram provider — WebSocket live transcription (STT-only).
- Cartesia provider —
SpeakRealtimefor low-latency TTS. - ElevenLabs provider —
SpeakRealtimefor high-quality TTS.