This section as Markdown: View Download

Web Voice Channel

Embed a real-time voice conversation with a Wiro agent into your own browser app. Used by the Voice Receptionist and Voice Sales Rep preset agents, and by any custom agent that needs an "Open mic" button on a website.

Overview

Toggle util-web-channel on a useragent and a single REST call (POST /UserAgent/Realtime/WebStart) returns everything the browser needs to open an authenticated WebSocket and stream microphone audio straight to the agent. The same agent can also pick up phone calls via Twilio Voice — the two channels share the same realtime runtime and end up in the same Call History.

Skill that powers this channel:

  • util-web-channel — browser voice channel. Bundled on the Voice Receptionist and Voice Sales Rep presets; toggle it on for any other agent via POST /UserAgent/SkillsApply.

Agents that typically enable this channel:

  • Voice Receptionist — phone + browser receptionist.
  • Voice Sales Rep — outbound sales rep with an "Open mic" CTA.
  • Custom agents that need a voice button on a website or web app.

Web channel vs Twilio channel — pick by where the caller comes from:

ChannelCaller comes fromWhen to use
Web (this page) Browser mic on your site "Open mic" button on a product / support / sales page. No phone number, no per-minute Twilio bill.
Twilio Inbound PSTN phone call A real phone number ringing. Twilio bills per inbound minute.

No third-party billing on this channel. The live call audio is billed through the int-wiro-aimodels skill against your own Wiro AI Models balance (you bring your own Wiro API key) — it is not charged to the agent's platform credit pool. The voice-prep turn and the post-call text turn are billed as normal token deducts (action: "tokens") on POST /UserAgent/TransactionList.

Availability

ModeStatusNotes
Bearer auth (Wiro-Web operator session) Available Origin must be https://wiro.ai / https://www.wiro.ai (or http://localhost:3000 / http://localhost:8080 in non-prod — other dev ports like :5173 / :4200 are rejected).
x-api-key (project API key) Available No Origin check. Use this when proxying through your own backend, or when embedding the voice button in a non-Wiro origin.

Prerequisites

  • A Wiro API key — Authentication.
  • A deployed agent in status: 4 (Running) — Agent Overview. Voice Receptionist / Voice Sales Rep presets work out of the box; for custom agents, enable util-web-channel via POST /UserAgent/SkillsApply.
  • A browser that can capture microphone audio at 24 kHz mono PCM (every evergreen browser via getUserMedia + Web Audio).

Setup

Step 1: Start a session — POST /UserAgent/Realtime/WebStart

Issues two opaque, one-time handles, both valid until expiresAt (5 minutes after issue): a sessionToken the browser presents to the WebSocket on first connect, and a cancelToken for Realtime/Cancel. No API key ever needs to reach the browser.

curl -X POST "https://api.wiro.ai/v1/UserAgent/Realtime/WebStart" \
  -H "Content-Type: application/json" \
  -H "x-api-key: YOUR_API_KEY" \
  -d '{
    "useragentguid": "your-useragent-guid",
    "session_metadata": {
      "page_url": "https://yourapp.com/agents/sales-rep",
      "display_identifier": "Acme Inc."
    }
  }'
