Authentication
Browser audio
Let an agent join a browser-based call and talk in it, with raw PCM in both directions.
Let an agent join a browser-based call (Zoom web client, Slack huddle, Google Meet, anything that runs in a tab) and talk in it. Workbench is the pipe: raw PCM out of the call, raw PCM into the call's microphone. Speech recognition, turn-taking and voice come from your agent (a full-duplex realtime voice model works best).
Enable it#
BROWSER_AUDIO_ENABLED=true
BROWSER_AUDIO_MAX_MINUTES=180 # hard cap per call
BROWSER_AUDIO_FIRST_ATTACH_SECONDS=600 # wait for the first reader/uplinkThe Docker image ships PulseAudio; nothing runs until the flag is on. With the
flag off the tools answer AUDIO_DISABLED. A browser that was already open
before you enabled it must restart: browser_audio_start returns
BROWSER_RESTART_REQUIRED, and you call it again with restart: true. Open tabs
close, logins survive (same profile), and the page reopens in a new tab whose
session_id is returned.
Flow#
browser_startgives asession_id. Do not open the meeting yet.browser_audio_start { session_id, sample_rate: 24000 }on that blank tab returns{ stream_url, clear_url, sample_rate, format, channels, session_id, restarted, headers }. The URLs are absolute, on the server's own origin, and are capability URLs: the secret in the path is the only credential they take (see Capability URLs). Repeatingbrowser_audio_startwith the same tab and rate is idempotent and returns the same URLs; a different rate returnsAUDIO_BUSY, so stop first, then start again.- Open the downlink:
GET stream_urlwithheaders(SSE). - Open the uplink:
POST stream_urlwithheaders,Content-Type: audio/pcm, chunked, and keep it open. browser_navigateto the meeting link. Order matters: meeting pages ask for the microphone as they load, and a request that arrives before the grant is denied and stays denied. If the page was already open, reload it afterbrowser_audio_start. The grant follows the tab across origins.- Click through the meeting's join flow (
browser_click,browser_screenshot); choose "computer audio" when asked. - Pipe: every SSE
audioframe goes to your model's input; every model audio delta is written into the uplink body. - When your model is interrupted:
POST clear_url, which returns{ played_ms, cleared_ms }.audio_end_ms = played_ms - <ms of audio you had sent before this response started>is what a realtime API's truncate call needs. - Leave the meeting, then
browser_audio_stop { session_id }, which returns{ played_ms, duration_ms }for the call.
Wire format#
PCM signed 16-bit little-endian, mono, at sample_rate (16000, 24000 or
48000; default 24000) in both directions.
SSE events:
| event | data |
|---|---|
audio | { "seq": 412, "pcm": "<base64>" }, 40 ms each; a seq gap means frames were dropped because you read too slowly |
playback | { "played_ms", "buffered_ms" } every 200 ms while your audio is queued, and once when it drains or when clear drops buffered audio |
ended | { "reason" }: stopped, tab_closed, browser_exit, idle, page_left, max_duration, capture_failed, playback_failed, audio_daemon_exit |
page_left means the tab landed on a meeting app's post-call page (today:
Jitsi's /static/close*.html, shown after any hangup, kick or meeting end). An
origin change alone does not end the session, because join flows hop origins.
One reader and one uplink at a time: a second concurrent GET gets
409 stream_busy and a second concurrent POST gets 409 uplink_busy. Once a
reader has disconnected, a new GET is accepted, so reconnecting after a
network blip is safe. The uplink accepts
audio faster than real time and queues up to 120 s; past that it stops reading
your body until audio plays. The response starts as soon as the uplink is
accepted: 200 with Content-Type: application/json, then a newline every
20 s while the call runs, so a proxy waiting for a response (Cloudflare's 524
after 100 s) or dropping an idle one keeps the call. Closing the body plays out
the queue, then the response ends with { "played_ms" }. Leading whitespace is
valid JSON, so the whole body still parses as one document. Treat the response
ending as the uplink being over, not its arrival. Cancelling the request, even
after the body is fully sent, drops the queued audio and frees the slot.
Other HTTP responses: 404 audio_not_found for an unknown, revoked or ended
capability (never a redirect), 415 unless Content-Type is audio/pcm.
Capability URLs#
The client that holds the call open is often not the agent that called
browser_audio_start, and carries no workbench token. So the audio routes take
no OAuth: browser_audio_start mints 128 random bits per audio session and puts
them in the path (/api/browser/audio/<capability>/stream and /clear). Any
Authorization header is ignored.
- The capability lives exactly as long as the audio session:
browser_audio_stop, theendedevent, closing the tab, a browser restart,max_duration, andpage_left, no reader or uplink withinBROWSER_AUDIO_FIRST_ATTACH_SECONDSof the start (default 600, room for a human to approve the call), and 60 s with neither connected after that (ended{idle}) all revoke it. The nextbrowser_audio_startmints a new one. - Treat the URLs as secrets. The server masks the capability in its request logs and records no trace spans for these paths; mask it in your proxy's access logs too (see Reverse proxy settings).
headerscarries onlyX-Browser-Session, a routing key, not a credential.- The URLs are built from
SERVER_PUBLIC_URL. A client that pins the workbench origin (refusing redirects and other hosts) needs that origin to match its own configured workbench URL exactly: scheme, host and port.
Errors from the tools#
| code | meaning |
|---|---|
AUDIO_DISABLED | BROWSER_AUDIO_ENABLED is off |
BROWSER_TAB_NOT_FOUND | that session_id is not one of your open tabs |
AUDIO_BUSY | another tab already holds your call, or this tab is running at a different sample_rate; the error carries the holding session_id. Call browser_audio_stop on it first |
BROWSER_RESTART_REQUIRED | the browser predates audio; call again with restart: true |
BROWSER_RESTART_FAILED | the restart did not come back |
AUDIO_ENDED | audio ended while starting; the detail names the reason |
Routing in a cluster#
Send the returned headers (X-Browser-Session) on all three requests so the
mesh routes them to the pod running your browser. If a request lands elsewhere
the server pipes it to the right pod itself (it needs INTERNAL_MCP_URL and
the X-Browser-Session header, since the capability names no user), at the
cost of an extra hop. Operator rules, as for every browser request: hash
consistently on X-Browser-Session, hash to pod endpoints rather than a
ClusterIP (which would re-round-robin), and keep CLUSTER_ENABLED off, since no
proxy can route inside a worker pool. Details in
Docker: multiple replicas and
browser session pod affinity.
Reverse proxy settings#
The uplink is a long request body and the downlink a long response, so a proxy
tuned for short requests cuts calls off. In front of the audio endpoints
(/api/browser/audio/*) set:
client_max_body_size 0; # default 1m cuts the uplink after ~22 s at 24 kHz
proxy_request_buffering off; # stream the body through
proxy_buffering off; # SSE downlink
proxy_http_version 1.1;
proxy_read_timeout 7200s; # at least the longest call
proxy_send_timeout 7200s;
access_log off; # the path is a credential (or log without $request_uri)ingress-nginx equivalents: nginx.ingress.kubernetes.io/proxy-body-size: "0",
proxy-request-buffering: "off", proxy-buffering: "off",
proxy-read-timeout: "7200", proxy-send-timeout: "7200". Also see
Docker: reverse proxy.
Behind Cloudflare's proxy, the uplink is one request body for the whole call,
and Cloudflare caps request bodies by plan (100 MB on Free and Pro). At 24 kHz
that is about 35 minutes of agent audio. For longer calls, serve the audio
routes from a DNS-only hostname, and set SERVER_PUBLIC_URL to it.
Isolation#
Each user gets their own PulseAudio daemon, so a page in your browser can only enumerate your own devices, never another user's call audio or microphone. The microphone permission is granted to one origin at a time and follows the tab's main-frame navigation. See the field note.
Limits#
- One call per user at a time (
AUDIO_BUSYnames the tab that has it). - Audio from your other tabs is mixed into the stream.
- A pod restart drops the connection with no
endedevent, and the old tab is gone with the browser. Callbrowser_start(orbrowser_tabs) for a newsession_id, navigate or rejoin the call, then callbrowser_audio_startagain.ended{browser_exit}means chromium itself exited. - Tell the people in the call they are talking to an agent. Recording and consent rules where you operate are yours to follow.