Widget docs

AmphiVox Widget — React

Vanilla JSReact

@amphivox/react adds a AmphiVox voice agent to any React app. It injects a single floating-mic iframe and bridges it to your components through props and callbacks — zero runtime dependencies.

Plain HTML, or not using React? See the Vanilla JS guide for the widget.js script tag.

Regional availability. The widget works anywhere in the world — no region is blocked. Voice is just tuned for India right now, so respondents further afield may notice slower responses or occasional drops during a session. Coverage is actively being expanded.

Install

npm install @amphivox/react

Peer dependencies: react >= 16.8 and react-dom >= 16.8. Ships ESM, CJS, and TypeScript types.

Your formId, widgetKey, and every field id come from the form's Developers page in the AmphiVox dashboard — open a form and choose Developers (also on the dashboard card menu). The widget key also controls which domains may embed the widget — add your app's origin to its allowed-domains list, or connections are rejected.

Serve your app over HTTPS. Microphone access requires a secure context. http://localhost is exempt, so local development works, but any other http:// origin will fail to get the mic.

Quick start

Drop <VoiceWidget> anywhere in your tree. It renders nothing visible itself — the floating mic lives in the injected iframe — and calls your handlers as the conversation progresses. The simplest setup: map each answer to its useState setter by field id.

import { useState } from 'react'
import { VoiceWidget } from '@amphivox/react'

function ContactForm() {
  const [name, setName] = useState('')
  const [email, setEmail] = useState('')

  return (
    <form>
      <input value={name} onChange={(e) => setName(e.target.value)} />
      <input value={email} onChange={(e) => setEmail(e.target.value)} />
      <VoiceWidget
        formId="YOUR_FORM_ID"
        widgetKey="YOUR_WIDGET_KEY"
        onField={(field, value) => {
          if (field === 'full_name') setName(String(value))
          if (field === 'email_address') setEmail(String(value))
        }}
        onComplete={() => {/* submit your form */}}
      />
    </form>
  )
}

field in onField is the AmphiVox field id (find them on the Developers page). value is typed unknown — narrow it in your handler. It is a string for most fields, an array for multi-select, and an object for phone and address (see Field value shapes).

