# AmphiVox Widgets — Vanilla JS

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](/docs/widget/react) for the `@amphivox/react` package instead.

> **Two companion pages.** The [API reference](/docs/widget/api/javascript) lists every method, option and callback of `widget.js`, and the schema's limits. [The AmphiVox Voice Agent](/docs/agents/voice-agent) 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 &mdash; 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

```text
 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](#build-your-own-ui) | Our dock, or your own UI (pass [`hidden`](/docs/widget/api/javascript#options) 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](#who-fills-your-inputs) | Autofill (pass [`target`](/docs/widget/api/javascript#options)) or callbacks (handle [`onField`](/docs/widget/api/javascript#onfield-fieldid-value-writeid)) | How an answer gets from the agent into your form. Autofill is the easier one when your inputs [match the schema](#what-has-to-match). 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()`](/docs/widget/api/javascript#voiceform-init-options) 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

:::form-linked
Paste your form and these two tags just before `</body>`. This one uses autofill: [`target`](/docs/widget/api/javascript#options) points at your form, and the SDK fills each field's input itself, finding it by its field id (or by its [`fieldMap`](/docs/widget/api/javascript#options) entry, where the names differ). [What has to match](#what-has-to-match) lists what your inputs need for that:

```html
<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`](/docs/widget/api/javascript#onadvancepage) 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](#fill-them-yourself).
:::

:::developer-defined
Paste these two tags just before `</body>`. Pass your whole form as [`schema`](/docs/widget/api/javascript#schema) (its keys and limits are in the [API reference](/docs/widget/api/javascript#schema)). This one uses callbacks: each answer arrives in [`onField`](/docs/widget/api/javascript#onfield-fieldid-value-writeid), and you report the page back:

```html
<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`](/docs/widget/api/javascript#options) and see [Autofill](#autofill).
:::

> **Two separate tags are required.** A `<script>` with a `src` ignores any inline code between its tags, so the loader and your [`init()`](/docs/widget/api/javascript#voiceform-init-options) 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:

```text
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](/docs/agents/voice-agent#writes), [Refusals](/docs/agents/voice-agent#refusals), [Page turns](/docs/agents/voice-agent#page-turns), [Silence and time limits](/docs/agents/voice-agent#silence-and-time-limits), [Endings](/docs/agents/voice-agent#endings).

## 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`](/docs/widget/api/javascript#options)): 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](#what-has-to-match). It exists only in `widget.js`, because it writes into plain HTML controls.
- **Callbacks** (handle [`onField`](/docs/widget/api/javascript#onfield-fieldid-value-writeid)): 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`](/docs/widget/api/javascript#options) 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`](/docs/widget/api/javascript#voiceform-reportpage-snapshot) |
| 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](#refuse-an-answer)) or keep a skip ([Remember a skip across sessions](#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](/docs/agents/voice-agent#skips) 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](#fill-them-yourself) 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:

| 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' `value`s, `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`; `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](#accept-a-file-upload).

### Fill them yourself

Each answer arrives in [`onField`](/docs/widget/api/javascript#onfield-fieldid-value-writeid) with its field id, a value in the shape the contract gives for its type ([Field types](/docs/agents/voice-agent#field-types)), and a write id. Check for [`VoiceForm.SKIP`](/docs/widget/api/javascript#voiceform-skip) first, put the value in your form, and report the whole page again:

```js
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`](/docs/widget/api/javascript#voiceform-reportpage-snapshot). 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`](/docs/widget/api/javascript#options) and build each part from these:

| The dock's part | What you use |
|---|---|
| Start and end a session | [`VoiceForm.open()` / `VoiceForm.close()`](/docs/widget/api/javascript#voiceform-open-voiceform-close); [`onSessionStart`](/docs/widget/api/javascript#onsessionstart) and [`onSessionEnd`](/docs/widget/api/javascript#onsessionend-ending) tell you when it happens |
| Mute | [`VoiceForm.mute()` / `VoiceForm.unmute()`](/docs/widget/api/javascript#voiceform-mute-voiceform-unmute); [`onMuteChange(muted)`](/docs/widget/api/javascript#live-status-callbacks) |
| Language and voice | [`VoiceForm.setLanguage(code)` / `VoiceForm.setVoiceGender(gender)`](/docs/widget/api/javascript#voiceform-setlanguage-code-voiceform-setvoicegender-gender), for the next session. The codes, and a live JSON list of them, are in [Languages and voices](/docs/agents/voice-agent#languages-and-voices) |
| Live transcript | [`onTranscript(speaker, text, index)`](/docs/widget/api/javascript#live-status-callbacks): lines grow as speech goes on, so replace the line at `index`, don't append |
| Speaking and thinking | [`onSpeaking(who, active)`](/docs/widget/api/javascript#live-status-callbacks) and [`onThinking(active)`](/docs/widget/api/javascript#live-status-callbacks): on or off, not an audio level |
| Problems | [`onError`](/docs/widget/api/javascript#onerror-code-message) |

```js
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()`](/docs/widget/api/javascript#voiceform-init-options) options they don't show are the ones from your [quick start](#quick-start).

### Handle a blocked Next

Your own Next decides whether the page moves. Report what happened either way:

```js
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](/docs/agents/voice-agent#page-turns) 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](#refuse-an-answer)), 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`](/docs/widget/api/javascript#onfield-fieldid-value-writeid) (`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:

```js
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:

```js
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`](/docs/widget/api/javascript#onfield-fieldid-value-writeid) as [`VoiceForm.SKIP`](/docs/widget/api/javascript#voiceform-skip) 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:

```js
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`](/docs/widget/api/javascript#voiceform-setinitialvalues-values) 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:

```js
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):

```js
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:

```js
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](/docs/agents/voice-agent#known-answers): 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)`](/docs/widget/api/javascript#voiceform-setinitialvalues-values) clears them.

### Handle every ending

```js
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`](/docs/widget/api/javascript#onerror-code-message) 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:

```js
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](/docs/agents/voice-agent#errors-website-widget).

### 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()`](/docs/widget/api/javascript#voiceform-open-voiceform-close):

```js
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`](/docs/widget/api/javascript#onerror-code-message) 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`](/docs/widget/api/javascript#voiceform-reportpage-snapshot) before [`open()`](/docs/widget/api/javascript#voiceform-open-voiceform-close); 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`](/docs/widget/api/javascript#onadvancepage), so each page turn times out as blocked. See [Handle a blocked Next](#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](https://developer.mozilla.org/en-US/docs/Web/API/Location/ancestorOrigins).)

> **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`](/docs/widget/api/javascript#voiceform-setlanguage-code-voiceform-setvoicegender-gender) / [`setVoiceGender`](/docs/widget/api/javascript#voiceform-setlanguage-code-voiceform-setvoicegender-gender) did nothing.** By design: both apply to the next session.

:::form-linked
> **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.
:::

:::developer-defined
> **[`setSchema`](/docs/widget/api/javascript#voiceform-setschema-schema) 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`](/docs/widget/api/javascript#options) option or [`VoiceForm.hide()`](/docs/widget/api/javascript#voiceform-hide-voiceform-show).

> **`crossorigin` missing.** Without it the browser loads the script in no-cors mode, and some servers refuse to run it.
