AmphiVox Widgets · For developers

AmphiVox Widgets — Vanilla JS

On this pageOverview

Add a voice agent to any website with a single <script> tag. Your visitors speak their answers; the agent fills your form. Your page keeps the form, its validation, its page navigation and its submission. No build step, no framework required.

Using React? See the React guide for the @amphivox/react package instead.

Two companion pages. The API reference (opens in a new tab) lists every method, option and callback of widget.js, and the schema's limits. The AmphiVox Voice Agent (opens in a new tab) describes how the agent behaves on every surface (field types, writes, refusals, skips, page turns, endings, timing). This guide shows how to put them together.

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.

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 ownsThe agent owns
The form, its fields and how they lookAsking the questions, in the visitor's language
Validation rules the schema can't expressChecking answers against the schema's rules
Going to the next pageAsking the visitor before it moves on
Submitting, after an ending of completeTelling you how the session ended

An integration makes three choices, and they are independent of each other:

ChoiceOptionsIt 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 yoursOur 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 inputsAutofill (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:

ModeWhere the schema livesBest 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 in widget.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.
AutofillCallbacks
Turn it onPass target: a CSS selector for your form (plus fieldMap (opens in a new tab) where names differ)Leave out target; handle onField
An answer arrivesThe SDK writes it into the matching input, reads it back, and reports the pageonField(fieldId, value, writeId): you put it in your form, then call reportPage (opens in a new tab)
Your page reportreportPage({ page }): only which page is on screenreportPage({ page, values }): the page number and every one of its values
The visitor typesThe SDK reports the page on every change inside targetYou call reportPage after every change
Choose it whenEvery field has a plain HTML control that matches itYour 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.

  1. The form. target is a CSS selector for one element that holds every control, such as '#application-form'. The SDK looks controls up inside it only.

  2. A control for every field. For each field id, the SDK looks inside target, in this order, for:

    1. an element whose id is the field's fieldMap entry, then one whose name is that entry, when the field has one;
    2. otherwise, an element whose id is the field id, then one whose name is 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 field email, and <input name="name"> for a field full_name needs fieldMap: { 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: onError gets widget_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 inside target.

  3. The right kind of control for each type. See the table below.

  4. 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 the value, so a value that differs from the option string is reported as an answer the agent doesn't accept.

  5. Page numbers. reportPage({ page }) takes the page number the schema gives your fields: 0 for the first page, then 1, 2…

Bind each field type to this kind of control:

FieldControlReads as
str, long, email, url, int, float, phonetext-like <input> or <textarea>its text, null when empty
date<input type="date"> or a text inputDD-MM-YYYY (converted to and from the date input's own format)
enumradio group (one shared name) or <select>the chosen option's value
enum with multicheckbox group (one shared name) or <select multiple>an array of the ticked options' values, null when none
boola Yes/No radio pair or <select>, with values yes/no or true/falsethe chosen option's value, null when unanswered
legala single checkboxtrue when ticked, null when not
addressone control per part (see below)an object of the parts
filenever filled, never readsee 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; country is 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:

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. hidden parks the frame as a 1×1 transparent element instead of display: 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:

causeMeansThe agent
'validation'The answer is well-formed, but your own rule rejects it: an email that's already registered, an expired couponTells the visitor your reason and asks for a different answer
'write' (the default)Your input couldn't take the valueResends 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 checkRefuse by
SynchronousReturning { 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 tripReturning 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.