Run the complete example. Scaffold this guide’s full, CI-tested code in one
command, no clone:Or browse it:
omni-browser-widget.How it fits together
One binary envelope shares the socket: byte0x01 carries PCM16 audio, 0x02
carries UTF-8 JSON transcript payloads, and 0x03 carries UTF-8 JSON control
payloads. Caller audio must also include the leading 0x01; outbound control
frames use 0x03 plus JSON keyed by type. See the wire protocol before
implementing a transport.
This guide is correct on transport, codecs, resampling, and the event behaviors
you build against. The exact JSON payloads for each event are defined by the
Omni wire protocol, which is the source of truth, we
isolate event handling behind a single function so you can fill in field names
once you’ve checked the reference.
Prerequisites
1
A key, that's it
Omni is zero-state: there’s nothing to create first. The session is
authorized by your key’s org, and the agent’s behavior travels in a
configure frame after connect. Grab a pyai_test_ sandbox key to build
against, it works instantly with hard daily caps and no billing.2
A local static server
getUserMedia requires a secure context, which includes http://localhost.
Any static server works: npx serve, python3 -m http.server, etc.Pick one sample rate and stick to it
The single biggest source of “it sounds like chipmunks/robots” bugs is a sample rate mismatch. Omni speaks PCM16 little-endian; for browser/WebRTC use 24 kHz, declared on the URL as?format=pcm16&rate=24000.
The clean trick: ask the browser for a 24 kHz AudioContext so the mic capture,
the worklets, and the wire all agree, no resampling needed.
if away and called out in the code.
Build it
1
Capture worklet: mic Float32 → PCM16
An
AudioWorkletProcessor runs on the audio thread and hands you 128-sample
blocks. Buffer them into ~20 ms frames (480 samples @ 24 kHz), convert
Float32 [-1, 1] to little-endian Int16, and post the bytes to the main
thread.capture-processor.js
2
Playback worklet: a ring buffer of agent audio
Agent audio arrives in bursts; speakers consume it at a steady 24 kHz. Bridge
the two with a ring buffer. Crucially, expose a
clear message, that’s how
barge-in stays snappy (next step).playback-processor.js
3
Connect to Omni and wire the audio loop
Open the WebSocket with the key as a subprotocol (browser-safe, browsers
can’t set headers on the upgrade), forward capture frames as binary, and feed
binary replies into the playback ring. The official
@pyai/sdk ships
realtimeURL / realtimeSubprotocol helpers so you don’t hand-build the URL.4
Handle events: transcript + barge-in
Text frames are JSON session events. You build against the known event
names below; render whatever transcript fields your agent emits. Keep all of
this in one function so the payload details live in exactly one place.
The exact fields on each event (and the transcript payload shape) are defined
in the Omni wire protocol. Map them inside
renderTranscript / the cases above once you’ve confirmed names there, the
transport and event names used here are stable.Run it
Dropcapture-processor.js, playback-processor.js, and an index.html (a
Start button calling start()) in one folder and serve it:
flush →
clear path).
Barge-in, latency & quality
- Barge-in is the difference between a demo and a product. The server detects
your speech and sends
flush; your only job is to stop playing queued agent audio right then, that’s the singleplayback.port.postMessage("clear")call. Don’t wait for the socket to drain. - Keep frames small (~20 ms). Smaller frames lower latency; much smaller and you pay per-message overhead. 480 samples @ 24 kHz is a good default.
- Let the browser do AEC.
echoCancellation: truestops the agent’s own voice from being captured and looping back as user speech. - Don’t add your own jitter buffer on top of the worklet ring, the ring is already the buffer. Extra queuing only adds latency.
- Resume the AudioContext from a user gesture. Browsers start it suspended;
call
ctx.resume()inside the click handler if playback is silent.
Troubleshooting
Make the agent speak first (greeting)
By default the caller speaks first, Omni listens, then replies. To open the call with a greeting (“Hi, thanks for calling, how can I help?”), set the nativegreeting field in your configure frame, it’s live, and the engine speaks
it as turn 0. The pre-roll pattern below is an alternative for when you’d
rather render the opening audio client-side:
1
Synthesize the greeting once with Speak
Call
POST /v1/audio/speech with response_format: "pcm" at your session
rate and cache the bytes (it’s the same line every call).2
Play it the moment the session is live
On
session_started, push the cached PCM into your playback ring before
you start streaming mic audio. The agent appears to speak first; normal
turn-taking + barge-in take over from there.The native
greeting field in the configure frame is live: set it and the
engine speaks your opening line as turn 0 (one billing line, no extra hop), so the
pre-roll above is optional. For agent profiles, set Greeting message in the
console Agents builder instead of sending greeting every
connect. The connect URL’s session_label is still just an opaque correlation tag
when you are not using an agent profile.Next steps
Omni wire protocol
Exact event payloads, close codes, and golden frames.
Phone agent with Twilio
Bridge the same Omni session to a real phone number.
FreeSWITCH integration
Fork SIP/PSTN audio into Omni.
Errors & limits
Close codes, rate limits, and concurrency.