Skip to main content
By the end of this guide you’ll have a single HTML page that opens your mic, streams audio to an Omni agent in real time, plays the agent’s reply through your speakers, and renders the live transcript as you both talk. No framework, no build step, just the Web Audio API and a WebSocket.
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: byte 0x01 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.
A pyai_test_ key is fine for a local experiment. Never ship any long-lived key or publishable token in a production voice agent. Use the hosted website widget from Agent → Connect, or have your backend call POST /v1/omni/sessions and hand the browser only the returned short-lived, origin-locked session token.

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.
Most browsers honor this. If yours pins the context to its hardware rate (commonly 48 kHz), you have two options: keep the 24 kHz context and let the worklet see 24 kHz directly, or run at 48 kHz and decimate 2:1 on capture (take every other sample) / upsample 2:1 on playback (duplicate each sample). The math is exact because 48000 / 24000 = 2. We use the 24 kHz context path; the fallback is one 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.
Frame-key asymmetry, the #1 Omni bug. Your outbound control frames (configure, dtmf) are keyed on type; the inbound server frames (hello, session_started, configured, flush, …) are keyed on event. The edge is transparent, so a mis-keyed {"event":"configure"} is acked but silently dropped, you get a connected session that never speaks, with no error. Send {"type":"configure",…}; parse inbound on event. The @pyai/sdk omni.connect() helper does both for you.
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

Drop capture-processor.js, playback-processor.js, and an index.html (a Start button calling start()) in one folder and serve it:
Click Start, allow the mic, and say hello. You should hear the agent reply within a few hundred milliseconds and see the transcript fill in. Talk over it, the agent’s audio should cut out as soon as you speak (that’s the flushclear 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 single playback.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: true stops 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 native greeting 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.