Voxa web SDK

Put a Voxa assistant behind your own button. @voxa/web asks the console for a short-lived call token with your organization's public key, captures the microphone, streams it to the voice server and plays the assistant's replies. You build the UI; the SDK reports status, transcript, speech and errors as events. Video agents show the assistant as a HeyGen avatar in your own <video> element. For a ready-made floating button, use the widget from an assistant's Widget tab instead.

Getting the package

@voxa/web is not published on npm yet, so npm i @voxa/web does not work. Use the script your console hosts, or install the package from the console's repository.

Script tag (hosted by your console)

Nothing to install. It defines window.Voxa, and the console address defaults to the origin the script was loaded from:

<script src="https://<your-console>/sdk/voxa-web.js"></script>

The script-tag build is about 44 KB minified and has no dependencies: the two video libraries (LiveKit, HeyGen's SDK) are fetched from a CDN the first time a video call starts and never for audio calls. It works in Chrome and Edge 66+, Firefox 76+ and Safari 15+ (AudioWorklet, WebSocket and getUserMedia are required). The React and Vue bindings are only in the npm package.

From the console repository (bundlers, React, Vue)

The package lives in packages/web-sdk of the console's repository. Its dist/ folder is not committed, so build it first, then pack it and install the tarball in your app:

# in the console repository
npm install
npm run build:sdk                                  # builds packages/web-sdk/dist
cd packages/web-sdk && npm pack                    # writes voxa-web-0.3.3.tgz

# in your app (keep the .tgz in the repo, or host it, so CI can install it too)
npm i /path/to/voxa-web-0.3.3.tgz
# only for video agents (optional peers; otherwise they load from a CDN on demand)
npm i livekit-client @heygen/liveavatar-web-sdk

Installing straight from a git URL does not work: npm cannot install a subfolder of a repository, and the build output is not committed. npm i /path/to/console-repo/packages/web-sdk (a folder link) works for local experiments, but the folder has its own node_modules with a development copy of Vue: a Vue app then needs resolve: { dedupe: ["vue"] } in its Vite config, so prefer the tarball. The imports are the same either way: @voxa/web, @voxa/web/react and @voxa/web/vue.

Quick start

You need the organization's public key (API keys page, pk_…) and the id of a Published assistant.

While you build, pass the test public key instead (pk_test_…, next to the public key on the API keys page): the SDK sends it as is, and the console starts test calls that use no credits, last at most 5 minutes, count against the organization's monthly test budget, run video agents in sandbox mode and send webhooks only to the test webhook URL (see Test mode). A used-up test budget rejects with code: "test_budget" (402); a Full video agent without sandbox mode with code: "forbidden", reason: "test_mode_video". Switch to the live pk_… key for production.

import Voxa from "@voxa/web";

const voxa = new Voxa({
  publicKey: "pk_...",
  baseUrl: "https://<your-console>", // optional with the script tag
});

voxa.on("status", ({ status }) => console.log("status", status)); // idle | connecting | active | ending
voxa.on("message", (m) => {
  if (m.type === "transcript") console.log(m.role, m.text);
});
voxa.on("error", (e) => console.error(e.message));

callButton.addEventListener("click", async () => {
  // start() must run inside the click: Safari and iOS only start audio from a user gesture.
  const { assistantId, callId } = await voxa.start("asst_...");
});
hangUpButton.addEventListener("click", () => voxa.stop());
start() creates the audio contexts synchronously, before its first await. Call it directly from the click or tap handler, not after another await or a timer, or iOS will keep the audio silent until the next tap.

Inline assistant

Instead of an id, pass the assistant's configuration. The console stores it as a hidden assistant of your organization (reused when you send the same configuration again, removed a day after its last use), so calls, recordings and analysis still show up in the console. Every field is optional; anything left out takes the console's defaults.

Off by default. Inline assistants only work when an organization admin turns on Allow inline SDK assistants on the console's API keys page. While it is off, start() with a configuration fails with code: "forbidden" (HTTP 403, "Inline assistants are turned off for this organization…"); starting a Published assistant by id is not affected. The switch also governs toolIds and kb: they are only accepted while it is on, and must name your organization's own tools and knowledge base.
Anyone with the public key can define one. So an inline assistant only gets what an admin allowed under the switch (What inline assistants may use): by default no tools (toolIds), no knowledge base (kb, Unsorted included), the organization's default model only, no built-in tools besides end and query, and prompts up to 4,000 characters (an admin can set 200 to 20,000). Anything else is refused with HTTP 400 naming the field (code: "invalid_request"). Each new configuration is recorded in the organization's activity log.
await voxa.start({
  name: "Order helper",
  firstMessage: "Hi! Which order can I help you with?",
  prompt: "You help customers with their orders. Be brief and friendly.",
  // provider / model: left out = the organization's default model (others only when an admin allows them)
  temperature: 0.4,
  voiceProvider: "Deepgram",   // "Cartesia" | "ElevenLabs" | "Deepgram" | "OpenAI"
  voice: "Maya",
  language: "en",
  endPhrases: ["goodbye"],
  maxDuration: 10,             // minutes
  silenceTimeout: 30,          // seconds
  tools: { end: true },
  recording: true,
});
FieldTypeNotes
name, firstMessage, promptstringAn empty first message makes the assistant wait for the caller. The prompt is capped at 4,000 characters unless an admin set another limit.
provider, model, temperaturestring, string, numbermodel must be the organization's default model or one an admin allowed; provider must match it (filled in when left out). Temperature 0 to 2.
maxTokensnumberAccepted, not applied to live calls It is stored with the assistant, but the voice agent has no reply-length setting, so live calls ignore it.
voiceProvider, voice, voiceIdstringvoiceId is the provider's own voice id when the provider is not Deepgram. Cartesia, ElevenLabs and OpenAI voices need that provider's key connected to your organization; without it the call uses a Deepgram Aura voice.
tProvider, tModel, languagestringTranscriber: Deepgram, AssemblyAI, Google or OpenAI. Accepted, not applied to live calls for anything but Deepgram: live calls always transcribe with Deepgram: tModel is nova-3 (default), nova-3-multi, flux-general-en, flux-general-multi or nova-2; anything else, or a model that does not cover language, falls back to one that does.
sttKeytermsstring[]Nova-3 and Flux: up to 100 product names, jargon or proper nouns (100 characters each) the transcriber should recognise. Other models ignore them.
sttEndOfTurnobjectFlux only: { threshold, eager, timeoutMs }. threshold 0.5 to 1 (default 0.7; higher waits for clearer pauses), eager 0.3 to 0.9 and at most the threshold (starts preparing a reply early; absent = off), timeoutMs 500 to 10000 (default 5000).
endPhrasesstring[]The assistant hangs up after saying one of these.
maxDuration, silenceTimeoutnumberMinutes; seconds.
recording, hipaaboolean
toolsobjectBuilt-in tools: book, transfer, end, order, query (booleans). Turning on anything but end and query needs an admin's opt-in.
toolIds, kbstring[], stringTool records and a knowledge base of your organization, each on the admin's allow-list (none by default). Accepted only while Allow inline SDK assistants is on (403 otherwise).
transferNumber, summaryPrompt, evaluation, analysisSchemaAs on the assistant's tabs in the console.

Unknown fields and wrong types are rejected before anything is sent (code: "invalid_config"). Ranges and references (toolIds, kb) are checked by the console (code: "invalid_request", HTTP 400).

Variables

variableValues are details about the caller for this call only. Keys are identifiers (letters, digits, underscores; up to 40 characters), at most 20 of them, values up to 200 characters, 2 KB in total. Anyone with your public key can send them, so they are treated as unverified: each value is put on one line (control characters and line breaks removed) and shown to the assistant in a separate "caller-supplied details" block after your prompt, never as instructions. They fill {{name}} placeholders in the prompt and first message, and seed workflow variables, only for names your organization lists under API keys → Page variables. For trusted values (an account id your tools rely on), mint the call on your server with POST /api/v1/web-calls and a secret key.

await voxa.start("asst_...", {
  variableValues: { name: "Ada", plan: "Pro" },
  muted: false,
});

Returning callers

customerId names the caller in your own system (1 to 128 characters of letters, digits and . _ : @ -), for example your customer or account id. It is stored on the call, and assistants with Remember callers on (console, assistant's Advanced tab) look up the caller's contact and add what earlier calls left there to the prompt: the last summary, known details and open promises. Contacts are listed on the console's Contacts page.

Anyone who can run your page could send any id, so with a public key the console uses customerId only together with customerIdSignature, which your server computes for the signed-in customer with the organization's customer id signing secret (console: Organization settings, next to Returning-caller memory; admins reveal and rotate it). Never put the secret in the page.

The signature is <expires>.<hex>: expires is a Unix time in seconds, in the future and at most 7 days ahead, and hex is the lowercase hex HMAC-SHA256 of customerId, a period and expires, keyed with the secret.

// On your server (Node), when rendering the page for a signed-in customer
import crypto from "node:crypto";
const expires = Math.floor(Date.now() / 1000) + 3600; // one hour
const hex = crypto.createHmac("sha256", process.env.VOXA_CUSTOMER_ID_SECRET).update(`${customerId}.${expires}`).digest("hex");
const customerIdSignature = `${expires}.${hex}`;

# Python
expires = int(time.time()) + 3600
sig = f"{expires}." + hmac.new(secret.encode(), f"{customer_id}.{expires}".encode(), hashlib.sha256).hexdigest()
// In the page
await voxa.start("asst_...", { customerId: "cus_8f2k1", customerIdSignature });
await voxa.startVideo("va_...", { element, customerId: "cus_8f2k1", customerIdSignature }); // Lite agents

An id without a valid signature (none, wrong, expired or more than 7 days ahead) is ignored: the call still runs, but without memory, nothing is stored on the call or the contact, and the token answer says why in customerIdIgnored (unsigned, invalid_signature, expired, expires_too_far; the SDK and the widget log a console warning). Rotating the secret invalidates every earlier signature. Tokens minted on your server with POST /api/v1/web-calls and a secret key need no signature. Full-mode video agents do not use memory. A malformed id or signature fails start() with invalid_config.

The caller's language

Assistants that speak several languages (Voice tab, Languages) start the call in the caller's language when you pass it as language, a tag such as "es" or "pt-BR", for example the browser's own. The call then opens with that language's greeting and voice, and still switches if the caller speaks another of the assistant's languages. A language the assistant does not speak is ignored; a malformed tag fails start() with invalid_config.

await voxa.start("asst_...", { language: navigator.language });

Workflows and squads

Pass workflowId or squadId (one of them) to run a workflow or a squad from the console for this call. With null instead of an assistant, the workflow's assistant or the squad's entry member answers.

await voxa.start(null, { squadId: "sq_..." });
await voxa.start("asst_...", { workflowId: "wf_...", variableValues: { name: "Ada" } });

voxa.on("handoff", ({ assistantId, name, from, reason }) => showBanner(`${from} handed you to ${name}`));
voxa.on("workflow-step", ({ nodeId, name, kind }) => highlightStep(nodeId));

An unknown workflow or squad fails start() with not_found.

Text chat

The same assistant (prompt, knowledge base, tools) answers a text chat on your site: no microphone, no call. voxa.chat(assistantId, options?) returns a chat; send(text) resolves the assistant's reply (messages go one at a time, in order). Only Published, non-HIPAA assistants chat, and only with the live public key.

const chat = voxa.chat("asst_...", { customerId, customerIdSignature }); // both optional, as for calls
chat.on("message", (m) => render(m.role, m.text));   // yours as you send, the assistant's as they arrive
await chat.start();                                   // optional: the assistant's first message
const reply = await chat.send("Do you open on Sundays?");
console.log(reply.text);

// "Switch to voice": a call with the same assistant, next to the chat.
callButton.onclick = () => voxa.start("asst_...");

The first answer carries a signed session the chat sends with every later message. A chat that ended on the server (a day without messages, or 50 messages) or whose session expired starts again on the next send() and emits restart. Other events: ready ({ conversationId, assistantId, assistantName }) and error (a VoxaError with status and reason: no_credits, hipaa, test_key, rate_limited, conversation_limit…). Properties: messages, conversationId, assistantName; reset() forgets the session. Each reply is billed per message; chats appear on the console's Conversations page. The request is POST /api/chat/messages.

Video agents

A video agent (console, Video agents page) shows an assistant as a HeyGen avatar. startVideo() takes the agent's id and the <video> element to show it in; the console says which of the two modes the agent runs in and the SDK takes the matching path. Only Published agents can be started with the public key (drafts answer 404).

ModeWho talksHow the call runsPrice
liteVoxa: the assistant's model and voice, through the voice server, exactly like start().The call is a normal voice call. When HeyGen is up the voice server says so (video-ready): the SDK joins the avatar's LiveKit room, shows its video on your element and from then on the voice arrives through the avatar's stream (lip-synced) instead of audio frames. If the avatar fails or drops (video-error) the call goes on with audio.The avatar's per-minute price on top of the assistant's rate.
fullHeyGen: speech recognition, the model, the voice and the avatar.The console mints a HeyGen session and logs the Call; the browser runs the session through HeyGen's web SDK (microphone and playback included), keeps the Call alive every 30 s and reports the end exactly once. There is no voice server leg, so send(), tools, workflows and squads do not apply. When the caller asks to end the call, the agent says goodbye and the SDK ends the call (call-end reason assistant-ended).One all-in rate per minute.

Sandbox agents (the sandbox avatar) are free in both modes and meant for trying things out. video-ready carries maxSec when HeyGen's plan caps the avatar session: a Lite call then continues as audio after the avatar leaves (video-end); a Full call ends.

<video id="avatar" autoplay playsinline style="width:100%;aspect-ratio:16/9;background:#1f1f1f"></video>

const voxa = new Voxa({ publicKey: "pk_...", baseUrl: "https://<your-console>" });
const el = document.getElementById("avatar");

voxa.on("video-ready", ({ mode, avatarName, sandbox, maxSec }) => hidePlaceholder());
voxa.on("video-error", ({ message }) => showNote(`Avatar unavailable: ${message}. Audio continues.`));
voxa.on("video-end", () => showPlaceholder("The avatar left"));
voxa.on("message", (m) => m.type === "transcript" && addLine(m.role, m.text));

// From the click handler, like start(): the console picks lite or full.
callButton.onclick = async () => {
  const { mode, callId } = await voxa.startVideo("va_...", { element: el, muted: false });
  interruptButton.hidden = mode !== "full";
  holdButton.hidden = voxa.interactivity !== "push_to_talk";
};
hangUpButton.onclick = () => voxa.stop();

// Full agents only: cut the avatar short, or hold the microphone open (push-to-talk agents).
interruptButton.onclick = () => voxa.interrupt();
holdButton.onpointerdown = () => voxa.startPushToTalk();
holdButton.onpointerup = () => voxa.stopPushToTalk();

The element

Give the <video> autoplay and playsinline and size it yourself (the avatar is 16:9 or 4:3 depending on the agent). The avatar's voice is attached to the same element, so do not mute it. Without an element the call still runs and the voice still plays through a hidden audio element. startVideo() must run inside the click or tap, like start(): if the browser refuses to play the avatar's sound without a gesture, the SDK retries on the next tap anywhere on the page.

Libraries

Video calls need livekit-client (Lite) or @heygen/liveavatar-web-sdk (Full). Both are optional peer dependencies: the SDK first tries import("livekit-client") / import("@heygen/liveavatar-web-sdk"), which your bundler resolves when the packages are installed. Next it uses a copy your page already loaded (window.LivekitClient / window.LiveAvatarSDK, e.g. from your own <script> tag), then ES module URLs you pass, and otherwise adds the pinned UMD builds as <script> tags with Subresource Integrity: first the console's own copy (<console>/vendor/livekit-client@2.22.3/livekit-client.umd.js, <console>/vendor/liveavatar-web-sdk@0.0.19/index.umd.js), then jsDelivr as the last resort, which is never used on the console's own origin (livekit-client@2.22.3/dist/livekit-client.umd.js, @heygen/liveavatar-web-sdk@0.0.19/dist/index.umd.js, integrity="sha384-…", crossorigin="anonymous"): a changed file is refused by the browser instead of run. The script-tag build of the SDK takes this route unless the page provides the libraries. Nothing is loaded for audio calls. To serve the modules yourself, pass their ES module URLs: new Voxa({ publicKey, modules: { livekit: "https://cdn.example.com/livekit-client.esm.js", heygen: "https://cdn.example.com/liveavatar.esm.js" } }) (imported as given; your host vouches for them). Nothing is evaluated from strings, so a Content-Security-Policy needs script-src for the CDN (or your host) but never 'unsafe-eval'; installing the packages avoids the CDN entirely.

Allowed origins

The agent lookup, the call token and the Full session are all requested with the public key from the visitor's page, so the page's origin must be in the organization's Allowed origins (or the list must be empty). Otherwise startVideo() rejects with code: "forbidden", reason: "origin_not_allowed".

Push to talk and interrupt

A Full agent is configured as conversational (the avatar listens all the time; setMuted() works as usual) or push to talk (the microphone opens only while startPushToTalk() is held and the avatar answers on stopPushToTalk()). voxa.interactivity says which once the call is live. interrupt() cuts a Full avatar's reply short; on audio calls and Lite agents the caller simply talks over the assistant, so it resolves without doing anything.

Events and results

Both modes emit status, call-start, message transcripts, speech-start / speech-end, the three video events and call-end. For a Full agent call-start.assistantId and activeAssistant name the video agent (there is no separate assistant); for a Lite agent they name the assistant the agent wraps. startVideo() resolves { assistantId, callId, mode }. voxa.isVideo / voxa.videoMode tell you what the current call is.

The explicit Lite form, when you already know the agent is Lite: voxa.start(null, { videoAgentId: "va_...", video: { element } }) (also with variableValues). A Full agent sent this way is refused with invalid_request, reason: "full_mode".

Methods and properties

MemberDescription
new Voxa({ publicKey, baseUrl?, workletUrl?, modules? })baseUrl is the console origin; it defaults to the script tag's origin (script build) or the page's origin. workletUrl serves the microphone worklet from your own origin when a Content-Security-Policy blocks blob: scripts (copy /pcm-worklet.js from the console). modules ({ livekit?, heygen? }) overrides where the video libraries are loaded from when they are not installed.
start(assistantIdOrConfig, options?)Resolves { assistantId, callId?, mode? } once the assistant is ready to talk; callId names the Call record in the console. The voice server sends it with the message that says the assistant is configured (SettingsApplied), when it writes the in-progress Call row, so current servers always include it; older ones may only report it at call-end. Options: variableValues, customerId + customerIdSignature (returning callers), muted, workflowId, squadId (pass null as the assistant to let the workflow or squad pick it), videoAgentId + video: { element } (a Lite video agent). Rejects with a VoxaError (message, code, status?, retryAfter?, reason?; see Errors), which is also emitted as error. A second start() while a call is connecting, live or ending rejects with already_active.
startVideo(videoAgentId, options?)Starts a video agent in whichever mode the console says it runs. Options: element (the <video>), muted, variableValues, customerId and customerIdSignature (Lite agents). Resolves { assistantId, callId, mode } once the call is live; rejects and emits error like start(). Call it from the click or tap.
stop()Hangs up. Resolves once the reply that is already queued has played and the server has closed the call (at most 3 seconds). While connecting it cancels the attempt at once (start() rejects with cancelled) and releases the microphone. Full video agents: stops the HeyGen session and reports the end to the console before call-end.
setMuted(muted), isMuted()Mutes the microphone. Silence keeps streaming, so the call keeps its timing. Full video agents: mutes HeyGen's microphone track (push-to-talk agents manage it themselves).
send(text)Hands the assistant a typed line (up to 1000 characters) as if the caller had said it; the assistant answers it like a spoken one. Use it for a chat box next to the call or for buttons such as "Repeat that". Throws a VoxaError: not_active without a live call (also on Full video calls, which have no voice server leg), invalid_config for an empty or too long text.
interrupt()Full video agents: cuts the avatar's reply short. Resolves without doing anything on audio calls and Lite agents (the caller talks over the assistant there).
startPushToTalk(), stopPushToTalk()Full video agents configured for push to talk: hold and release the microphone. startPushToTalk() rejects with not_active without a live Full call and unsupported when the agent listens continuously; stopPushToTalk() resolves quietly when nothing is held.
status"idle", "connecting", "active" or "ending" (the farewell is playing out, or a Full session is being reported).
isSpeakingThe assistant's audio is playing (Full video: the avatar is speaking; Lite video: only until the avatar takes over the voice).
isVideo, videoMode, interactivityWhether the current call runs a video agent and how ("lite", "full" or null); for Full agents, "conversational" or "push_to_talk" once the session is minted (null otherwise).
on(event, fn), once(event, fn), off(event, fn?)on and once return a function that unsubscribes.
Voxa.VERSION, VoxaErrorAlso named exports: import { Voxa, VoxaError, VERSION } from "@voxa/web".

Events

EventPayloadWhen
status{ status }Every status change.
call-start{ assistantId, callId?, activeAssistant }The assistant is configured and listening. callId names the Call record (sent with the server's SettingsApplied). activeAssistant is { assistantId, name }: the assistant that answers, with the name the console reported when it issued the call token (the id when an older console does not report one).
call-end{ reason?, callId?, recordingUrl? }After everything is released. reason: customer-ended (stop or page closed), assistant-ended, error, connection-lost. callId and recordingUrl are there when the server saved the call (and the recording) before the connection closed. recordingUrl is the absolute REST link <baseUrl>/api/v1/calls/<callId>/recording: it needs a secret API key, so fetch it from your server (see the recording endpoint), never as a link for the caller.
speech-start, speech-end{}The assistant's audio started; it finished or the caller interrupted it.
message{ type: "transcript", role, text }A finished line from the caller (user) or the assistant. The voice server only reports complete lines (no interim results), so every transcript message is final.
{ type: "tool-call", name, arguments }The assistant called a tool with these arguments. Sent just before the matching tool-result, and only when the server includes the arguments: it leaves them out for HIPAA assistants.
{ type: "tool-result", name, result }A tool ran (result is clipped to 500 characters).
handoff{ assistantId, name, from?, reason? }Squads: another member (assistantId, name) took over from from.
workflow-step{ nodeId, name, kind?, from?, via?, field?, value?, outcome?, branch? }Workflows: the call moved to node nodeId. kind is start, conv, tool, transfer, end, extract or condition; from is the previous node's label. Leaving an extract node: field, the recorded value (never for HIPAA assistants) and outcome (recorded, skipped or failed); leaving a condition node: branch, the rule that matched (when date is missing) or else.
volume-level{ level }Microphone level from 0 to 1, about 10 times a second while active (0 while muted). Audio calls and Lite video agents; Full agents capture the microphone through HeyGen.
video-ready{ mode, avatarName, sandbox, sessionId?, maxSec? }Video agents: the avatar is on your element. Lite: the voice now comes through the avatar's stream. maxSec is HeyGen's per-session cap for the plan, when known.
video-error{ message }Lite: the avatar failed or dropped (HeyGen refused the session, the room could not be joined, the library could not be loaded). The call continues with audio.
video-end{}The avatar left. Lite: the call may go on as audio (for example after maxSec); Full: call-end follows. Also emitted before call-end when the call ends while the avatar is up.
error{ message, code?, status?, retryAfter?, reason? }A failed start, a provider error during the call, or a lost connection. See Errors.

Errors

Every failure is a VoxaError with a stable code. Messages are written to be shown to people as they are; use code (and, for refused call tokens, reason) to branch.

CodeMeaning
invalid_configBad constructor option or start() argument (missing publicKey, malformed baseUrl, an unknown inline assistant field, bad variableValues, customerId or customerIdSignature), or send() with an empty or too long text.
already_activestart() while a call is connecting, live or ending. Rejects only; not emitted as error.
cancelledstop() was called before the call connected. Rejects only; not emitted as error.
not_activesend() without a live call (thrown).
invalid_request (400)The console rejected the assistant or variables; the message names the fields.
unauthorized (401)Unknown or missing public key (reason: "unknown_public_key").
no_credits, monthly_limit, billing (402)The organization cannot pay for calls. The message is the console's own copy: show it as is. reason repeats the code.
forbidden (403)The console refused the call token; reason says why (table below).
not_found (404)Unknown assistant, workflow or squad; a video agent that is unknown or still a draft.
not_available (409)Video agents are not available for the organization (HeyGen is not configured, or the feature is off). The message is the console's own copy.
module_loadA video library (livekit-client, @heygen/liveavatar-web-sdk) is neither installed nor loadable from the CDN (blocked network or Content-Security-Policy). The message names both attempts; see Libraries.
rate_limited (429)Too many calls from this address (reason: "rate_limited_ip") or for this key ("rate_limited_key"). retryAfter is in seconds.
concurrency_limit (429)The organization is already on as many calls at once as its limit allows (Billing page). The message is visitor-friendly; retryAfter suggests when to try again.
daily_budget (429)The assistant spent its daily budget; it takes calls again after 00:00 UTC (retryAfter counts down to it).
http_errorAny other failed status of the token request (status has it).
bad_responseThe console answered without a call token, a voice server address or an assistant id (is the voice server configured?).
network, connect_failedThe console or the voice server could not be reached, or the voice server did not start the call in time.
microphoneMicrophone permission denied, no microphone, or the microphone is in use by another application.
insecure_context, unsupportedPlain http (not localhost), or a browser without Web Audio, AudioWorklet, getUserMedia or WebSocket.
server_errorThe voice server or provider reported an error.
connection_lostThe connection dropped mid-call (event only; call-end follows with connection-lost).

Why a call token was refused (reason, with code: "forbidden" unless noted). Older consoles send no reason and new values may be added, so treat an unknown value like a missing one.

ReasonMeaning
origin_not_allowedThe page's origin is not in Allowed origins. Add its exact origin (scheme, host and port).
not_publishedThe assistant (or a squad member) is not Published.
inline_not_allowedstart() got a configuration (or toolIds / kb) while Allow inline SDK assistants is off. Turn it on under API keys, or start a Published assistant by id.
foreign_assistantThe assistant belongs to another organization than the public key.
not_squad_member, workflow_assistant_mismatchThe assistant is not a member of that squad, or the workflow runs on a different assistant.
full_modeWith code: "invalid_request" (400): a Full video agent was started through start({ videoAgentId }); use startVideo().
unknown_public_keyWith code: "unauthorized" (401): no organization has this public key.
rate_limited_ip, rate_limited_keyWith code: "rate_limited" (429).
no_credits, monthly_limitWith the same code (402).
test_budgetWith the same code (402): a test public key (pk_test_…) and the month's test minutes are used up.
test_mode_videoWith code: "forbidden" (403): a test public key started a Full video agent that is not in sandbox mode.
concurrency_limit, daily_budgetWith the same code (429).
voxa.start("asst_...").catch((e) => {
  if (e.code === "cancelled") return;               // stop() while connecting
  if (e.code === "rate_limited") retryIn(e.retryAfter);
  if (e.reason === "origin_not_allowed") console.warn("Add this site under Allowed origins");
  showBanner(e.message);
});

React

useVoxa() owns one client per public key and console, mirrors its state and stops the call when the component unmounts. Its start() and send() never throw: failures land in error (start() resolves with null, send() returns false). React is an optional peer dependency.

import { useVoxa } from "@voxa/web/react";

export function VoiceButton() {
  const { start, stop, setMuted, isMuted, status, isSpeaking, transcript, error } = useVoxa({
    publicKey: "pk_...",
    baseUrl: "https://<your-console>",
  });

  return (
    <div>
      {status === "idle" ? (
        <button onClick={() => start("asst_...", { variableValues: { name: "Ada" } })}>Talk to us</button>
      ) : (
        <button onClick={() => stop()}>{status === "connecting" ? "Cancel" : "End call"}</button>
      )}
      <button disabled={status !== "active"} onClick={() => setMuted(!isMuted)}>{isMuted ? "Unmute" : "Mute"}</button>
      <p>{status}{isSpeaking ? " (assistant speaking)" : ""}</p>
      {error && <p role="alert">{error.message}</p>}
      <ul>{transcript.map((l, i) => <li key={i}>{l.role}: {l.text}</li>)}</ul>
    </div>
  );
}
ReturnedDescription
start, startVideo, stop, send, setMuted, interrupt, startPushToTalk, stopPushToTalkAs on the client (see Methods); none of them throws. startVideo resolves with null on failure, the push-to-talk pair with false; the failure lands in error.
status, isSpeaking, isMutedCall status, assistant audio playing, microphone muted.
videoMode, avatarUp"lite", "full" or null for the current call; whether the avatar is on screen (between video-ready and video-error / video-end).
volumeLevelMicrophone level 0 to 1, about 10 updates a second during a call (0 otherwise). Each update re-renders the component that uses the hook; keep it small.
transcript{ role, text }[] of the current (or last) call.
activeAssistant{ assistantId, name }: who is talking. Set at call start (from call-start; the name falls back to the id), updated on every squad handoff.
step{ nodeId, name, kind? }: the workflow node the call is on, or null.
lastCall{ callId?, recordingUrl?, reason? } from the last call-end; kept until the next call ends. recordingUrl needs a secret API key: do not link it for callers.
errorThe last error, cleared by start().
voxaThe underlying client, for anything the hook does not mirror (tool-call messages...).

Vue 3

@voxa/web/vue exports a useVoxa() composable with the same fields as the React hook, as refs. Call it in setup(): the call stops and the listeners go away when the component unmounts. Vue 3.2 or later is an optional peer dependency.

<script setup lang="ts">
import { ref } from "vue";
import { useVoxa } from "@voxa/web/vue";

const { start, stop, send, setMuted, status, isMuted, isSpeaking, volumeLevel, transcript, activeAssistant, step, lastCall, error } =
  useVoxa({ publicKey: "pk_...", baseUrl: "https://<your-console>" });
const typed = ref("");
</script>

<template>
  <button v-if="status === 'idle'" @click="start('asst_...')">Talk to us</button>
  <button v-else @click="stop()">End call</button>
  <button :disabled="status !== 'active'" @click="setMuted(!isMuted)">{{ isMuted ? "Unmute" : "Mute" }}</button>
  <meter min="0" max="1" :value="volumeLevel" />
  <p>{{ status }}<span v-if="activeAssistant"> with {{ activeAssistant.name }}</span><span v-if="step"> · {{ step.name }}</span></p>
  <form @submit.prevent="send(typed) && (typed = '')"><input v-model="typed" /></form>
  <p v-if="error" role="alert">{{ error.message }}</p>
  <ul><li v-for="(l, i) in transcript" :key="i">{{ l.role }}: {{ l.text }}</li></ul>
  <p v-if="status === 'idle' && lastCall">Last call ended ({{ lastCall.reason }})</p>
</template>

The options are read once; to switch keys, mount a new component. Outside a component, call it inside an effectScope() or call the returned dispose() yourself.

Angular

An injectable service that wraps the client and exposes signals. Events arrive from audio and socket callbacks, so they re-enter the zone.

import { Injectable, NgZone, OnDestroy, signal } from "@angular/core";
import Voxa, { type CallStatus, type VoxaErrorEvent } from "@voxa/web";

@Injectable({ providedIn: "root" })
export class VoxaService implements OnDestroy {
  private readonly voxa = new Voxa({ publicKey: "pk_...", baseUrl: "https://<your-console>" });
  private readonly offs: (() => void)[] = [];
  readonly status = signal<CallStatus>("idle");
  readonly transcript = signal<{ role: string; text: string }[]>([]);
  readonly error = signal<VoxaErrorEvent | null>(null);

  constructor(zone: NgZone) {
    this.offs.push(
      this.voxa.on("status", (e) => zone.run(() => this.status.set(e.status))),
      this.voxa.on("message", (m) => {
        if (m.type === "transcript") zone.run(() => this.transcript.update((t) => [...t, { role: m.role, text: m.text }]));
      }),
      this.voxa.on("error", (e) => zone.run(() => this.error.set(e))),
    );
  }

  /** Call from a (click) handler. */
  start(assistantId: string) {
    this.transcript.set([]);
    this.error.set(null);
    return this.voxa.start(assistantId).catch(() => null);
  }
  stop() { return this.voxa.stop(); }
  setMuted(muted: boolean) { this.voxa.setMuted(muted); }

  ngOnDestroy() {
    this.offs.forEach((off) => off());
    void this.voxa.stop();
  }
}

Plain JavaScript

<script src="https://<your-console>/sdk/voxa-web.js"></script>
<button id="call" type="button">Start call</button>
<ol id="lines"></ol>
<script>
  const voxa = new Voxa({ publicKey: "pk_..." });
  const btn = document.getElementById("call");

  voxa.on("status", ({ status }) => (btn.textContent = status === "idle" ? "Start call" : "End call"));
  voxa.on("message", (m) => {
    if (m.type !== "transcript") return;
    const li = document.createElement("li");
    li.textContent = (m.role === "assistant" ? "Assistant: " : "You: ") + m.text;
    document.getElementById("lines").appendChild(li);
  });
  voxa.on("error", (e) => alert(e.message));

  btn.addEventListener("click", () => {
    if (voxa.status === "idle") voxa.start("asst_...").catch(() => {});
    else voxa.stop();
  });
</script>

Widget attributes

The ready-made floating button (/voxa-widget.js, embed code on an assistant's Widget tab) is configured with data-* attributes on its script tag:

AttributeDescription
data-public-keyRequired. The organization's public key.
data-assistant-idThe Published assistant to call. Optional with a workflow or squad.
data-customer-idThe caller's id in your own system, for returning callers (1 to 128 characters of letters, digits and . _ : @ -). Used only with data-customer-signature. A malformed id stops the widget with a message instead of calling.
data-customer-signatureYour server's signature of data-customer-id (<expires>.<hex>, see returning callers). Without a valid one the id is ignored and the call runs without memory.
data-variablesA JSON object of {{name}} values, e.g. data-variables='{"name":"Ada"}' (single quotes around the attribute). Same limits as variableValues.
data-workflow-id, data-squad-idRun a workflow or a squad (one of them). The panel header shows the member talking after a handoff and the current workflow step.
data-video-agent-idCall a Published video agent instead of an assistant (embed code in the agent's Embed dialog on the Video agents page). Not with data-assistant-id, data-workflow-id or data-squad-id. The panel grows a video stage above the transcript with the avatar's name, a Sandbox chip and HeyGen's session limit when it has one; the controls become Mute (hidden for push-to-talk agents), Hold to talk (push-to-talk agents), Interrupt (Full agents, while the avatar speaks) and End. The widget loads this console's SDK bundle (/sdk/voxa-web.js) when it mounts and runs the call through startVideo(); audio-only embeds never load it. data-variables applies to Lite agents.
data-modechat opens the panel as a text chat with the assistant (see Text chat); Switch to voice starts a call in the same panel and Back to chat returns. Default voice. Not with data-video-agent-id.
data-video-elementWith a video agent: a CSS selector for a <video> element of your page to show the avatar in, instead of the panel's stage.
data-position, data-theme, data-labelbottom-right or bottom-left; light or dark; the panel title (and the button text unless data-button-label is set).

Branding

All optional. Each value is checked when the widget loads; an invalid one falls back to the default with a warning in the browser console. Values only reach the page as text, validated URLs or validated colors, never as HTML. The console's Widget tab (and a video agent's Embed dialog) saves these with the assistant or agent, shows a live preview and writes only the ones that differ from the defaults into the embed code.

AttributeValuesDefault
data-accentHex color #rgb or #rrggbb: the button, focus ring and primary buttons, in both themes. The text on it is white or near-black, whichever has the higher WCAG contrast.Blue (#0b57d0, lighter in the dark theme)
data-logoA 24 px image in the panel header: an https URL (at most 2048 characters) or a data:image/png, data:image/jpeg or data:image/svg+xml base64 URL of at most 50 kB. Loaded without a referrer; hidden if it fails to load. The console does not host uploads: paste a URL.None
data-welcomePlain text of at most 140 characters, shown in the panel before the call starts."Say hello to start the conversation."
data-button-iconmic, chat, phone, wave, or an https image URL (shown 20 px; the microphone if it fails to load).mic
data-button-labelButton text next to the icon, at most 24 characters.data-label
data-powered-byfalse hides the "Powered by Voxa" footer of the panel.true
data-previewtrue: the console's live preview. The panel opens next to the button and the widget never asks for a call token, the microphone, the SDK or a connection.Off
<script src="https://<your-console>/voxa-widget.js"
  data-assistant-id="asst_..."
  data-public-key="pk_..."
  data-label="Acme support"
  data-accent="#7c3aed"
  data-logo="https://acme.example/logo.svg"
  data-welcome="Hi! Ask about your order, returns or opening hours."
  data-button-icon="chat"
  data-button-label="Ask us"
  data-powered-by="false"></script>

See it on /widget-demo.html?assistant=<id>&key=<public key>&example=branded.

<script src="https://<your-console>/voxa-widget.js"
  data-public-key="pk_..."
  data-squad-id="sq_..."
  data-variables='{"name":"Ada","plan":"Pro"}'
  data-label="Talk to us"></script>

A video agent, with the avatar in the widget's panel (or in your own element with data-video-element):

<script src="https://<your-console>/voxa-widget.js"
  data-video-agent-id="vide_..."
  data-public-key="pk_..."
  data-theme="dark"
  data-label="Meet our avatar"></script>

<!-- or render the avatar into the page -->
<video id="avatar" autoplay playsinline style="width:100%;aspect-ratio:4/3;background:#131314"></video>
<script src="https://<your-console>/voxa-widget.js"
  data-video-agent-id="vide_..." data-public-key="pk_..." data-video-element="#avatar"></script>

Try either on /widget-demo.html?video=<video agent id>&key=<public key>. Refusals are shown to the visitor in plain words (site not allowed, agent not published or unavailable, no credits, video libraries blocked), with the console's own explanation in the browser console for the site owner.

After the call

The browser learns the callId (from start(), call-start or call-end) and, when recording is on, the recordingUrl, the REST endpoint <baseUrl>/api/v1/calls/<callId>/recording. The token response does not name the organization and callers have no console session, so the SDK reports the API link: send the callId to your backend and fetch the recording there with a secret key (curl -L; with bucket storage it answers a 302 to a signed URL valid for at least 15 minutes). Summaries, analysis and the full transcript are produced on the server after the call; to receive them on your own backend, subscribe to webhooks: see Webhooks in the API reference. Never call the secret-key API from the browser.

Security

  • The public key is public. It only lets a browser ask for call tokens; it cannot read or change anything. Never put a secret key (sk_live_…) in browser code.
  • Restrict where it works. Under Organization settings, list your sites in Allowed origins: token requests from any other site are refused (403). Without a list, any site can use the key.
  • Tokens are short-lived. Each call gets its own token, valid for a few minutes and for one assistant. Token issuance is rate-limited per key and per address (429).
  • Only Published assistants can be started by id with a public key. Inline assistants are created inside your organization and billed to it like any call, so they are off until an admin turns on Allow inline SDK assistants (API keys page); keep Allowed origins set when you use them. Whoever holds the public key can then write the prompt and choose the arguments, so allow only the tools, knowledge bases and models that are safe for any visitor.
  • Calls are metered against the organization's credits and monthly limit (402 when exhausted).

Troubleshooting

"The microphone is only available on HTTPS pages"

Browsers only expose the microphone on https:// pages and on http://localhost. An https page also needs a wss:// voice server address (mixed content is blocked).

No sound on iPhone or iPad

Call start() directly in the tap handler. If it ran after an await, audio resumes on the next tap anywhere on the page.

402: no credits or monthly limit

Show error.message as is; it explains what to do. Add credits or raise the limit on the console's Billing page.

403: site not allowed or assistant not published

Check error.reason: for origin_not_allowed add the page's exact origin (scheme, host and port) to Allowed origins; for not_published publish the assistant.

403: inline assistants are turned off

An inline configuration needs Allow inline SDK assistants on the API keys page (admins only). Without it, start a Published assistant by id.

"Could not start the audio processor"

A Content-Security-Policy may block blob: scripts. Allow blob: in script-src / worker-src, or host pcm-worklet.js on your origin and pass workletUrl.

The call never connects

The voice server address the console hands out must be reachable from your visitors' browsers. Check /health on the voice server.

Video: "Could not load livekit-client" or "...liveavatar-web-sdk"

The package is not installed next to the SDK and the CDN build could not be fetched (offline, a Content-Security-Policy without cdn.jsdelivr.net in script-src, or a file that no longer matches its integrity hash). Install the packages in your app, load them on the page yourself (window.LivekitClient / window.LiveAvatarSDK), or host the ES modules yourself and pass modules: { livekit, heygen }.

Video: the avatar never appears, the call runs as audio

Listen to video-error: it says why (HeyGen refused the session, the plan's session limit, the room could not be joined). A Lite call keeps going with the assistant's voice. 404 on startVideo() means the agent is a draft or unknown; 409 (not_available) that video agents are off for the organization.