Quick start — drop-in
Paste these two tags into your page, just before </body>. Point target at your form, map AmphiVox field ids to your inputs, and let the widget do the rest:
<script src="https://amphivox.com/widget.js" crossorigin></script>
<script>
VoiceForm.init({
formId: 'YOUR_FORM_ID',
widgetKey: 'YOUR_WIDGET_KEY',
target: '#contact-form',
autoSubmit: true,
fieldMap: { full_name: 'name', email_address: 'email' },
});
</script>
That's the whole setup: the widget injects a floating mic button, the visitor speaks, each answer is written into the matching input in #contact-form, and the form submits itself when the conversation completes.
Two separate tags is required. A <script> with a src ignores any inline code between its tags, so the loader and your init() call must be separate tags. Keep crossorigin on the first tag, and do not add type="module" to either — module scripts are deferred and VoiceForm would be undefined when init() runs.
Serve your page 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.
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). It generates a ready-to-paste snippet with your real field ids already filled in. The widget key also controls which domains are allowed to embed it.
Field mapping
Inside target, the widget looks up each field in this order: the id you give in fieldMap, then a matching id attribute, then a matching name attribute.
fieldMap: {
full_name: 'name', // AmphiVox field "full_name" → <input id="name">
email_address: 'email', // AmphiVox field "email_address" → <input id="email">
}
If your input ids already match the AmphiVox field ids, you can omit fieldMap entirely. The widget handles text inputs, textareas, checkboxes, radios, and single/multi <select> — and it dispatches input/change events so React, Vue, and Angular controlled inputs update correctly.
Phone fields
Every field arrives as a string except phone, which arrives as a triple so you can fill a country selector and a number input separately:
{ e164: '+919876543210', country: 'IN', national: '9876543210' }
e164— the canonical, unambiguous form. Use this unless you have a reason not to.country— ISO 3166-1 alpha-2 (IN,US), for a country/flag control.national— local digits, no country code.
Map it to one input and you get the E.164 string; map it to two controls with an object entry:
fieldMap: {
// one input → gets '+919876543210'
phone_number: 'phone',
// or two controls → country gets 'IN', number gets '9876543210'
phone_number: { country: 'country_select', number: 'phone_input' },
}
In headless mode, narrow it yourself: typeof value === 'object' ? value.e164 : value.
Address fields
Address fields are not auto-filled yet. An address arrives as one object of its components (street, city, zip, country, …), and drop-in mode has no splitting for it — mapping an address to a single input writes a literal [object Object]. Until this ships, omit address fields from fieldMap and handle them in onField, where you get the object and can write each component into your own inputs. The generated snippet lists per-component entries (home_address.street, …) for forward compatibility; they have no effect today.
Prefill
If your page already knows the respondent's name, the agent shouldn't ask for it. Hand it over and the session starts with those fields filled in:
VoiceForm.init({
formId: 'YOUR_FORM_ID',
widgetKey: 'YOUR_WIDGET_KEY',
target: '#contact-form',
// Keys are AmphiVox field ids — the same namespace fieldMap maps FROM,
// not your input ids.
prefillData: {
full_name: 'Asha Rao',
receive_updates: false,
},
});
Update it later — after a login resolves, or as the visitor types:
document.querySelector('#name').addEventListener('change', function (e) {
VoiceForm.setPrefill({ full_name: e.target.value });
});
setPrefill replaces the whole snapshot rather than merging, so send everything you want the agent to know each time. Pass null or {} to clear it.
When it's read. At the start of the next session, whether the respondent taps the dock or you call open(). Calling it mid-session doesn't rewrite the conversation in progress.
What counts as empty. null, '' and [] are dropped; false and 0 are kept, being real answers.
Value shapes match what onField gives you, with one exception: send phone as an E.164 string ('+919876543210'), not the object.
If it's rejected. A malformed or oversized prefill payload is refused before a session is built — the connect fails and onError fires. The server's own reason (initial_state_invalid for a bad shape, initial_state_too_large past 12 KB or 256 keys) is in the failed request's response body in the network tab; the callback itself may only report a generic connect failure, so debug this one from the network panel.
This is sent to AmphiVox. Prefill becomes part of the agent's context so it can talk about it naturally — 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 does nothing.
Custom submit (fetch / SPA)
autoSubmit calls the form's native submit(). If you submit with fetch or manage submission in JavaScript, use onComplete instead — it overrides autoSubmit:
VoiceForm.init({
formId: 'YOUR_FORM_ID',
widgetKey: 'YOUR_WIDGET_KEY',
target: '#contact-form',
onComplete: () => {
fetch('/api/save', { method: 'POST', body: collectFields() });
},
});
Headless — own every write
Don't want the widget touching your DOM at all? Omit target and wire callbacks instead — you decide what happens with each answer. The mic button still appears; nothing gets written or submitted unless you do it.
VoiceForm.init({
formId: 'YOUR_FORM_ID',
widgetKey: 'YOUR_WIDGET_KEY',
onReady: () => {}, // session is live
onField: (field, value) => {}, // each answer collected
onComplete: () => {}, // session finished
onTimeout: () => {}, // idle timeout
onError: (error) => {},
onTranscript: (speaker, text) => {}, // speaker is 'user' or 'bot'
onThinking: (active) => {}, // agent processing
onSpeaking: (who, active) => {}, // who is 'user' or 'bot'
onMuteChange: (muted) => {},
});
field in onField is the AmphiVox field id — switch on it to update your own state. value is a string for most fields, an array for multi-select, an object for phone ({ e164, country, national }) and for address (one key per component). Narrow before writing it into an input, or you'll render [object Object].
Headless still shows our mic button. Omitting target means you own every DOM write and submission — the floating dock is still injected. To replace the voice UI as well, add hidden (below).
Bring your own UI
Pass hidden: true to take the built-in dock off the screen and render your own interface. The session keeps running — drive it with the control API and paint it from the callbacks:
VoiceForm.init({
formId: 'YOUR_FORM_ID',
widgetKey: 'YOUR_WIDGET_KEY',
hidden: true, // ← no built-in dock
onReady: () => setLive(true),
onThinking: (active) => setSpinner(active),
onSpeaking: (who, active) => { if (who === 'bot') pulse(active); },
onComplete: () => setLive(false),
});
myButton.addEventListener('click', () => VoiceForm.open());
VoiceForm.hide() and VoiceForm.show() toggle it at runtime without disturbing a live session.
Everything you need for a convincing custom UI comes through the callbacks: onReady / onComplete / onTimeout / onError for session state, onSpeaking 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.
Controls
init() returns the VoiceForm object; every control below is also available on window.VoiceForm:
| Call | Effect |
|---|---|
VoiceForm.open() | Start the session programmatically. |
VoiceForm.close() | End the session. |
VoiceForm.mute() / .unmute() | Toggle the microphone. |
VoiceForm.setLanguage('hi') | Set language for the next session (no mid-session effect). |
VoiceForm.setVoiceGender('male') | Set the agent's voice for the next session (no mid-session effect). |
VoiceForm.setPrefill({ … }) | Give the agent values you already have, so it skips those fields on the next session. Pass null to clear. See Prefill. |
VoiceForm.hide() / .show() | Hide or restore the built-in dock without touching the session. |
VoiceForm.destroy() | Remove the widget and its listeners entirely. |
window.VoiceForm is a singleton — one widget per page. Calling init() again replaces the configuration but does not give you a second independent widget; call destroy() before re-initialising. In a single-page app, call destroy() when the view that owns the widget unmounts, or the iframe and its listeners outlive the route.
Options
| Option | Default | Description |
|---|---|---|
formId | — | Required. The form to run. |
widgetKey | — | Required. Your widget key from the Developers page. |
target | — | CSS selector of the form to auto-fill (drop-in mode). |
fieldMap | {} | Maps each AmphiVox field id to your input's id. Omit if they already match. |
autoSubmit | false | Submit target automatically when the session completes. |
position | 'bottom-right' | Or 'bottom-left'. |
language | 'en' | Initial conversation language (see Languages). |
voiceGender | 'female' | Initial agent voice — 'female' or 'male' (see Voice gender). |
showTranscript | true | Show the live transcript panel inside the dock. |
hidden | false | Hide the built-in dock and drive the session from your own UI (see Bring your own UI). |
prefillData | null | Values you already have, keyed by AmphiVox field id, so the agent skips them (see Prefill). |
origin | script's origin | Advanced — override where the widget is served from (local dev / self-hosted). |
Callbacks
All are optional. Pass any of them to init().
| Callback | Fires when |
|---|---|
onReady() | The session is live and the agent has greeted. |
onField(field, value) | Each answer is collected. See value shapes. |
onComplete() | The conversation finished successfully. Overrides autoSubmit. |
onTimeout() | The session ended from inactivity. See idle timeout. |
onError(error) | The session failed. error is a short code string — see Error codes. |
onTranscript(speaker, text) | Each turn of speech — speaker is 'user' or 'bot'. |
onThinking(active) | The agent starts (true) / stops (false) processing. |
onSpeaking(who, active) | who ('user' | 'bot') starts / stops speaking. |
onMuteChange(muted) | The microphone is muted or unmuted, including from inside the dock. |
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:
| Ending | What happened | You get |
|---|---|---|
| Complete | The agent collected everything and said goodbye. | onComplete() (or autoSubmit) |
| Idle timeout | The respondent went quiet and stayed quiet. | onTimeout() |
| Failure | Connect or mid-session error. | onError(code) |
| Manual | You 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.
- ~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 page.
- Another ~10 seconds of silence → the session ends. The widget posts a timeout to your page and
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 thanonTimeout().
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 — see the note in Bring your own UI.
After it ends
The widget stays on the page 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. Answers already delivered through onField are yours and are unaffected.
Error codes
onError(error) always receives a short, stable string code — never raw provider text and never anything you should show a visitor verbatim. Write your own copy per code; keep an else branch, because the list can grow.
Connect-time — the session never started. Nothing was collected, and retrying is usually reasonable.
| Code | What happened | Suggested handling |
|---|---|---|
session_busy | Voice capacity is momentarily saturated; no session could be built. | Ask the visitor to try again in a moment. Safe to retry. |
session_limit_exceeded | Too many sessions are already live for this deployment. | Same as above — retry shortly. |
session_spawn_failed | The voice process could not be started or did not finish in time. | Retry once; if it repeats, fall back to the typed form. |
connect_timeout | The connection did not complete within 25 seconds. | Retry. Often a slow or restricted network. |
token_fetch_failed | Voice 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 visitor. Check the Developers page. |
webrtc_unavailable | The browser cannot open a WebRTC session at all. | Hide the mic and let the visitor type. |
connect_failed | Generic 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.
| Code | What happened | Suggested handling |
|---|---|---|
voice_service_busy | An upstream voice provider rate-limited us (429 / quota). | Keep collected answers; offer to restart in a moment. |
voice_service_unavailable | An upstream provider was unreachable or erroring (5xx). | Same — keep answers, offer a retry. |
voice_service_error | An unclassified pipeline failure. | Keep answers, offer a retry. |
session_ended_unexpectedly | The 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.
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:
| Code | Language | Code | Language |
|---|---|---|---|
en | English | lt | Lietuvių |
es | Español | no | Norsk |
fr | Français | pl | Polski |
de | Deutsch | pt | Português |
it | Italiano | ro | Română |
nl | Nederlands | ru | Русский |
ja | 日本語 | sk | Slovenčina |
ar | العربية | sl | Slovenščina |
bg | Български | sv | Svenska |
hr | Hrvatski | th | ไทย |
cs | Čeština | tr | Türkçe |
da | Dansk | uk | Українська |
et | Eesti | vi | Tiếng Việt |
fi | Suomi | zh | 中文(简体) |
el | Ελληνικά | zh-TW | 中文(繁體) |
hu | Magyar | id | Bahasa Indonesia |
ko | 한국어 | lv | Latviešu |
Bharat — Indian languages, with automatic language detection:
| Code | Language |
|---|---|
en-IN | English (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:
- Initial — the
languageoption seeds the session:VoiceForm.init({ …, language: 'hi' }). It's bound when the session starts. - Programmatic —
VoiceForm.setLanguage('hi')sets the language for the next session. It does not retune a call already in progress. - 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.
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:
VoiceForm.init({
formId: 'YOUR_FORM_ID',
widgetKey: 'YOUR_WIDGET_KEY',
voiceGender: 'male', // 'female' (default) | 'male'
});
VoiceForm.setVoiceGender('female') changes it for the next session, like setLanguage. Anything other than 'female' or 'male' falls back to 'female'.
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.
Content Security Policy
If your page sends a Content-Security-Policy header, two directives are all the widget needs:
Content-Security-Policy:
frame-src https://amphivox.com;
script-src 'self' https://amphivox.com;
frame-src lets the widget's iframe load; script-src lets widget.js execute.
You do not need a connect-src entry for the widget. widget.js opens no connections from your page — it injects an iframe and talks to it over postMessage, nothing more. Every network call the voice session makes happens inside that iframe, which is a separate browsing context governed by AmphiVox's own CSP, not yours. The realtime audio itself runs over WebRTC, which CSP does not govern at all.
You do not need media-src either: the audio element lives inside the iframe.
If you self-host AmphiVox, substitute your own origin in both directives. Nothing else changes.
Troubleshooting
Session never starts, no error. Check these three, in order. 1) Permissions-Policy: microphone=() from your server — it overrides the iframe's permission and blocks the mic silently. 2) The page's origin is missing from the widget key's allowed-domains list (Developers page) — look for a 403 in the network tab. 3) A frame-src CSP violation in the console, meaning the iframe never loaded. A connect-src violation is not a cause here — your page's connect-src does not govern anything the widget does; see Content Security Policy.
Mic blocked. The widget needs microphone permission. Make sure your server does not send a Permissions-Policy: microphone=() header — it overrides the iframe's permission and silently blocks the mic.
403 on connect. Your widget key has an allowed-domains list. The page's origin (scheme + host + port) must be on it. An empty list blocks every origin — add at least one domain on the Developers page. 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 visitor 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, or the tab was backgrounded for a long stretch.
setLanguage / setVoiceGender did nothing. By design — both apply to the next session, not the one in progress.
An input shows [object Object]. You mapped a phone or address field to a single text input. See Phone fields and Address fields.
Session dies after ~10s when I hide the widget myself. Hiding the iframe with your own display: none throttles its keepalive and the server drops the session. Use the hidden option or VoiceForm.hide() instead — see Bring your own UI.
crossorigin missing. Without it the browser uses no-cors mode and some servers (including local dev) refuse to execute the script.