Overview
your page widget.js AmphiVox iframe voice agent
───────── ───────── ─────────────── ───────────
your form, its rules, ──► VoiceForm.reportPage ──► relays the page ──► asks, checks,
Next and submit VoiceForm.* controls and your controls writes values
◄── onField, onAdvancePage, ◄── relays the agent ◄──
onSessionEnd, …
widget.js adds a floating mic button (an iframe) to your page and relays between your page and the agent. Who owns what:
| Your page owns | The agent owns |
|---|---|
| The form, its fields and how they look | Asking the questions, in the visitor's language |
| Validation rules the schema can't express | Checking answers against the schema's rules |
| Going to the next page | Asking the visitor before it moves on |
Submitting, after an ending of complete | Telling you how the session ended |
An integration makes three choices, and they are independent of each other:
| Choice | Options | It decides |
|---|---|---|
| The widget's mode | 'form-linked' or 'developer-defined' | Where the schema (the list of fields the agent asks for) lives. Set when you create the widget key. |
| Our UI or yours | Our dock, or your own UI (pass hidden (opens in a new tab) and drive it with the VoiceForm methods and callbacks) | Who draws the voice controls: start and stop, mute, language and voice, the transcript, and the speaking and thinking indicators. It changes nothing about your form. |
| Who fills your inputs | Autofill (pass target (opens in a new tab)) or callbacks (handle onField (opens in a new tab)) | How an answer gets from the agent into your form. Autofill is the easier one when your inputs match the schema. Either works with either mode. |
Every widget key is created in the Widgets area of the AmphiVox dashboard, in one of the two modes. The mode you pass to init() (opens in a new tab) must match the key:
| Mode | Where the schema lives | Best for |
|---|---|---|
'form-linked' | In AmphiVox: a form you built or imported there, or one of your published forms. Your page mirrors its fields and pages. | You already have the form in AmphiVox (you built it, imported it, or have been collecting responses with it), and now want the same form, and the same voice agent, on your own site. |
'developer-defined' | In your code: you pass the whole schema to init(). Your code supplies the schema for each session. | Your site already has its own form, and you want to add the voice agent to it without building anything in AmphiVox. |
Each part of this guide that differs between the two carries a Form-linked / Developer-defined switch: pick yours on any of them and every such part follows.
Quick start
Your form lives in AmphiVox; your page mirrors it.
Paste your form and these two tags just before </body>. This one uses autofill: target (opens in a new tab) points at your form, and the SDK fills each field's input itself, finding it by its field id (or by its fieldMap (opens in a new tab) entry, where the names differ). What has to match lists what your inputs need for that:
<form id="application-form">
<section data-page="0">
<input name="name" required> <!-- field "full_name": mapped in fieldMap -->
<input name="email" type="email" required> <!-- field "email": found by its name -->
</section>
<section data-page="1" hidden>
<textarea name="goal"></textarea> <!-- field "goal" -->
</section>
</form>
<script src="https://amphivox.com/widget.js" crossorigin></script>
<script>
let currentPage = 0; // the page number on screen: 0, 1, 2…
VoiceForm.init({
mode: 'form-linked',
widgetKey: 'YOUR_WIDGET_KEY',
target: '#application-form',
fieldMap: { full_name: 'name' }, // field id → your input's id or name, where they differ
onAdvancePage: () => {
const moved = goToNextPage(); // your own Next: validation, then show the next section
VoiceForm.reportPage({ page: currentPage });
if (!moved) VoiceForm.reportAdvanceBlocked();
},
onSessionEnd: (ending) => {
// the browser's own validation, then your form's submit
if (ending.reason === 'complete') document.querySelector('#application-form').requestSubmit();
},
});
// Before the first session, and whenever your form shows a different page:
VoiceForm.reportPage({ page: currentPage });
</script>
The widget key and every field id are on the widget's page in the Widgets area, which also generates this snippet for you, with a fieldMap entry for every field, grouped by page. On a one-page form, onAdvancePage (opens in a new tab) never fires; keep it anyway, so adding a page later doesn't strand your visitors on page 0. To put the answers into your form yourself instead, see Fill them yourself.
You pass the schema in code; nothing is stored.
Paste these two tags just before </body>. Pass your whole form as schema (opens in a new tab) (its keys and limits are in the API reference (opens in a new tab)). This one uses callbacks: each answer arrives in onField (opens in a new tab), and you report the page back:
<script src="https://amphivox.com/widget.js" crossorigin></script>
<script>
VoiceForm.init({
mode: 'developer-defined',
widgetKey: 'YOUR_WIDGET_KEY',
schema: {
fields: [
{ id: 'full_name', label: 'Full name', type: 'str', required: true, page: 0 },
{ id: 'goal', label: 'What do you want to achieve?', type: 'long', page: 1 },
],
},
onField: (fieldId, value, writeId) => {
if (value === VoiceForm.SKIP) return; // a skipped optional field: not an answer
setValue(fieldId, value); // put it in your form…
reportCurrentPage(); // …and report the page again
},
onAdvancePage: () => {
const moved = goToNextPage(); // your own Next: validation, then navigation
reportCurrentPage();
if (!moved) VoiceForm.reportAdvanceBlocked();
},
onSessionEnd: (ending) => {
if (ending.reason === 'complete') submitMyForm(); // your own validation + submit
},
});
// Before the first session, after every change, and on every page change:
function reportCurrentPage() {
VoiceForm.reportPage({
page: currentPage, // the page number on screen: 0, 1, 2…
values: valuesOfPage(currentPage), // exactly this page's field ids, null for no answer
});
}
reportCurrentPage();
</script>
The widget key is on the widget's page in the Widgets area. The field ids are your own; a page is just the number your fields carry. If your inputs match the schema, you can let the SDK fill them instead: add target (opens in a new tab) and see Autofill.
Two separate tags are required. A <script> with a src ignores any inline code between its tags, so the loader and your init() (opens in a new tab) 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.
A session, step by step
One conversation on a two-page form, showing what your page does at each step:
1. Page loads reportPage({ page: 0, values: { … } })
2. Visitor taps mic onSessionStart()
3. Visitor answers onField('email', 'a@b.com', 'w_17')
→ put it in your form, then reportPage again
(when the SDK fills your inputs, it does both)
4. Your rule says no onField returns { ok: false, cause: 'validation', reason }
→ the agent says why and asks again; after two
failed attempts it leaves the field to the visitor
5. Page 0 is done the agent asks: "Anything to change before I move on?"
the visitor agrees → onAdvancePage()
→ Next moved: reportPage({ page: 1, … })
→ Next failed: reportPage({ page: 0, … }), then
reportAdvanceBlocked(): the agent goes back to an
unanswered field, or else ends the session (hands_off)
6. Visitor is silent the agent nudges, warns, then
onSessionEnd({ reason: 'timeout', detail: { timeout: 'idle' } })
7. Last page done the agent reads the answers back, then
onSessionEnd({ reason: 'complete' }) → validate and submit
Each step in the contract: Writes (opens in a new tab), Refusals (opens in a new tab), Page turns (opens in a new tab), Silence and time limits (opens in a new tab), Endings (opens in a new tab).
Who fills your inputs
When the agent has an answer, something has to put it into your form and tell the agent what the page now holds. There are two ways:
- Autofill (pass
target(opens in a new tab)): the SDK fills your inputs itself. It is the easier way, and it works when your form's controls match the schema, as listed in What has to match. It exists only inwidget.js, because it writes into plain HTML controls. - Callbacks (handle
onField(opens in a new tab)): you put each answer into your form yourself and report the page. It works with any form.
| Autofill | Callbacks | |
|---|---|---|
| Turn it on | Pass target: a CSS selector for your form (plus fieldMap (opens in a new tab) where names differ) | Leave out target; handle onField |
| An answer arrives | The SDK writes it into the matching input, reads it back, and reports the page | onField(fieldId, value, writeId): you put it in your form, then call reportPage (opens in a new tab) |
| Your page report | reportPage({ page }): only which page is on screen | reportPage({ page, values }): the page number and every one of its values |
| The visitor types | The SDK reports the page on every change inside target | You call reportPage after every change |
| Choose it when | Every field has a plain HTML control that matches it | Your values live somewhere else (a framework's state, a custom control, a form library), or your controls don't match |
onField still fires with autofill, just after the write, so you can still refuse an answer your own rules reject (Refuse an answer) or keep a skip (Remember a skip across sessions).
Autofill
Pass target, and the SDK fills every field from the schema into your form. Nothing else changes: you still run your own Next, report which page is on screen, and submit.
It writes like a person would (clicking checkboxes and radios, setting other controls and dispatching input/change), so framework-controlled inputs update. Then it reads the control back: a value the control did not take (a date the input rejects, an option it lacks) is refused as a write failure, and the agent resends it once, rather than losing it. It reports the page again after every write and after any change event inside target. A skip (opens in a new tab) leaves the control untouched.
What has to match
Autofill needs every one of these to hold. Where your form can't meet one, use callbacks instead.
-
The form.
targetis a CSS selector for one element that holds every control, such as'#application-form'. The SDK looks controls up inside it only. -
A control for every field. For each field id, the SDK looks inside
target, in this order, for:- an element whose
idis the field'sfieldMapentry, then one whosenameis that entry, when the field has one; - otherwise, an element whose
idis the field id, then one whosenameis the field id.
A mapping replaces the field id rather than coming before it: if the entry matches nothing, the SDK does not fall back to the field id. So
<input name="email">needs no mapping for a fieldemail, and<input name="name">for a fieldfull_nameneedsfieldMap: { full_name: 'name' }. Every field of the page on screen needs a control. If one is missing, the SDK doesn't send that page report at all:onErrorgetswidget_page_invalid, and the agent keeps working from what it last knew. The controls of other pages can stay hidden; they only have to be insidetarget. - an element whose
-
The right kind of control for each type. See the table below.
-
Option values. Each option's
value(on a radio, checkbox or<option>) is exactly the schema's option string, such as<input type="radio" name="plan" value="Pro">for the option'Pro'. When the SDK writes an answer, it also accepts a match on an option's visible text, ignoring case and surrounding spaces. When it reads the page, it reports thevalue, so avaluethat differs from the option string is reported as an answer the agent doesn't accept. -
Page numbers.
reportPage({ page })takes the page number the schema gives your fields:0for the first page, then1,2…
Bind each field type to this kind of control:
| Field | Control | Reads as |
|---|---|---|
str, long, email, url, int, float, phone | text-like <input> or <textarea> | its text, null when empty |
date | <input type="date"> or a text input | DD-MM-YYYY (converted to and from the date input's own format) |
enum | radio group (one shared name) or <select> | the chosen option's value |
enum with multi | checkbox group (one shared name) or <select multiple> | an array of the ticked options' values, null when none |
bool | a Yes/No radio pair or <select>, with values yes/no or true/false | the chosen option's value, null when unanswered |
legal | a single checkbox | true when ticked, null when not |
address | one control per part (see below) | an object of the parts |
file | never filled, never read | see below |
A single checkbox can only say yes: it reads null when unticked, never "no", and a false written to it is refused, so give a bool a Yes/No control.
- Phone arrives as an E.164 string (
'+919876543210'): map it to one input. - Address arrives as an object of parts. Give it an object entry in
fieldMap, one key per part the schema asks for (street,apt,city,state,zip,country;countryis always included), each the id or name of that part's control:fieldMap: { home: { street: 'street', city: 'city', zip: 'zip', country: 'country' } }. Written to a single input, an address is refused. - File inputs are never written (browsers forbid it) and never read: a selected file isn't an uploaded one. You report the field yourself; see Accept a file upload.
Fill them yourself
Each answer arrives in onField (opens in a new tab) with its field id, a value in the shape the contract gives for its type (Field types (opens in a new tab)), and a write id. Check for VoiceForm.SKIP (opens in a new tab) first, put the value in your form, and report the whole page again:
onField: (fieldId, value, writeId) => {
if (value === VoiceForm.SKIP) return;
setValue(fieldId, value);
VoiceForm.reportPage({ page: currentPage, values: valuesOfPage(currentPage) });
},
The same write id can arrive twice, so applying it again must change nothing. values names exactly the page's field ids, null for no answer: see VoiceForm.reportPage (opens in a new tab). Nothing has to match here: your controls can have any names, and the field ids only have to line up with the keys of values.
Build your own UI
Our dock is the floating mic button and its panel. To use your own components instead, pass hidden: true (opens in a new tab) and build each part from these:
| The dock's part | What you use |
|---|---|
| Start and end a session | VoiceForm.open() / VoiceForm.close() (opens in a new tab); onSessionStart (opens in a new tab) and onSessionEnd (opens in a new tab) tell you when it happens |
| Mute | VoiceForm.mute() / VoiceForm.unmute() (opens in a new tab); onMuteChange(muted) (opens in a new tab) |
| Language and voice | VoiceForm.setLanguage(code) / VoiceForm.setVoiceGender(gender) (opens in a new tab), for the next session. The codes, and a live JSON list of them, are in Languages and voices (opens in a new tab) |
| Live transcript | onTranscript(speaker, text, index) (opens in a new tab): lines grow as speech goes on, so replace the line at index, don't append |
| Speaking and thinking | onSpeaking(who, active) (opens in a new tab) and onThinking(active) (opens in a new tab): on or off, not an audio level |
| Problems | onError (opens in a new tab) |
VoiceForm.init({
// …mode, widgetKey, and the rest of your options…
hidden: true,
onSessionStart: () => setLive(true),
onSessionEnd: () => setLive(false),
onThinking: (active) => setSpinner(active),
onSpeaking: (who, active) => { if (who === 'bot') pulse(active); },
onMuteChange: (muted) => setMuteIcon(muted),
onTranscript: (speaker, text, index) => { lines[index] = { speaker, text }; renderLines(lines); },
});
micButton.addEventListener('click', () => (live ? VoiceForm.close() : VoiceForm.open()));
muteButton.addEventListener('click', () => (muted ? VoiceForm.unmute() : VoiceForm.mute()));
languageSelect.addEventListener('change', () => VoiceForm.setLanguage(languageSelect.value));
Your form works exactly as before: hiding the dock changes nothing about how answers reach it.
- The microphone prompt still comes from the AmphiVox origin.
hidden, not your own CSS. The iframe must stay rendered to keep the session alive: browsers slow down hidden cross-origin frames until they look disconnected.hiddenparks the frame as a 1×1 transparent element instead ofdisplay: none. Toggling it never restarts a live session.- AI disclosure is required. Visitors must be told they are talking to an AI before they start. Our dock shows that line; when you hide it, your own UI must show it, next to whatever starts the call: You're speaking with an AI assistant, not a person.
Recipes
Each recipe is one task, end to end. The init() (opens in a new tab) options they don't show are the ones from your quick start.
Handle a blocked Next
Your own Next decides whether the page moves. Report what happened either way:
VoiceForm.init({
// …mode, widgetKey, and the rest of your options…
onAdvancePage: () => {
const errors = validatePage(currentPage);
if (errors.length === 0) {
currentPage += 1;
showPage(currentPage);
}
VoiceForm.reportPage({ page: currentPage, values: valuesOfPage(currentPage) });
if (errors.length > 0) VoiceForm.reportAdvanceBlocked();
},
});
A blocked Next with an unanswered field on the page sends the agent back to that field. Otherwise it ends the session (opens in a new tab) as hands_off: a block the agent can't see the reason for can't be fixed by voice, so it hands the page back to the visitor. Most blocked Nexts come from a rule the agent didn't know about. Put it in the schema (required, min, maxLength…) so the agent enforces it while asking, or refuse the value as it arrives (next recipe), so the page is valid by the time the agent asks to move on.
Refuse an answer
A refusal is one thing: you tell the agent an answer didn't land. It carries a cause, which decides what the agent does next:
cause | Means | The agent |
|---|---|---|
'validation' | The answer is well-formed, but your own rule rejects it: an email that's already registered, an expired coupon | Tells the visitor your reason and asks for a different answer |
'write' (the default) | Your input couldn't take the value | Resends the same value once, without telling the visitor |
Either way, after two failed attempts the agent leaves the field for the visitor to fill. When the SDK fills your inputs, it sends write refusals itself (an input that didn't take the value), so in practice you only send validation. The SDK never checks your rules for you: it does not read the browser's own validation state on your inputs (required, pattern, min, max), so a rule the agent should respect is either in the schema or refused by you from onField.
You can refuse straight away, or after an async check, whichever your rule needs:
| Your check | Refuse by |
|---|---|
| Synchronous | Returning { ok: false, cause, reason } from onField (opens in a new tab) (false alone is short for { ok: false, cause: 'write' }) |
| Async, such as a server round trip | Returning nothing from onField, then calling VoiceForm.reportWriteResult({ fieldId, writeId, ok: false, cause, reason }) with the same writeId |
Straight away:
onField: (fieldId, value, writeId) => {
if (value === VoiceForm.SKIP) return;
if (fieldId === 'email' && registeredEmails.has(value)) {
return { ok: false, cause: 'validation', reason: 'That email already has an account.' };
}
setValue(fieldId, value);
reportCurrentPage();
},
After an async check:
onField: (fieldId, value, writeId) => {
if (value === VoiceForm.SKIP) return;
if (fieldId !== 'coupon') { setValue(fieldId, value); reportCurrentPage(); return; }
checkCoupon(value).then((valid) => {
if (valid) { setValue(fieldId, value); reportCurrentPage(); return; }
VoiceForm.reportWriteResult({
fieldId, writeId, ok: false, cause: 'validation', reason: 'That coupon has expired.',
});
});
},
Keep reporting what your form really holds afterwards, the rejected value included if you kept it (when the SDK fills your inputs, the value is already in the input when onField runs). The agent never picks a refused value up from a page report. The reason is spoken aloud and kept in AmphiVox's session logs: keep it short and never put the value in it.
Remember a skip across sessions
A skip arrives in onField (opens in a new tab) as VoiceForm.SKIP (opens in a new tab) with a null write id. Keep the field id, and send the marker back before the next session so the agent doesn't ask again:
const skipped = new Set();
const sendSkips = () =>
VoiceForm.setInitialValues(Object.fromEntries([...skipped].map(id => [id, VoiceForm.SKIP])));
VoiceForm.init({
// …mode, widgetKey, and the rest of your options…
onField: (fieldId, value, writeId) => {
if (value === VoiceForm.SKIP) {
skipped.add(fieldId); // not an answer: leave the field as it is
sendSkips();
return;
}
if (skipped.delete(fieldId)) sendSkips(); // answered now: forget the skip
setValue(fieldId, value);
reportCurrentPage();
},
});
The widget keeps the marker while later page reports still show the field empty (null, blank text, or an empty array). A real answer the visitor types afterwards replaces it. To forget a skip, remove its key and call setInitialValues (opens in a new tab) again. Sending a skip for a required field changes nothing.
Accept a file upload
The agent never asks for a file by voice; it tells the visitor to attach it on your page. Check the file against the field's maxSizeMb and extensions, store it yourself, then report the field as uploaded. Never report the file or its name:
fileInput.addEventListener('change', async () => {
const file = fileInput.files[0];
if (!file || !acceptsFile(file)) return; // your own size and type check
await uploadToYourServer(file);
uploaded.cv = true;
// When the SDK fills your inputs (`target` set): report only the fields you own.
VoiceForm.reportPage({ page: currentPage, values: { cv: { uploaded: true } } });
});
When the SDK fills your inputs, values holds only the fields you own, like this file. It wins over what the SDK reads and is kept for the SDK's own reports of that page until the page changes.
When you fill them yourself, every report carries every field of the page, so the file goes in beside your other values ({ uploaded: true }, or null until then):
VoiceForm.reportPage({
page: currentPage,
values: { ...readCurrentPageValues(), cv: { uploaded: true } }, // your own reader
});
Start with answers you already know
For a returning visitor, pass the answers you already hold, from any page, so the agent doesn't ask for them again:
VoiceForm.init({
// …mode, widgetKey, and the rest of your options…
initialValues: { full_name: user.name, email: user.email },
});
// Later, before another session:
VoiceForm.setInitialValues({ full_name: user.name, email: user.email, company: user.company });
These are known answers (opens in a new tab): the same shapes as page reports, checked the same way, so an invalid one is dropped and the agent asks for that field as usual. A key that is not one of your field ids rejects the whole set (widget_page_invalid). setInitialValues(null) (opens in a new tab) clears them.
Handle every ending
onSessionEnd: (ending) => {
switch (ending.reason) {
case 'complete': // every answer is in: validate and submit
submitMyForm();
break;
case 'hands_off': // the visitor must finish some fields
highlightFields(ending.detail.outstanding); // their labels
break;
case 'timeout': // 'idle' or 'max_duration'
case 'manual': // the visitor stopped, or you called close()
break; // nothing to do: their answers are on the page
case 'error': // a live session failed; answers already written stay valid
showNotice('Voice stopped working. You can finish the form by hand.');
break;
}
},
Never submit on anything but complete. The dock itself shows the visitor a short notice for hands_off and timeout, then asks them to rate the voice session.
Handle errors
onError (opens in a new tab) reports anything that stopped a session before it started, and messages from your page that the widget rejected. Switch on the code; show the message, which is safe to display:
onError: ({ code, message }) => {
switch (code) {
case 'origin_not_allowed': // add this site to the key's allowed websites
case 'invalid_widget_key':
case 'widget_mode_mismatch':
console.error('[voice setup]', code, message);
break;
case 'widget_page_invalid': // your page report and the schema disagree
console.error('[voice page]', message);
break;
case 'session_limit_exceeded':
case 'session_busy':
showNotice('Voice is busy right now. Try again in a minute.');
break;
default: // the list can grow: always keep a default
showNotice(message);
}
},
Every code and what it means: Errors (opens in a new tab).
Change the schema between sessions
For developer-defined keys only. The schema is fixed for a session: it is read when the call connects and never changes during it. Call VoiceForm.setSchema(schema) only while no session is running. A call during a session is refused with widget_schema_locked and discarded, not kept for later. Your last page report is kept if it still fits the new schema; if not, report the page again before open() (opens in a new tab):
VoiceForm.setSchema(schemaFor(selectedPlan));
VoiceForm.reportPage({ page: 0, values: valuesOfPage(0) });
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;
You don't need a connect-src entry. widget.js opens no connections from your page: it injects an iframe and talks to it over postMessage. Every network call happens inside that iframe, under AmphiVox's own CSP, and the audio runs over WebRTC, which CSP doesn't govern. You don't need media-src either.
Troubleshooting
Session never starts. Check onError (opens in a new tab) first: admission failures arrive there with a code. Then, in order: 1) a Permissions-Policy: microphone=() header from your server blocks the mic silently. 2) The page's origin is missing from the widget's allowed websites (origin_not_allowed). 3) A frame-src CSP violation in the console means the iframe never loaded.
widget_page_required. Call reportPage (opens in a new tab) before open() (opens in a new tab); when the SDK fills your inputs, reportPage({ page: 0 }) on load is enough.
widget_page_invalid. The page report named a field the page doesn't have, or missed one it does. When the SDK fills your inputs, a mapped control is missing from the page.
Every multi-page session ends on page 0. You aren't handling onAdvancePage (opens in a new tab), so each page turn times out as blocked. See Handle a blocked Next.
origin_not_allowed even though your domain is on the list. Check whether the page embedding the widget sends Referrer-Policy: no-referrer or same-origin. Both hide your page's origin from the documents it frames, so the widget can't tell which site it's on. Use the default strict-origin-when-cross-origin (or origin, strict-origin, no-referrer-when-downgrade) on that page. (Why.)
Screen sleeps during a session. The iframe asks the browser to keep the screen on. Only a Permissions-Policy: screen-wake-lock=() header on your page overrides it: drop it, or include the widget's origin.
setLanguage (opens in a new tab) / setVoiceGender (opens in a new tab) did nothing. By design: both apply to the next session.
The agent asks for a field my page doesn't have. The form was edited in AmphiVox and your page hasn't caught up. Update your page's fields, ids and pages to match.
setSchema (opens in a new tab) did nothing. It was called during a session, so it was refused (widget_schema_locked) and discarded. The schema is fixed once a call connects. Call setSchema again after the session ends.
The session drops when I hide the widget myself. Hiding the iframe with display: none makes the browser slow it down until it looks disconnected. Use the hidden (opens in a new tab) option or VoiceForm.hide() (opens in a new tab).
crossorigin missing. Without it the browser loads the script in no-cors mode, and some servers refuse to run it.