ParameterTypeRequiredDescription
useragentguid string Yes Useragent instance guid. Must be an agent you deployed (for a team agent, call with that team's project key or workspace, or remain an active member of the team), must be in status: 4 (Running), and must have util-web-channel enabled.
session_metadata object No Optional caller context surfaced to the agent + later to Call History.
session_metadata.page_url string No The page URL the caller opened the mic from. Up to 2048 chars. Echoed back as callerInfo.page_url.
session_metadata.display_identifier string No Operator-supplied display name (e.g. logged-in customer name, account name). 1–100 chars matching ^[\p{L}\p{N} @._\-'+]+$ (Unicode letters / numbers / spaces / @ . _ - ' +). The check is whole-string — any character outside the allowlist (newlines, tabs, ampersands, backticks, $, etc.) silently rejects the entire field (callerInfo.display_identifier ends up null). Pre-sanitize on your side if you accept arbitrary input.

Response:

{
  "result": true,
  "correlationId": "voice_3fK9xQ2LmVb7TzR1pWc8NdYa",
  "wsUrl": "wss://socket.wiro.ai/v1/AgentRealtime/Web",
  "sessionToken": "<opaque one-time session handle>",
  "cancelToken": "<opaque one-time cancel handle>",
  "expiresAt": 1748212800000,
  "estimatedReadyMs": 8000
}
FieldDescription
correlationId Random public identifier for this session. Use it as the audio frame prefix (Step 2); server audio frames and the Realtime/Cancel response carry it too.
wsUrl Environment-specific WebSocket URL — pass it back to the browser verbatim, don't hardcode the host. Production lives at wss://socket.wiro.ai/v1/AgentRealtime/Web.
sessionToken Opaque one-time handle, valid until expiresAt; don't parse it. Send it as the first WebSocket message ({ type: "session_start", sessionToken }); it works only once.
cancelToken Opaque one-time handle for Realtime/Cancel (Step 3), valid until expiresAt. Only the user who called WebStart can use it.
expiresAt Expiry of both handles, in ms since epoch (5 minutes after issue). After this point, call WebStart again for fresh handles.
estimatedReadyMs Approximate time (ms) until the agent will be ready to speak after WS connect. The real "ready" signal is the { type: "ready" } frame on the WebSocket.

The endpoint also kicks off an asynchronous agent prep so the realtime model has the agent's system prompt + memory warm by the time the browser finishes the WS handshake. That's why a separate Realtime/Cancel call exists — if the browser never makes it to the WS step (mic permission denied, user changed their mind), Cancel tears the prep down immediately instead of leaving the warmed session to idle until the cleanup guard fires.

Step 2: Connect the WebSocket

Open a WebSocket to the returned wsUrl and send the session_start JSON frame within 10 seconds. After that, frame microphone audio as 24 kHz mono PCM int16 with a <correlationId>| text prefix. Server-sent binary audio frames use the same prefix shape — you must strip it before feeding the bytes to Web Audio.

The example below boots a working voice call end-to-end: it acquires the mic, mounts an AudioWorklet that emits 24 kHz Int16 PCM, opens the WebSocket, presents the token, handles every server frame the bridge actually sends, and plays the agent's audio back through AudioContext. Drop it into a page paired with the worklet at audio-processor.js shown below.

// ===== voice-client.js — runs in the browser =====

// 1. POST /UserAgent/Realtime/WebStart from YOUR backend, then pass the
//    correlationId / wsUrl / sessionToken / cancelToken down to the page.
//    Never expose your Wiro API key to the page.
const { correlationId, wsUrl, sessionToken, cancelToken } = await fetch("/api/voice/start").then((r) => r.json());

const audioCtx = new AudioContext({ sampleRate: 24000 });
await audioCtx.audioWorklet.addModule("/audio-processor.js");
let playbackCursor = 0;
const scheduledNodes = new Set();
let interruptActive = false;

function playPcmInt16(pcm) {
  const f32 = new Float32Array(pcm.length);
  for (let i = 0; i < pcm.length; i++) f32[i] = pcm[i] / 32768;
  const buf = audioCtx.createBuffer(1, f32.length, 24000);
  buf.copyToChannel(f32, 0);
  const node = audioCtx.createBufferSource();
  node.buffer = buf;
  node.connect(audioCtx.destination);
  const startAt = playbackCursor < audioCtx.currentTime ? audioCtx.currentTime : playbackCursor;
  scheduledNodes.add(node);
  node.onended = () => scheduledNodes.delete(node);
  node.start(startAt);
  playbackCursor = startAt + buf.duration;
}

function flushPlayback() {
  for (const node of scheduledNodes) { try { node.stop(); } catch {} }
  scheduledNodes.clear();
  playbackCursor = audioCtx.currentTime;
}

// 2. Open the WebSocket and present the token as the first message.
const ws = new WebSocket(wsUrl);
ws.binaryType = "arraybuffer";

ws.addEventListener("open", () => {
  ws.send(JSON.stringify({ type: "session_start", sessionToken }));
});

ws.addEventListener("message", async (event) => {
  if (event.data instanceof ArrayBuffer) {
    if (interruptActive) return;
    const u8 = new Uint8Array(event.data);
    const pipe = u8.indexOf(0x7c); // '|'
    if (pipe < 0) return;
    const pcmBytes = u8.subarray(pipe + 1);
    const pcm = new Int16Array(pcmBytes.buffer, pcmBytes.byteOffset, pcmBytes.byteLength / 2);
    playPcmInt16(pcm);
    return;
  }
  const msg = JSON.parse(event.data);
  switch (msg.type) {
    case "connecting":   onConnecting?.(); break;
    case "ready":        await startMicCapture(); break;
    case "transcript":
      appendTranscript(msg.role, msg.text, msg.ts); // role: "user"|"ai"
      break;
    case "clear":
      flushPlayback();
      interruptActive = true;
      break;
    case "resume":
      interruptActive = false;
      break;
    case "session_end":
      onEnded?.(msg.reason, msg.message || msg.error);
      try { ws.close(); } catch {}
      break;
  }
});

// 3. Mic capture pipeline.
let micStream, workletNode;
async function startMicCapture() {
  micStream = await navigator.mediaDevices.getUserMedia({
    audio: { echoCancellation: true, noiseSuppression: true, sampleRate: 24000 },
  });
  const source = audioCtx.createMediaStreamSource(micStream);
  workletNode = new AudioWorkletNode(audioCtx, "pcm-24k-processor");
  source.connect(workletNode);

  const sessionMarker = new TextEncoder().encode(correlationId);
  workletNode.port.onmessage = (e) => {
    const pcmBytes = new Uint8Array(e.data);
    const frame = new Uint8Array(sessionMarker.length + 1 + pcmBytes.byteLength);
    frame.set(sessionMarker, 0);
    frame[sessionMarker.length] = 0x7c; // '|'
    frame.set(pcmBytes, sessionMarker.length + 1);
    if (ws.readyState === WebSocket.OPEN) ws.send(frame);
  };
}

// 4. UI hooks.
function muteMic(on) {
  // Mute is purely client-side — the bridge has no server-side mute handler.
  micStream?.getAudioTracks().forEach((t) => (t.enabled = !on));
}
function bargeIn() { ws.send(JSON.stringify({ type: "interrupt" })); }
function endCall() { ws.send(JSON.stringify({ type: "end" })); }
// ===== audio-processor.js — served from your origin, registered above =====
class PCM24kProcessor extends AudioWorkletProcessor {
  constructor() { super(); this.buffer = new Float32Array(0); }
  process(inputs) {
    const input = inputs[0]?.[0];
    if (!input) return true;
    const merged = new Float32Array(this.buffer.length + input.length);
    merged.set(this.buffer);
    merged.set(input, this.buffer.length);
    this.buffer = merged;
    while (this.buffer.length >= 4800) {
      const chunk = this.buffer.slice(0, 4800);
      this.buffer = this.buffer.slice(4800);
      const i16 = new Int16Array(chunk.length);
      for (let i = 0; i < chunk.length; i++) {
        const s = Math.max(-1, Math.min(1, chunk[i]));
        i16[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
      }
      this.port.postMessage(i16.buffer, [i16.buffer]);
    }
    return true;
  }
}
registerProcessor("pcm-24k-processor", PCM24kProcessor);

Sample rate trick. Browsers treat getUserMedia({audio:{sampleRate:24000}}) as a hint, not a guarantee — most capture at 48 kHz. The AudioContext({sampleRate: 24000}) wrapper does the resampling for you on the MediaStreamSource → AudioWorkletNode graph, so the worklet always sees 24 kHz Float32 input. If you create the context at any other rate you'll ship the wrong sample rate and the agent will sound chipmunked or slowed.

Step 3 (optional): Cancel a stale session — POST /UserAgent/Realtime/Cancel

If the browser can't progress past WebStart (mic permission denied, user navigated away, tab crashed), call Realtime/Cancel so the server-side agent prep tears down immediately instead of waiting on the 5-minute cleanup guard. Send the cancelToken from WebStart, authenticated as the user who called WebStart; each cancel handle works once.

curl -X POST "https://api.wiro.ai/v1/UserAgent/Realtime/Cancel" \
  -H "Content-Type: application/json" \
  -H "x-api-key: YOUR_API_KEY" \
  -d '{
    "cancelToken": "CANCEL_TOKEN_FROM_WEBSTART"
  }'
ParameterTypeRequiredDescription
cancelToken string Yes The cancelToken returned by WebStart. One-time, valid until expiresAt; only the user who called WebStart can use it.

Response:

{ "result": true, "correlationId": "voice_3fK9xQ2LmVb7TzR1pWc8NdYa", "cancelled": true }
HTTPErrorWhen
400 cancelToken required cancelToken is missing.
401 invalid token Unknown, already used or expired handle.
403 access denied The handle was issued to another user.

No-op when the session is already cleaned up (bridge teardown or the 5-min cleanup guard). The endpoint always returns cancelled: true on success — there's no separate "already cleaned" signal. Reusing a cancel handle returns invalid token.

WebSocket Protocol

The browser bridge lives at wss://<env>/v1/AgentRealtime/Web. The same prefix hosts the Twilio Media Streams handler at /v1/AgentRealtime/Twilio, but you never call that directly — Twilio's auto-configured VoiceUrl points at it.

First message (text frame)

{ "type": "session_start", "sessionToken": "<sessionToken-from-WebStart>" }

Must arrive within 10 seconds of the WebSocket open event. Late or malformed → the server closes the socket with no further messages. The handle works once and only until expiresAt; an unknown, used or expired handle closes the socket the same way. The URL carries no session identifier.

Audio frames (binary, both directions)

FieldFormat
Audio PCM int16, 24 kHz, mono.
Frame <correlationId>|<binary-pcm> — UTF-8 text prefix (the correlationId returned by WebStart) + | (0x7C) separator + raw PCM bytes. Same shape as the Realtime Voice Conversation channel. Both directions use this prefix — when you receive a binary frame from the server you must locate the first 0x7C byte and slice everything after it as Int16Array.

The bridge transcodes server-side if a downstream model needs a different sample rate, so you always send 24 kHz from the browser.

Browser → server control frames (text)

BodyEffect
{ "type": "interrupt" } Cuts the agent mid-sentence (barge-in). Use this when the user starts speaking again before the agent finishes. The bridge confirms with a clear frame back.
{ "type": "end" } Graceful disconnect — the bridge flushes its buffers, emits a final transcript + session_end frame, then closes.

Mute is client-side, not a server frame. The bridge has no mute handler — toggle the mic MediaStreamTrack.enabled = false (or stop posting audio frames from your worklet) instead. Sending {type:"mute"} is silently ignored.

Server → browser frames

Body / typeNotes
{ "type": "connecting" } Sent right after the bridge accepts your session_start. Handshake is in progress; the realtime model is being warmed up. UI can show a "connecting" state.
{ "type": "ready" } Agent prep + model session are live; start the mic-capture pipeline and send audio frames from this point on.
{ "type": "transcript", "role": "user"|"ai", "text": "…", "ts": <epoch-ms> } Live transcript pair, same content that goes into the operator panel chat. Note the field name is text (not content) and the agent role is "ai" (not "agent"). ts is server time in ms since epoch.
{ "type": "clear" } Barge-in confirmation — the bridge has discarded the agent utterance still in flight. Drop every AudioBufferSourceNode you've already scheduled and stop appending new audio until you see resume.
{ "type": "resume" } A new agent utterance is starting after a clear. Resume normal binary-frame playback.
Binary frame Agent audio chunk — <correlationId>|<int16-pcm-24khz-mono>. Strip the prefix and feed the PCM tail to Web Audio.
{ "type": "session_end", "reason": "...", "message"?: "...", "error"?: "..." } Terminal frame, immediately followed by a clean ws.close(1000, …). All errors and graceful ends arrive here — there is no separate error frame. Common reason values: wiro_completed, wiro_cancelled, wiro_disconnect, wiro_error, start_error, max_duration, concurrent_limit, browser_disconnect, rejected. message is populated for reason: "rejected" (operator-facing reject reason). error is populated for reason: "start_error" (raw exception message from the realtime model bring-up).

Session lifetime

  • The sessionToken handle is valid for 300 seconds from issue (until expiresAt) and works once, so the browser must send session_start before then. To connect again, call WebStart for fresh handles — there's no in-band refresh.
  • The WebStart-armed 5-minute pre-WS cleanup guard is cancelled the moment the bridge accepts your session_start frame, so a live call is never torn down by it. Live call audio is billed through int-wiro-aimodels against your own Wiro AI Models balance — the pre-WS prep window carries no audio charge.

Rate Limits

  • 60 sessions / hour per operator (fixed window). The operator identity is hashed and never logged raw.
  • Fast-fail sessions (prep timeout, model rejecting the realtime session, WS upgrade error) decrement the counter back so a flaky downstream doesn't burn through quota during an incident.
  • Origin allow-list (Bearer auth path only) — https://wiro.ai / https://www.wiro.ai (plus http://localhost:3000 / http://localhost:8080 in non-production). The API-key path skips the Origin check because key + IP whitelist already authenticated the caller.

Common Errors

HTTPError codeMessageWhen
200 — useragentguid required Missing useragent reference.
403 95 useragent-not-found The guid doesn't exist (note: returned as 403, not 404).
403 96 useragent-access-denied Caller didn't deploy the useragent.
200 — This agent belongs to a team. Switch to the team context to access it. Team agent called from a personal workspace or personal project key by a caller who is no longer a member of the agent's team.
200 — This agent belongs to a different team. Team agent called from another team's workspace or project key by a caller who is no longer a member of the agent's team.
200 — Agent is not running Useragent is not in status: 4. Start it via POST /UserAgent/Start first.
200 — util-web-channel not enabled Toggle util-web-channel via POST /UserAgent/SkillsApply (custom agents) or pick a preset that bundles it (Voice Receptionist / Voice Sales Rep).
403 — web voice only available from Wiro-Web (Bearer auth) or via API key Bearer auth call with an Origin outside the Wiro allow-list. Either switch to API-key auth or call from an allow-listed origin.
429 — Rate limit: max N web voice sessions per hour Operator burned through the per-hour cap. Wait for the fixed-window expiry.
500 — internal error Unexpected backend failure — check POST /UserAgent/Logs for context.

Multi-Tenant Architecture

Every WebStart call is scoped by:

  • One-time handles — sessionToken and cancelToken are opaque, random and single-use, valid until expiresAt. Nothing readable travels in the handle or the WS URL — same "single endpoint, first-message token" pattern Wiro uses for its native realtime models.
  • useragentguid + caller uuid — kept on the server behind the handle, never sent to the browser; the bridge re-checks the agent when the browser connects.
  • Origin allow-list (Bearer path only) — defence-in-depth against a stolen localStorage Bearer being weaponised from a third-party site.
  • Per-operator rate-limit key — hashed, so logs never carry the raw operator identity.
  • Channel-isolated upgrade handlers — /v1/AgentRealtime/Web and /v1/AgentRealtime/Twilio are distinct upgrade handlers on the same process; one cannot read the other's session state. Adding a new provider in the future is a single new handler under the same /v1/AgentRealtime/<NewChannel> prefix — no nginx config churn.