Why no DOM auto-fill? In React the DOM is a projection of state, so writing to inputs directly fights the framework. onFieldsetState is the idiomatic path. (The Vanilla JS SDK does auto-fill the DOM — that's the right tool for non-React pages.)

Many fields — map once

Switching on each field id by hand gets unwieldy past a handful of inputs. For dynamic or longer forms, keep a single state object and a FIELD_MAP from AmphiVox field ids to your state keys, then write through it with one setState:

import { useState } from 'react'
import { VoiceWidget } from '@amphivox/react'

// AmphiVox field id → your form's state key
const FIELD_MAP: Record<string, string> = {
  full_name: 'name',
  email_address: 'email',
  phone_number: 'phone',
}

function ContactForm() {
  const [form, setForm] = useState({ name: '', email: '', phone: '' })

  const update = (key: string, value: string) =>
    setForm((prev) => ({ ...prev, [key]: value }))

  return (
    <form>
      {Object.entries(form).map(([key, value]) => (
        <input key={key} value={value} onChange={(e) => update(key, e.target.value)} />
      ))}
      <VoiceWidget
        formId="YOUR_FORM_ID"
        widgetKey="YOUR_WIDGET_KEY"
        onField={(field, value) => update(FIELD_MAP[field] ?? field, String(value))}
        onComplete={() => {/* submit your form */}}
      />
    </form>
  )
}

FIELD_MAP[field] ?? field falls back to the raw field id when there's no mapping, so an unmapped answer still lands in state under its AmphiVox id. Drop the map entirely if your state keys already match the field ids.

The String(value) above is fine for text fields but will render [object Object] for a phone or address answer. If your form has either, narrow first — see below.

Field value shapes

value is typed unknown because it isn't always a string. Handle these three cases:

Field typevalue
Most fieldsstring
Multi-selectstring[]
Phone{ e164: string, country: string, national: string }
Addressobject with one key per component (street, city, zip, country, …)

Phone gives you three forms of the same number so you can fill either one input or a country-plus-number pair. e164 ('+919876543210') is canonical and unambiguous — prefer it unless you specifically need the parts. country is ISO 3166-1 alpha-2; national is the local digits.

onField={(field, value) => {
  if (field === 'phone_number' && value && typeof value === 'object') {
    const phone = value as { e164: string; country: string; national: string }
    setPhone(phone.e164)
    setCountry(phone.country)
    return
  }
  update(FIELD_MAP[field] ?? field, String(value))
}}

Address arrives as a single object once the whole address is collected — partial addresses are never emitted, so you get it all at once or not at all. Spread its components into your own fields rather than stringifying it.

Skip what you already know — prefillData

If the respondent has already typed their name into your own form, the agent shouldn't ask for it again. Pass what you have and it starts the conversation with those fields already filled in:

import { useMemo, useState } from 'react'
import { VoiceWidget } from '@amphivox/react'

function ContactForm() {
  const [form, setForm] = useState({ name: 'Asha Rao', email: '', updates: false })

  // Keys are AmphiVox field IDs — the ids on your form's Developers page, not
  // your own state keys. This is FIELD_MAP in reverse.
  const prefillData = useMemo(() => ({
    full_name: form.name,
    email_address: form.email,
    receive_updates: form.updates,
  }), [form.name, form.email, form.updates])

  return (
    <VoiceWidget
      formId="YOUR_FORM_ID"
      widgetKey="YOUR_WIDGET_KEY"
      prefillData={prefillData}
      onField={(field, value) => {
        if (field === 'full_name') setForm(p => ({ ...p, name: String(value) }))
      }}
    />
  )
}

When it's read. At the start of the next session — whether the respondent taps the built-in dock or you call open(). Changing it during a live conversation doesn't rewrite that conversation; the new snapshot applies to the following one.

What counts as empty. null, undefined, '' and [] are dropped. false and 0 are kept — they're real answers to a checkbox or a number field.

Value shapes match the table above, with one exception: send phone as an E.164 string ('+919876543210'), even though onField reports phone as an object.

Clearing. Remove a key, or pass null / {}, and it's gone from the next session. Omit the prop entirely and nothing changes from before.

Keep the object stable. useMemo as above. A fresh object every render is compared before anything is sent, so it costs at most a wasted comparison — but stability is cheaper.

Size limits. A payload over 12 KB or 256 keys, or one with a malformed shape, is rejected before a session is built: the connect fails and onError fires. The server's specific reason (initial_state_invalid / initial_state_too_large) is in the failed request's response body in the network tab, so debug that one from the network panel.

This is sent to AmphiVox. Prefill becomes part of the agent's context so it can hold a natural conversation about it. It is not local to your page. Fields marked PII on your form, and file / url / legal fields, are excluded server-side and never reach the model — so prefilling them has no effect.

Prefill troubleshooting

SymptomLikely cause
The agent still asks for a prefilled valueKey isn't an AmphiVox field ID; the value was empty or failed validation; the field is PII/manual; or the value arrived after the session had already started
Phone prefill ignoredSend an E.164 string, not the { e164, country, national } object onField gives you
A callback fired for a value I suppliedExpected — see the note on onField in Props
The session won't connect at all once prefill is setThe payload was rejected — check the failed request's response body for initial_state_invalid / initial_state_too_large

Drive it yourself — useVoiceWidget

When you want your own Start button, mute toggle, or language switcher — not just to react to events — call the hook directly. It takes the same options as the component and returns imperative controls:

import { useVoiceWidget } from '@amphivox/react'

function VoiceToolbar() {
  const { open, close, mute, unmute, setLanguage, setVoiceGender } = useVoiceWidget({
    formId: 'YOUR_FORM_ID',
    widgetKey: 'YOUR_WIDGET_KEY',
    onField: (field, value) => {/* update your state */},
    onComplete: handleSubmit,
  })

  return (
    <div>
      <button onClick={open}>Start voice</button>
      <button onClick={close}>Stop</button>
      <button onClick={mute}>Mute</button>
      <button onClick={() => setLanguage('hi')}>हिन्दी</button>
    </div>
  )
}

<VoiceWidget> is literally this hook wrapped to render null. Reach for the component for fire-and-forget; reach for the hook when your UI drives the session.

Bring your own UI

Pass hidden to take the built-in dock off the screen entirely and render your own interface. The session keeps running — you drive it with the controls and paint it from the callbacks:

function CustomVoiceUI() {
  const [live, setLive] = useState(false)
  const [botTalking, setBotTalking] = useState(false)
  const [thinking, setThinking] = useState(false)

  const { open, close, mute, unmute } = useVoiceWidget({
    formId: 'YOUR_FORM_ID',
    widgetKey: 'YOUR_WIDGET_KEY',
    hidden: true,                                   // ← no built-in dock
    onReady:    () => setLive(true),
    onThinking: setThinking,
    onSpeaking: (who, active) => who === 'bot' && setBotTalking(active),
    onComplete: () => setLive(false),
    onField:    (field, value) => {/* your state */},
  })

  return (
    <MyMicButton
      live={live}
      pulsing={botTalking}
      spinner={thinking}
      onClick={() => (live ? close() : open())}
    />
  )
}

Everything you need for a convincing custom UI comes through the callbacks: onReady / onComplete / onTimeout / onError for session state, onSpeaking('user' | 'bot', active) for who has the floor, onThinking for the processing beat, onMuteChange for mic state, and onTranscript for live captions.

What you still don't get: raw audio. onSpeaking is a boolean, not an amplitude stream, so you can animate that someone is speaking but not a true waveform driven by volume. The microphone permission prompt also comes from the AmphiVox origin, not yours.

Why hidden instead of your own CSS? The iframe must stay rendered to keep the session alive — it sends a keepalive roughly once a second, and browsers throttle timers in cross-origin frames that aren't being painted. A throttled keepalive looks like a dead client and the session gets closed mid-conversation. hidden parks the frame as a 1×1 transparent element instead of using display: none, which is exactly the trap to avoid. Toggle it freely — it never restarts a live session.

Props

The same options apply to both <VoiceWidget> and useVoiceWidget.

PropDefaultDescription
formIdRequired.
widgetKeyRequired.
originhttps://amphivox.comOverride for local dev or self-hosted deployments.
position'bottom-right'Or 'bottom-left'.
offsetX / offsetY0Pixels from the anchored edge / bottom.
language'en'Initial language (see Languages).
voiceGender'female'Initial agent voice — 'female' or 'male' (see Voice gender).
showTranscripttrueShow the transcript panel (read once at mount).
hiddenfalseHide the built-in dock and drive the session from your own UI (see Bring your own UI).
prefillDatanullValues you already have, keyed by AmphiVox field ID, so the agent skips them (see Skip what you already know).
onReadySession is live.
onField(field, value) for each answer collected. Values you supplied via prefillData are reported back too, and a field can be reported more than once — treat it as "current value", not "changed once".
onCompleteSession completed successfully.
onTimeoutIdle timeout (see Session lifecycle).
onError(error) — a stable code string, see Error codes.
onTranscript(speaker, text) — speaker is 'user' or 'bot'.
onThinking(active) — agent processing.
onSpeaking(who, active) — who is 'user' or 'bot'.
onMuteChange(muted).

Imperative controls returned by useVoiceWidget: open(), close(), mute(), unmute(), setLanguage(code), setVoiceGender('female' | 'male').

Session lifecycle and idle timeout

A session has exactly four endings, and each one is a different callback — or, in one case, no callback at all:

EndingWhat happenedYou get
CompleteThe agent collected everything and said goodbye.onComplete()
Idle timeoutThe respondent went quiet and stayed quiet.onTimeout()
FailureConnect or mid-session error.onError(code)
ManualYou called close(), or the respondent ended it from the dock.nothing — you already know

How the idle timeout works

The agent watches for silence only when the floor is the respondent's — the clock does not run while the agent is speaking or while it is processing an answer, so a long agent turn can never be mistaken for an idle visitor.

  1. ~10 seconds of silence after the agent finishes speaking → the agent nudges: it briefly acknowledges the pause and re-asks the last unanswered question out loud. This is a normal part of the conversation, not an error, and nothing is reported to your app.
  2. Another ~10 seconds of silence → the session ends. onTimeout() fires.

So a respondent who walks away loses the session after roughly 20 seconds of continuous silence, having been prompted once. Those are the current server defaults (a 10-second silence window, one nudge before ending) — treat them as approximate rather than a contract, and never build a UI that depends on the exact number.

Two things that surprise people:

  • Muting is not pausing. A muted mic is indistinguishable from a silent respondent, so the idle clock keeps running and a long mute will time the session out.
  • There is a hard ceiling too. Any single session is capped at 10 minutes of wall-clock time regardless of activity. That ceiling tears the session down without a timeout notice, so it surfaces as onError('session_ended_unexpectedly') rather than onTimeout().

Separately, the iframe sends a keepalive roughly once a second, and the server closes a session it hasn't heard from for ~18 seconds. This is why the frame must stay rendered — use the hidden prop rather than display: none, and don't unmount the component to "pause" a call.

After it ends

The widget stays mounted in every case. The next open() — or the respondent tapping the dock — starts a fresh session, re-reading whatever prefill, language and voice you last set. State you built from onField is yours and is unaffected.

Error codes

onError(error) always receives a short, stable string code — never raw provider text and never anything you should show a respondent verbatim. Map each code to your own copy, and keep a default branch, because the list can grow.

Connect-time — the session never started. Nothing was collected, and retrying is usually reasonable.

CodeWhat happenedSuggested handling
session_busyVoice capacity is momentarily saturated; no session could be built.Ask the respondent to try again in a moment. Safe to retry.
session_limit_exceededToo many sessions are already live for this deployment.Same as above — retry shortly.
session_spawn_failedThe voice process could not be started or did not finish in time.Retry once; if it repeats, fall back to the typed form.
connect_timeoutThe connection did not complete within 25 seconds.Retry. Often a slow or restricted network.
token_fetch_failedVoice access could not be minted for this form — usually a bad/revoked widget key, an origin missing from the key's allowed-domains list, or the form not accepting responses.Not retryable by the respondent. Check the Developers page.
webrtc_unavailableThe browser cannot open a WebRTC session at all.Hide the mic affordance and let them type.
connect_failedGeneric connect failure that matched nothing more specific — a rejected prefill payload lands here too.Retry, then fall back. Check the failed request in the network tab for the server's reason.

Mid-session — the conversation had started. Anything already delivered through onField is still valid and worth keeping in state.

CodeWhat happenedSuggested handling
voice_service_busyAn upstream voice provider rate-limited us (429 / quota).Keep collected answers; offer to restart in a moment.
voice_service_unavailableAn upstream provider was unreachable or erroring (5xx).Same — keep answers, offer a retry.
voice_service_errorAn unclassified pipeline failure.Keep answers, offer a retry.
session_ended_unexpectedlyThe session dropped without a specific cause — network loss, the tab being backgrounded long enough to throttle the keepalive, or the 10-minute session ceiling.Keep answers; a retry usually works.

Design notes worth knowing. Only the first error of a session is reported — a specific failure is never overwritten by the generic disconnect that follows it. Transient hiccups that the pipeline retries successfully are not reported at all, so an onError means the session is over. And a code you don't recognise is possible: treat any unknown string as a generic failure rather than assuming it is one of the above.

const ERROR_COPY: Record<string, string> = {
  session_busy: 'Voice is warming up — try again in a moment.',
  session_limit_exceeded: 'Voice is busy right now. Please try again shortly.',
  connect_timeout: 'That took too long to connect. Please try again.',
  webrtc_unavailable: 'This browser can’t run voice — please type your answers.',
}

onError={(code) => setBanner(ERROR_COPY[code] ?? 'Voice stopped unexpectedly. Please try again.')}

Languages

AmphiVox speaks 45 languages across two voice families (full list below — pull the live catalog anytime from GET /api/voice/languages if you'd rather not hardcode it). Pass the code; everything behind it is handled for you.

Worldwide — recognition listens for the selected language plus English fallback, so a visitor can code-switch to English mid-sentence and the voice follows:

CodeLanguageCodeLanguage
enEnglishltLietuvių
esEspañolnoNorsk
frFrançaisplPolski
deDeutschptPortuguês
itItalianoroRomână
nlNederlandsruРусский
ja日本語skSlovenčina
arالعربيةslSlovenščina
bgБългарскиsvSvenska
hrHrvatskithไทย
csČeštinatrTürkçe
daDanskukУкраїнська
etEestiviTiếng Việt
fiSuomizh中文(简体)
elΕλληνικάzh-TW中文(繁體)
huMagyaridBahasa Indonesia
ko한국어lvLatviešu

Bharat — Indian languages, with automatic language detection:

CodeLanguage
en-INEnglish (India) — tuned for Indian accents and names; distinct from en
hiहिन्दी
taதமிழ்
teతెలుగు
knಕನ್ನಡ
mlമലയാളം
bnবাংলা
mrमराठी
guગુજરાતી
paਪੰਜਾਬੀ
odଓଡ଼ିଆ

There are three ways a language gets chosen:

  1. Initial — the language prop seeds the session: <VoiceWidget language="hi" … />. It's bound when the session starts.
  2. ProgrammaticsetLanguage('hi') from the hook sets the language for the next session. It does not retune a call already in progress.
  3. In-dock picker — the floating dock includes a language selector listing every supported language, grouped by voice family with a search box, so visitors can pick their own without any code from you.
<VoiceWidget formId="YOUR_FORM_ID" widgetKey="YOUR_WIDGET_KEY" language="hi" />

Voice gender

The agent speaks with a female voice by default, or a male one. It works exactly like language — you seed it, the visitor can still change it:

<VoiceWidget
  formId="YOUR_FORM_ID"
  widgetKey="YOUR_WIDGET_KEY"
  voiceGender="male"   // 'female' (default) | 'male'
/>

setVoiceGender('female') from the hook changes it for the next session, like setLanguage. Changing the voiceGender prop on a live session never restarts it — it applies to the next one.

The visitor can override your choice: the dock header shows a small avatar icon next to the language pill, and clicking it before the session starts swaps the voice. Once a session is live the choice locks — the avatar becomes a non-interactive presence indicator (it glows while the agent talks) instead of a button. There's no callback for their selection.

The exported VoiceGender type ('female' | 'male') is available if you keep the value in your own state:

import type { VoiceGender } from '@amphivox/react'

Next.js / SSR

All DOM work happens inside effects, so the package never runs on the server. In the App Router, add the client directive to the file that imports it:

'use client'
import { VoiceWidget } from '@amphivox/react'

The component handles React 18 StrictMode, prop changes, and unmount cleanup without tearing down an active call — only formId, widgetKey, or origin changes re-inject the iframe. Changing language or voiceGender never restarts a live session; it applies to the next one. (showTranscript is read once when the iframe is injected and changing it later has no effect at all.)

Unmounting removes the iframe and detaches every listener, so there is nothing to clean up by hand.

Multiple widgets

Every message from the iframe carries its formId, and each hook ignores any message that did not come from its own iframe — so multiple <VoiceWidget>s on one page each receive only their own events. This holds even when two of them run the same form, which used to cross-deliver (fixed in 0.3.0). (The Vanilla JS SDK is a singleton and cannot do this.)

Bear in mind each instance injects its own floating dock, and both anchor to the same corner unless you separate them with position / offsetX / offsetY.

Content Security Policy

If your app sends a Content-Security-Policy header, the widget needs one directive:

Content-Security-Policy: frame-src https://amphivox.com;

That lets the widget's iframe load. You do not need script-src for AmphiVox (the package is bundled into your own JavaScript), and you do not need connect-src or media-src — the package opens no connections of its own. It injects an iframe and talks to it over postMessage; every network call the voice session makes happens inside that iframe, a separate browsing context governed by AmphiVox's CSP rather than yours. The realtime audio runs over WebRTC, which CSP does not govern at all.

If you pass a custom origin, substitute it in the directive.

Local development

When testing against a local AmphiVox dev server, point origin at it and make sure your dev origin is on the widget key's allowed-domains list:

<VoiceWidget
  formId="YOUR_FORM_ID"
  widgetKey="YOUR_WIDGET_KEY"
  origin="http://localhost:5173"
/>

Troubleshooting

Nothing connects in production. origin defaults to https://amphivox.com. If you self-host or run locally, pass origin explicitly.

403 on connect. The page's origin must be on the widget key's allowed-domains list (Developers page). An empty list blocks all origins. In your callbacks this arrives as token_fetch_failed.

403 even though your domain is on the allowlist. Check whether the page embedding the widget sends Referrer-Policy: no-referrer or Referrer-Policy: same-origin — as an HTTP header or a <meta> tag. Both hide your page's origin from documents it frames, so the widget cannot tell us which site it is running on, and the origin check rejects it. You would see the widget key and domain both look correct. Fix it by using a less restrictive policy on the page that carries the widget — the browser default strict-origin-when-cross-origin works, as do origin, strict-origin and no-referrer-when-downgrade. This is about your page's policy; the AmphiVox origin's own headers are not involved. (Why: an embedder can opt out of revealing itself to what it frames, and the widget relies on exactly that signal to prove which domain it is embedded on.)

The session ended while the respondent was still reading. That's the idle timeout — roughly 20 seconds of silence, with one spoken nudge in between. Muting the mic does not pause it.

onTimeout never fires, I get session_ended_unexpectedly instead. Either the session hit the 10-minute ceiling, or the keepalive was throttled — most often because the frame was hidden with CSS, the component was unmounted, or the tab was backgrounded for a long stretch.

setLanguage / setVoiceGender did nothing mid-call. By design — both apply to the next session.

An input shows [object Object]. You passed a phone or address answer through String(value). See Field value shapes.

Mic blocked. Check that your app is on HTTPS (or localhost), and that your server does not send Permissions-Policy: microphone=() — it overrides the iframe's permission and silently blocks the mic.

Two widgets, one set of events. Fixed in 0.3.0 — each hook now also checks the message came from its own iframe, so two widgets on the same formId no longer receive each other's events. On 0.2.0 and earlier they did; upgrade rather than working around it.

I want a fully custom mic UI. Pass hidden and render your own — see Bring your own UI. Do not hide the iframe with your own display: none; it throttles the keepalive and the session gets dropped.

Session dies after ~10s when I hide the widget myself. That's the keepalive being throttled in a non-rendered cross-origin frame. Use the hidden prop instead of your own CSS.