# AmphiVox voice widget: vanilla JavaScript, developer-defined

Everything needed to integrate the vanilla JavaScript widget (`widget.js`) with a developer-defined widget key (your code sends the schema), in one file: the integration guide, the API reference, then the contract describing what the voice agent does. Generated from https://amphivox.com/docs/widget/javascript, https://amphivox.com/docs/widget/api/javascript and https://amphivox.com/docs/agents/voice-agent; the other widget mode is left out.

## Part 1: The integration guide

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

> **Two companion parts.** The [API reference](#part-2-the-api-reference) lists every method, option and callback of `widget.js`, and the schema's limits. [The AmphiVox Voice Agent](#part-3-the-voice-agent-contract) 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`](#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`](#options)) or callbacks (handle [`onField`](#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()`](#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. |



### Quick start

Paste these two tags just before `</body>`. Pass your whole form as [`schema`](#schema) (its keys and limits are in the [API reference](#schema)). This one uses callbacks: each answer arrives in [`onField`](#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`](#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()`](#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](#writes), [Refusals](#refusals), [Page turns](#page-turns), [Silence and time limits](#silence-and-time-limits), [Endings](#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`](#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`](#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`](#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`](#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](#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`](#onfield-fieldid-value-writeid) with its field id, a value in the shape the contract gives for its type ([Field types](#field-types)), and a write id. Check for [`VoiceForm.SKIP`](#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`](#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`](#options) and build each part from these:

| The dock's part | What you use |
|---|---|
| Start and end a session | [`VoiceForm.open()` / `VoiceForm.close()`](#voiceform-open-voiceform-close); [`onSessionStart`](#onsessionstart) and [`onSessionEnd`](#onsessionend-ending) tell you when it happens |
| Mute | [`VoiceForm.mute()` / `VoiceForm.unmute()`](#voiceform-mute-voiceform-unmute); [`onMuteChange(muted)`](#live-status-callbacks) |
| Language and voice | [`VoiceForm.setLanguage(code)` / `VoiceForm.setVoiceGender(gender)`](#voiceform-setlanguage-code-voiceform-setvoicegender-gender), for the next session. The codes, and a live JSON list of them, are in [Languages and voices](#languages-and-voices) |
| Live transcript | [`onTranscript(speaker, text, index)`](#live-status-callbacks): lines grow as speech goes on, so replace the line at `index`, don't append |
| Speaking and thinking | [`onSpeaking(who, active)`](#live-status-callbacks) and [`onThinking(active)`](#live-status-callbacks): on or off, not an audio level |
| Problems | [`onError`](#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()`](#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](#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`](#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`](#onfield-fieldid-value-writeid) as [`VoiceForm.SKIP`](#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`](#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](#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)`](#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`](#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](#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()`](#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`](#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`](#voiceform-reportpage-snapshot) before [`open()`](#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`](#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`](#voiceform-setlanguage-code-voiceform-setvoicegender-gender) / [`setVoiceGender`](#voiceform-setlanguage-code-voiceform-setvoicegender-gender) did nothing.** By design: both apply to the next session.

> **[`setSchema`](#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`](#options) option or [`VoiceForm.hide()`](#voiceform-hide-voiceform-show).

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

## Part 2: The API reference

Every method, option and callback `widget.js` exposes, and the schema it accepts. New to the widget? Start with the [Vanilla JS guide](#part-1-the-integration-guide), which puts these together; what the agent does with each of them is in [The AmphiVox Voice Agent](#part-3-the-voice-agent-contract).

Each entry says when to call it or when it fires, what you must do, what happens if you don't, and which widget modes it applies to.

### Methods

`init()` returns the `VoiceForm` object, and every method is also on `window.VoiceForm`. It is a **singleton, one widget per page**: calling `init()` again destroys the previous widget first.

**Before the widget has loaded**, the methods that act on a session (`open`, `close`, `mute`, `unmute`, `reportWriteResult`, `reportAdvanceBlocked`) are refused with `onError({ code: 'widget_not_ready' })`, never queued. The ones that set state (`reportPage`, `setInitialValues`, `setSchema`, `setLanguage`, `setVoiceGender`, `hide`, `show`) are kept and applied once it has loaded.

#### `VoiceForm.init(options)`

- **Call it:** once, after the loader tag, with the [options](#options) and [callbacks](#callbacks).
- **You must:** pass `mode` and `widgetKey`, plus `schema` for a developer-defined key.
- **If you don't:** `init` throws. An option name it doesn't know is ignored with a console warning (`[AmphiVox] unknown option "…" is ignored`).
- **Modes:** both.

```js
VoiceForm.init({ mode: 'developer-defined', widgetKey: 'YOUR_WIDGET_KEY', schema, onField });
```

#### `VoiceForm.reportPage(snapshot)`

- **Call it:** before the first session, after every change to the page's values, and on every page change. It is a [page snapshot](#page-snapshots): the whole page, never only what changed.
- **You must:** when you fill your inputs, pass `{ page, values }`, with `values` naming exactly the page's field ids (`null` for no answer). When the SDK fills them (`target`), pass `{ page }`, and add `values` only for fields you own, such as a file.
- **If you don't:** `open()` fails with `widget_page_required`; a missing or extra field id fails with `widget_page_invalid`; a stale report means the agent works from old values.
- **Modes:** both.

```js
VoiceForm.reportPage({ page: 1, values: { goal: null, budget: 5000 } });   // you fill your inputs
VoiceForm.reportPage({ page: 1 });                                         // the SDK fills them
```

#### `VoiceForm.reportWriteResult(result)`

- **Call it:** to refuse a write after `onField` has returned, for example after an async check. To refuse straight away, return the refusal from [`onField`](#onfield-fieldid-value-writeid) instead. Both are the same refusal: see [Refuse an answer](#refuse-an-answer).
- **You must:** pass the `fieldId` and `writeId` from `onField`, `ok: false`, and a `cause` (`'validation'` or `'write'`, the default) with a short `reason`.
- **If you don't:** the write counts as accepted.
- **Modes:** both.

```js
VoiceForm.reportWriteResult({ fieldId, writeId, ok: false, cause: 'validation', reason: 'That coupon has expired.' });
```

#### `VoiceForm.reportAdvanceBlocked()`

- **Call it:** from `onAdvancePage`, when your Next did not move. Report the unchanged page first.
- **If you don't:** the agent waits ~15 seconds for a page change, then treats the move as blocked anyway.
- **What happens:** if a field on the page is still unanswered, the agent goes back to it; otherwise the session ends as `hands_off`. See [Page turns](#page-turns).
- **Modes:** both.

```js
VoiceForm.reportPage({ page: currentPage, values: valuesOfPage(currentPage) });
VoiceForm.reportAdvanceBlocked();
```

#### `VoiceForm.setInitialValues(values)`

- **Call it:** before a session, with answers or remembered skips you already hold, from any page, keyed by field id.
- **What happens:** the agent doesn't ask for them; see [Known answers](#known-answers). They apply to the **next** session. `null` clears them.
- **Modes:** both.

```js
VoiceForm.setInitialValues({ email: user.email, newsletter: VoiceForm.SKIP });
```

#### `VoiceForm.setSchema(schema)`

- **Call it:** to change a developer-defined schema between sessions. See [Schema](#schema).
- **If you call it during a session:** it is refused with `widget_schema_locked` and discarded, not kept for later. Call it again once the session has ended.
- **Modes:** developer-defined only. With a form-linked key it is refused with `widget_schema_forbidden`: the form's schema is the one stored in AmphiVox.

```js
VoiceForm.setSchema({ fields: [...baseFields, ...extraFieldsFor(plan)] });
```

#### `VoiceForm.open()` / `VoiceForm.close()`

- **Call them:** to start or end a session from your own controls. `close()` ends it with the ending `manual`.
- **If you call `open()` too early:** before the widget has loaded, it fails with `widget_not_ready`. Call it from a user action.
- **Modes:** both.

```js
startButton.addEventListener('click', () => VoiceForm.open());
```

#### `VoiceForm.mute()` / `VoiceForm.unmute()`

- **Call them:** to turn the visitor's microphone off and on. `onMuteChange` reports every change, including ones made in the dock.
- **Note:** [muting is not pausing](#silence-and-time-limits). A muted visitor counts as silent and the session can time out.
- **Modes:** both.

```js
muteButton.addEventListener('click', () => VoiceForm.mute());
```

#### `VoiceForm.setLanguage(code)` / `VoiceForm.setVoiceGender(gender)`

- **Call them:** to choose the language and voice for the **next** session. A session already running keeps its own. Codes: [Languages and voices](#languages-and-voices).
- **Note:** a voice other than `'female'` or `'male'` falls back to `'female'` when given at start; passed to the method that changes the voice later, it is ignored and the current voice stays. The visitor can also change both in the dock before starting.
- **Modes:** both.

```js
VoiceForm.setLanguage('hi');
VoiceForm.setVoiceGender('male');
```

#### `VoiceForm.hide()` / `VoiceForm.show()`

- **Call them:** to hide or restore the built-in dock at runtime. A live session keeps running.
- **Modes:** both.

```js
VoiceForm.hide();
```

#### `VoiceForm.destroy()`

- **Call it:** in a single-page app, when the view that owns the widget unmounts. It removes the iframe and every listener.
- **Modes:** both.

```js
router.beforeEach(() => VoiceForm.destroy());
```

### Options

Everything `init()` takes besides its [callbacks](#callbacks):

| Option | Default | Description |
|---|---|---|
| `mode` | — | **Required.** `'form-linked'` or `'developer-defined'`; must match the key. |
| `widgetKey` | — | **Required.** From the Widgets area. |
| `schema` | — | Developer-defined only, and required there. See [Schema](#schema). |
| `initialValues` | — | Known answers or remembered `VoiceForm.SKIP` markers from any page, keyed by field id. |
| `target` | — | CSS selector of your form. Pass it to turn on autofill: the SDK fills your inputs itself ([Autofill](#autofill), and [What has to match](#what-has-to-match)); works in both modes. |
| `fieldMap` | `{}` | With `target`: field id → your control's id or name, where they differ; for an address, an object of each part's control. |
| `position` | `'bottom-right'` | Or `'bottom-left'`. |
| `offsetX` / `offsetY` | `0` | Pixels from the anchored side / bottom. |
| `language` | `'en'` | The first session's language. |
| `voiceGender` | `'female'` | The first session's voice: `'female'` or `'male'`. |
| `showTranscript` | `true` | Show the live transcript inside the dock. |
| `hidden` | `false` | Hide the built-in dock (see [Build your own UI](#build-your-own-ui)). |

### Callbacks

Functions you pass to `init()` alongside the options. Every one is optional, but a working integration handles `onAdvancePage` and `onSessionEnd`, plus `onField` unless you use autofill.

#### `onField(fieldId, value, writeId)`

- **Fires when:** the agent has an answer for a field (a [write](#writes)), or the visitor skipped one (`value === VoiceForm.SKIP`, `writeId === null`).
- **You must:** check for `SKIP` first; put the value in your form; report the page again. The same `writeId` can arrive twice, so applying it again must change nothing.
- **To refuse:** return `{ ok: false, cause: 'validation', reason }` or `{ ok: false, cause: 'write' }` (or `false`). To refuse later, call [`reportWriteResult`](#voiceform-reportwriteresult-result). See [Refuse an answer](#refuse-an-answer).
- **If you don't handle it:** when you fill your inputs, the answers never reach your form. When the SDK fills them, it has already written the value, so `onField` is optional.

```js
onField: (fieldId, value, writeId) => {
  if (value === VoiceForm.SKIP) return;
  setValue(fieldId, value);
  reportCurrentPage();
},
```

#### `onAdvancePage()`

- **Fires when:** a page is complete and the visitor agreed to move on. See [Page turns](#page-turns).
- **You must:** run your own Next, then report the new page, or the unchanged page plus [`reportAdvanceBlocked()`](#voiceform-reportadvanceblocked).
- **If you don't handle it:** after ~15 seconds the move counts as blocked, so every multi-page session ends on its first page.

```js
onAdvancePage: () => {
  const moved = goToNextPage();
  reportCurrentPage();
  if (!moved) VoiceForm.reportAdvanceBlocked();
},
```

#### `onSessionStart()`

- **Fires when:** the session is live.

```js
onSessionStart: () => setLive(true),
```

#### `onSessionEnd(ending)`

- **Fires when:** a session that started has ended, exactly once. `ending` is `{ reason, detail? }`; see [Endings](#endings).
- **You must:** submit only on `reason === 'complete'`. See [Handle every ending](#handle-every-ending).

```js
onSessionEnd: ({ reason }) => { if (reason === 'complete') submitMyForm(); },
```

#### `onError({ code, message })`

- **Fires when:** a session couldn't start, or the widget rejected something your page sent. A running session carries on after a rejected message.
- **You must:** switch on `code` and keep a default branch. See [Handle errors](#handle-errors).

```js
onError: ({ code, message }) => console.warn('[voice]', code, message),
```

#### `onReconcileRequest()`

- **Fires when:** the agent asked for a fresh page report. The SDK has already answered with your last one (when it fills your inputs, it reads the page again), so this is only a notice.

#### Live status callbacks

For drawing your own indicators ([Build your own UI](#build-your-own-ui)):

| Callback | Fires when |
|---|---|
| `onTranscript(speaker, text, index)` | A transcript line, new or updated. `speaker` is `'user'` or `'bot'`. Replace the line at `index`; don't append. |
| `onThinking(active)` | The agent starts or stops working on an answer. |
| `onSpeaking(who, active)` | `who` (`'user'` or `'bot'`) starts or stops speaking. |
| `onMuteChange(muted)` | The microphone was muted or unmuted, including from the dock. |

### Constants

#### `VoiceForm.SKIP`

- **What it is:** the value `onField` receives, with a `null` write id, when the visitor skips an optional field. See [Skips](#skips).
- **You must:** check for it first in `onField`, and never put it in your form.
- **Use it to:** send a skip back (in `setInitialValues` or a page report) so the next session doesn't ask again. See [Remember a skip across sessions](#remember-a-skip-across-sessions).

```js
if (value === VoiceForm.SKIP) return;
```

### Schema

The schema lists the fields the agent asks for and the rules it checks answers against: the [field types and rules](#field-types) of the contract. It never describes how a field looks (control type, placeholder, page titles); that stays in your page.

You pass the schema as `schema`, and it is checked on the server before any session starts:

- An optional `description` (up to 500 characters), which gives the agent context for its opening line.
- `fields`: up to **200** in total and **60 per page**, with unique ids. Each has `id`, `label` (up to 120 characters), `type` and `page`, plus any [rules](#rules-a-field-can-carry). Any other key (`pages`, `title`, `ui`, `placeholder`) is rejected.
- `page` is the field's page number. Pages run `0, 1, 2…` with none skipped, up to **10** pages.
- `type` is one of `str`, `long`, `int`, `float`, `bool`, `date`, `enum`, `email`, `phone`, `url`, `file`, `address`, `legal`.
- Your page stores the files, so the file limits are yours: `maxSizeMb` is any positive number, and `extensions` any list of up to 50, each up to 20 characters. Case and a leading dot don't matter (`.pdf` and `PDF` are the same): they are normalised to `PDF`.

One schema using every type and rule:

```js
VoiceForm.init({
  mode: 'developer-defined',
  widgetKey: 'YOUR_WIDGET_KEY',
  schema: {
    description: 'A short application for our design internship.',
    fields: [
      { id: 'full_name', label: 'Full name', type: 'str', required: true, maxLength: 80, page: 0 },
      { id: 'email', label: 'Email', type: 'email', required: true, page: 0 },
      { id: 'phone', label: 'Phone', type: 'phone', region: 'IN', page: 0 },        // read without +91 as Indian
      { id: 'age', label: 'Age', type: 'int', min: 18, max: 99, required: true, page: 0 },  // 18 and 99 allowed
      { id: 'national_id', label: 'National ID', type: 'str', pii: true, page: 0 },  // never asked by voice
      { id: 'role', label: 'Role', type: 'enum', options: ['Design', 'Research', 'Engineering'], required: true, page: 1 },
      { id: 'tools', label: 'Tools you use', type: 'enum', multi: true, options: ['Figma', 'Sketch', 'Framer'], page: 1 },
      { id: 'start_date', label: 'Earliest start date', type: 'date', page: 1 },
      { id: 'relocate', label: 'Open to relocating?', type: 'bool', page: 1 },
      { id: 'hourly_rate', label: 'Expected hourly rate', type: 'float', min: 0, page: 1 },
      { id: 'why', label: 'Why this internship?', type: 'long', maxLength: 2000,
        desc: 'What draws them to the role, in their own words.', page: 1 },
      { id: 'home', label: 'Home address', type: 'address', page: 2, components: [
        { subType: 'street', required: true },
        { subType: 'city', required: true },
        { subType: 'zip' },
        { subType: 'country', default: 'IN' },                                    // assumed when not said
      ] },
      { id: 'portfolio', label: 'Portfolio link', type: 'url', page: 2 },
      { id: 'cv', label: 'CV', type: 'file', maxSizeMb: 5, extensions: ['pdf', 'docx'], page: 2 },
      { id: 'terms', label: 'I accept the terms', type: 'legal', required: true, page: 2 },
    ],
  },
});
```

What each rule does, and its default when left out, is in [Rules a field can carry](#rules-a-field-can-carry). A field without `maxLength` gets 150 characters (`str`) or 1,000 (`long`); `maxLength` itself must be 50 to 5,000.

**Rules that don't exist**, so the server rejects them as unknown keys: a minimum length, a
word count, a text pattern (regular expression), and number limits that exclude the end
value ("greater than 18" rather than "at least 18"). Check those in your own code as each
answer arrives, and refuse with `cause: 'validation'` and a short reason ([Refuse an
answer](#refuse-an-answer)): the agent tells the visitor why and asks for a
different answer.

The schema is fixed for a session: it is read when the call connects and never changes during it. Send a new one with [`setSchema`](#voiceform-setschema-schema) while no session is running; one sent during a session is refused and discarded. See [Change the schema between sessions](#change-the-schema-between-sessions).

## Part 3: The voice agent contract

This part describes what the AmphiVox voice agent does while it fills a form: which questions it asks, the shape of every answer it hands over, when it moves to the next page, and how a session ends. It is the same agent everywhere AmphiVox runs, so the same rules apply whichever way you use it.

It describes behaviour you can observe and rely on, not how the agent is built. For the code that connects your site, read the [JavaScript guide](#part-1-the-integration-guide) or the [React guide](https://amphivox.com/docs/widget/react); each links back here for the details.

### How it fits together

There are three parties in every session:

- **The respondent** speaks their answers.
- **The agent** asks the questions, understands the answers, checks them against each field's rules, and hands over clean values.
- **The form** is separate from the agent and stays in charge. On an AmphiVox form it is the form page itself; on a website it is your page; with the extension it is the Google or Microsoft form being filled.

The agent never owns the form. It does not submit it, it does not navigate it by itself, and it does not decide what the form holds. It asks; the form shows what it actually holds; the agent believes the form.

AmphiVox runs in three places:

| Surface | What it is | Who connects it |
|---|---|---|
| AmphiVox Forms | A form built or imported in AmphiVox and shared as a link or a QR code. Respondents answer it on amphivox.com, by voice or by typing. | Nobody: AmphiVox provides both the agent and the form. |
| AmphiVox Widgets | A voice button on your own site, added with `widget.js` or the `@amphivox/react` package. | You, the site owner. |
| AmphiVox Extension | The AmphiVox browser extension for Chrome and Edge (coming soon), which fills Google Forms and Microsoft Forms in the respondent's own browser. | Nobody: the respondent installs it. |

On an AmphiVox form there is nothing to connect. AmphiVox's own form page is the form side of everything below, and it follows every rule described here. When the conversation completes on a form with no fields the respondent fills in by hand, that page submits the answers itself; otherwise the respondent submits. The agent never does.

The rest of this part is written for developers connecting the widget, but it applies to all three. Where something differs, it says which.

### Words used in this contract

**Session.** One voice conversation, from the moment the respondent starts talking to the moment it ends.

**Field.** One question on the form. Every field has an **id** (a stable name like `full_name`, which code uses), a **label** (the question as the respondent reads it), and a **type** (what kind of answer it takes).

**Page.** Forms can have several pages. Pages are numbered from `0`. The agent only works on the page that is on screen.

**Page snapshot.** A report from the form to the agent: which page is showing, and what every field on it holds right now.

**Write.** The agent handing the form a value to put in a field. Every write carries a **write id**, a label that identifies that one attempt.

**Refusal.** The form telling the agent "I didn't take that value", with the reason.

**Skip.** The respondent choosing not to answer an optional question.

**Manual field.** A field the agent never fills by voice. The respondent fills it in on the form themselves.

**Ending.** How a session finished: completed, handed back to the respondent, timed out, stopped, or failed.

### Field types

Every field has one of these types. The value has the same shape in both directions: what the agent writes, and what the form reports back in a snapshot.

| Type | Asks for | Value | Available on |
|---|---|---|---|
| `str` | One line of text | string | all |
| `long` | Longer text, several lines | string | all |
| `int` | A whole number | number | all |
| `float` | A number that can have decimals | number | all |
| `bool` | Yes or no | `true` / `false` | AmphiVox forms, widget |
| `date` | A calendar date | `"DD-MM-YYYY"`, e.g. `"07-03-2026"` | all |
| `time` | A time of day | `"HH:MM"`, 24-hour, e.g. `"14:30"` | extension |
| `duration` | A length of time | `"HH:MM:SS"`, e.g. `"01:30:00"` | extension |
| `email` | An email address | string | all |
| `phone` | A phone number | E.164 string, e.g. `"+919876543210"` | AmphiVox forms, widget |
| `enum` | One choice from a list | one option string, exactly as declared | all |
| `enum` with `multi` | Any number of choices from a list | array of option strings | all |
| `ranking` | Put every item of a list in order | array of **all** the options, most preferred first | extension |
| `address` | A postal address | object of parts, e.g. `{ "street": "…", "city": "…", "zip": "…", "country": "IN" }` | AmphiVox forms, widget |
| `url` | A link | never asked by voice, see [below](#fields-the-agent-never-asks-for) | all |
| `file` | A file upload | never asked by voice, see [below](#fields-the-agent-never-asks-for) | all |
| `legal` | Accepting terms or giving consent | never asked by voice, see [below](#fields-the-agent-never-asks-for) | AmphiVox forms, widget |

"All" means AmphiVox forms, the website widget and the browser extension. The extension's `time`, `duration` and `ranking` come from question types Google Forms and Microsoft Forms have; neither an AmphiVox form nor a widget schema can declare them. `address`, `bool`, `phone` and `legal` have no equivalent question type on those platforms: a phone or yes/no question there reaches the agent as plain text or a choice.

**Text collected by voice is in Latin script.** Whatever language the respondent speaks, names are romanised and free text is translated to English before it is stored. An answer the respondent types on the form stays as typed, in whatever script they used.

#### Rules a field can carry

Every rule is optional. When one is left out, its default applies.

| Rule | Applies to | Default | What it does |
|---|---|---|---|
| `required` | any type | `false` | The respondent must answer before the form can be finished. |
| `pii` | any type | `false` | Marks personal data. The agent never asks for it by voice; the respondent fills it in on the form. |
| `desc` | any type | none | Extra context the agent can use when asking (up to 300 characters). |
| `options` | `enum`, `ranking` | none (required) | The exact option strings, up to 30. Each is trimmed and cut to 100 characters; a blank one is dropped. |
| `multi` | `enum` | `false` | `true` lets the respondent pick more than one. |
| `maxLength` | `str`, `long` | AmphiVox forms and widget: 150 (`str`), 1000 (`long`). Extension: the form's own limit, if it sets one (Microsoft Forms caps every text answer at 4000 anyway) | The longest answer accepted, in characters: 50 to 5000. A `long` answer is stored in full, every point the respondent made; it is condensed only when it would exceed this limit. |
| `min` / `max` | `int`, `float`, `duration` | none | The smallest and largest number accepted. Both ends are included: `min: 1, max: 10` accepts 1 and 10. When `min` equals `max`, that one number is the only accepted answer. Whole numbers for `int`. For `duration`, they limit the hours. |
| `region` | `phone` | `"US"` | The country (ISO code such as `"IN"`) used to read a number spoken without a country code. |
| `components` | `address` | none (required) | Which parts to ask for: `street`, `apt`, `city`, `state`, `zip`, `country`, each with its own optional `desc` and `required`. A required `country` is always added; its `default` (an ISO code) is the country assumed when the respondent doesn't say. |
| `maxSizeMb` / `extensions` | `file` | 10 MB / any | The largest file and the allowed file types, checked by your form. |

#### What you can rely on in a value

- **Values arrive in one standard shape.** A date is always `DD-MM-YYYY`, a phone number always E.164, a choice always one of the option strings exactly as declared, never the respondent's own wording of it.
- **Rules are checked before a value reaches you.** A value the agent writes already satisfies the field's `options`, `maxLength` and `min`/`max`.
- **Lists are always complete.** A multiple-choice value is the whole selection, not just what changed, and a ranking always contains every option exactly once. Treat the array as the final state: untick anything no longer in it.

### Fields the agent never asks for

Some fields are never filled by voice. The agent tells the respondent to fill them in on the form. On a page with more pages after it, it waits until the form shows them filled, then moves on. On the last page it does not wait: it tells the respondent what is left and ends. These are:

- `file`, `url` and `legal` fields;
- any field marked `pii`;
- on the extension, any question it can't fill reliably (a question type it doesn't recognise, for example). Where the extension can't read such a question either, nothing on the form can show it filled, so the agent accepts the respondent saying it is done.

**The agent never learns their contents.** It only ever learns *that* the field has an answer, never what it is. On AmphiVox forms and the widget, the value is replaced with a plain "filled in" marker before anything leaves the browser. On the extension, a link typed into a link question is sent with the page's other answers and reduced to that marker on arrival.

Because of that, only report one of these fields as filled when your form would accept its value. If an invalid link is reported as filled, the agent moves on while your form still refuses to continue. For a `file` field, check the file against `maxSizeMb` and `extensions` and store it first, then report it as uploaded.

### Pages

A form can have several pages. The agent only asks about fields on the page that is on screen. Answers from earlier pages are kept, but the agent can't change them, because they are no longer on screen to change.

Pages are numbered `0, 1, 2…`, with no gaps. The last page is the one that submits.

### Page snapshots

The form tells the agent what it holds by sending a **page snapshot**: the page number on screen, and the value of every field on that page.

```json
{
  "page": 0,
  "values": { "full_name": "Asha Rao", "email": null, "newsletter": null }
}
```

- **Always the whole page, never just what changed.** If one snapshot goes missing, the next one fixes everything.
- **Exactly the page's fields.** A snapshot that leaves out one of the page's fields, or names a field from another page, is rejected.
- **`null` means no answer.** So do an empty string and an empty list.
- **Send one before the first session, after every change, and on every page change.** That includes changes the respondent makes by typing.
- **The form is the truth.** A write is only a request; the next snapshot is the fact. If the respondent edits a field the agent filled, the agent goes with the edit.

Before turning the page, and before finishing, the agent asks for one fresh snapshot, so that the respondent's last edit on that page is included.

### Known answers

Before a session starts, the form can give the agent answers it already has, from any page, keyed by field id: a returning respondent's details, say, or answers collected earlier. The agent doesn't ask for those again.

- **Same shapes, same checks.** Known answers use the [value shapes](#field-types) above and are checked like a snapshot: an invalid one is dropped, and the agent asks for that field as usual. A key that is not one of the form's field ids is different: it rejects the whole set with `widget_page_invalid`.
- **They can carry skips.** A [skip marker](#skips) among them keeps an earlier skip.
- **They apply when a session starts.** Changing them affects the next session, not one already running.

On an AmphiVox form, the form keeps a respondent's unfinished answers in their browser for up to a day. If they come back and choose to resume, those answers are the known answers. On the extension there is nothing to send: what the form already shows is the known answers.

### Writes

When the respondent answers, the agent sends a **write**: the field id, the value, and a write id.

- **Taking the value needs no reply.** Put it in the field; your next snapshot confirms it.
- **The same write id can arrive twice.** Applying it a second time must change nothing.
- **A write is always a valid value** for the field's type and rules (see [What you can rely on in a value](#what-you-can-rely-on-in-a-value)).

### Refusals

If your form doesn't take a value, refuse the write with its write id and a reason. There are two kinds of refusal, and only you know which one applies:

| Kind | When | What the agent does |
|---|---|---|
| `validation` | Your own rule rejected the answer, one the field's rules can't express ("that email is already registered", "that coupon has expired"). | Tells the respondent your reason and asks for a different answer. The rejected value is never sent again. |
| `write` | The value couldn't be put into the field at all. | Sends the same value once more, without telling the respondent. Any refusal that doesn't say `validation` is treated this way. |

- **Attempts are limited per field.** A `write` refusal gets two tries: the value, then the one resend. A `validation` refusal gets three, each a different answer. After that, the agent stops and asks the respondent to fill that field in on the form.
- **Rejections are announced together.** When you refuse several answers as `validation` at once, the agent tells the respondent every reason in one reply and asks for all of them again.
- **Keep reporting the truth.** After a refusal, your snapshots should show whatever the field really holds, the rejected value included. The agent never picks up a value you refused from a snapshot.
- **The reason is spoken aloud and kept in AmphiVox's session logs.** Keep it short, and never put the rejected value or anything personal in it. Reasons longer than 200 characters are cut.

### Skips

A respondent can skip any **optional** field by saying so. The agent then reports a **skip** for that field, using a special marker value, `"__CLEAR__"`. Both SDKs give it a name, `SKIP`, so you never have to type the string.

- **A skip is not an answer.** Never put the marker in your form, validate it, or submit it. Leave the field as it is.
- **It has no write id.** Nothing was written, so there is nothing to refuse or confirm.
- **To remember a skip in a later session**, send the marker back as that field's value, among your known answers or in a snapshot while the field is still empty. Otherwise the next session asks the question again. A real answer the respondent gives later always replaces it.
- **Required fields can't be skipped.** Sending the marker for one changes nothing.

On an AmphiVox form, skips are remembered with the respondent's unfinished answers. On the extension, skips need nothing from anyone: the form's field is simply left alone.

### Page turns

When every field on a page is answered and more pages follow, the agent asks before moving on: **"Anything to change before I move on?"** If the respondent agrees, the agent asks the form to go to the next page.

- **The form runs its own Next.** The same checks and navigation as its own Next button, then a snapshot of the new page.
- **If Next didn't move,** the form reports the unchanged page and says the move was blocked. If a field on the page is still unanswered, or was left for the respondent, that explains it: the agent goes back to that field and carries on. **If nothing on the page explains it, the session ends:** the agent tells the respondent it is stuck and disconnects, ending as [`hands_off`](#endings). A block the agent can't see the reason for can't be fixed by voice, so it hands the form back rather than guess. Since the agent only asks to move on once the page looks complete, a block from your own rule usually ends the session.
- **If the form says nothing,** neither a new page nor "blocked", the agent waits ~15 seconds and then treats the move as blocked. A form that never handles page turns therefore ends every multi-page session on its first page, after that pause.
- **Manual fields hold the page.** While a [manual field](#fields-the-agent-never-asks-for) on the page is empty, the agent won't ask to move on. It asks the respondent to fill it in first, or, if it's optional, to say they want to skip it.

### Finishing

When everything it can fill is filled, the agent reads the answers back for the respondent to confirm, then ends the session. **It never submits the form.** Submitting is the form's job, and only after an ending of `complete`.

### Endings

Every session that started ends exactly once, with one of these:

| Ending | What happened | What to do | Extra detail |
|---|---|---|---|
| `complete` | The agent has every answer it needs. A form with any [field the agent never asks for](#fields-the-agent-never-asks-for), or any field it gave up on after repeated attempts, never ends this way: it ends `hands_off`, even if the respondent has since filled everything, and `outstanding` may then be empty. | Run your own checks and submit. | — |
| `hands_off` | The agent filled what it could and is handing the form back: some fields need the respondent, or the agent could not continue. | Don't submit. Let the respondent review and finish on the form. | `outstanding`: the labels of the fields left to fill, which can be empty |
| `timeout` | The session ended after silence, or reached its time limit. | Let the respondent continue on the form, or start again. | `timeout`: `idle` or `max_duration` |
| `manual` | The respondent stopped the session, or your code closed it. | Nothing. | — |
| `error` | A live session failed. Answers already written stay valid. | Let the respondent continue, or start again. | `code` and `message` |

### Silence and time limits

These are the current server settings. Treat them as approximate.

| What | When |
|---|---|
| First nudge (the agent repeats the question) | ~15 seconds of silence after the agent stops speaking |
| Second nudge (the agent warns it will disconnect) | ~15 more seconds of silence |
| Session ends with `timeout: idle` | ~15 more seconds of silence (about 45 seconds in all) |
| Longest session, ending with `timeout: max_duration` | 10 minutes |
| A page turn the form never answers counts as [blocked](#page-turns) | ~15 seconds after the agent asks for it |
| A browser that stops responding is disconnected | after 2 missed checks in a row; checks run every ~4 seconds, starting ~10 seconds into the session |

The silence clock only runs while it is the respondent's turn: not while the agent is speaking or thinking. **Muting is not pausing.** A muted microphone sounds exactly like a silent respondent.

On the website widget, the voice runs inside an iframe that must stay rendered. Hiding it with `display: none` makes the browser slow it down until it looks unresponsive and is disconnected; use the SDK's own `hidden` option instead.

### Languages and voices

The agent speaks **45 languages**, in two groups. Pass the code; the live list is also available as JSON from `GET https://api.amphivox.com/api/voice/languages`.

**Worldwide.** The agent also understands English, so a respondent can switch to English mid-sentence.

**Bharat.** Indian languages; the agent detects which one the respondent is speaking.

**Voice.** The agent speaks with a female voice (`female`, the default) or a male one (`male`). Any other value given at start falls back to `female`; one given when changing the voice later is ignored, and the current voice stays.

The language and the voice are set when a session starts. Changing either one applies to the next session, not the one already running.

### Errors (website widget)

The widget reports problems as an error with a `code` and a `message`. The `message` is plain text that is safe to show; decide what to do from the `code`, and keep a fallback for codes you don't recognise, because this list can grow.

These stop a session before it starts, or reject a message your page sent (a live session carries on after those):

| Code | What happened |
|---|---|
| `invalid_widget_key` | The key doesn't exist, or was deleted. |
| `origin_not_allowed` | Your site isn't on the key's list of allowed websites. |
| `widget_mode_mismatch` | The mode you passed doesn't match the mode the key was created with. |
| `widget_request_invalid` | A voice request carried data the key's mode doesn't accept. Only reachable by calling the API directly. |
| `widget_session_forbidden` | A reconnect named a session that belongs to a different widget key. Only reachable by calling the API directly. |
| `widget_page_required` | No page snapshot was sent before the session was opened. |
| `widget_page_invalid` | A page snapshot didn't match the form's fields. |
| `widget_payload_too_large` | A schema or page snapshot was over the size limit. |
| `widget_not_ready` | A control was used before the widget finished loading. Try again, for example from a click. |
| `invalid_voice_token` / `voice_token_expired` | The session couldn't be authorised, or took too long to start. Open it again. |
| `session_not_found` | The session a reconnect asked for has already ended. Open a new one. |
| `initial_state_invalid` / `initial_state_too_large` | The known answers were malformed, or over the size limit. |
| `session_limit_exceeded` / `session_busy` / `session_spawn_failed` | Voice capacity is full, or the session couldn't start. Try again shortly. |
| `token_fetch_failed` / `connect_timeout` / `connect_failed` / `webrtc_unavailable` | The connection failed, or this browser can't make voice calls. |
| `widget_origin_unavailable` | The browser didn't say which site the widget is embedded in, so it can't run there. |

Only with a **form-linked** key (the form is stored in AmphiVox):

| Code | What happened |
|---|---|
| `widget_schema_changed` | The form was edited while the widget was loading. The next open loads the new version and reuses your last page snapshot if it still fits; otherwise send the page again. |
| `form_not_found` | The key's form no longer exists. |
| `widget_schema_forbidden` | A schema was sent, but this key's form is the stored one. Don't send a schema. |

Only with a **developer-defined** key (your code sends the form):

| Code | What happened |
|---|---|
| `widget_schema_required` | No form yet: none was given, or a snapshot or known answers arrived before a valid one. |
| `widget_schema_invalid` | The form breaks one of the [rules above](#rules-a-field-can-carry). `onError` gives this code and a general message. Where the server can name the field and the rule (never a value), that is in the failed request's HTTP response body, not in `onError`. |
| `widget_schema_locked` | The form was changed while a session was running. The change is discarded; send it again once no session is running. |

A session that started and then failed doesn't report an error; it ends with [`error`](#endings). Its `code` is one of `session_ended_unexpectedly`, `reconnect_failed`, `voice_service_unavailable`, `voice_service_busy`, `voice_service_error` or `voice_session_error`.

## Integration checklist

- [ ] In `onField`, check `value === VoiceForm.SKIP` first. A skip is not an answer: never put it in the form, validate it, or submit it.
- [ ] Call `VoiceForm.reportPage` before the first `open()`, after every change to the page's values, and on every page change. `values` names exactly the page's field ids, `null` for no answer. When the SDK fills your inputs (`target`), report `{ page }` only.
- [ ] Handle `onAdvancePage` on any form that may ever have more than one page: run your own Next, report the page, and call `VoiceForm.reportAdvanceBlocked()` when it did not move.
- [ ] Submit only when `onSessionEnd` reports `reason: 'complete'`. Never submit on `hands_off`, `timeout`, `manual` or `error`.
- [ ] Never hide the widget with `display: none`; use the `hidden` option or `VoiceForm.hide()`.
- [ ] Load `widget.js` and call `VoiceForm.init` in two separate `<script>` tags, neither with `type="module"`.
