# AmphiVox voice widget: React, form-linked

Everything needed to integrate the React package (`@amphivox/react`) with a form-linked widget key (the form is stored in AmphiVox), 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/react, https://amphivox.com/docs/widget/api/react and https://amphivox.com/docs/agents/voice-agent; the other widget mode is left out.

## Part 1: The integration guide

`@amphivox/react` adds an AmphiVox voice agent to any React app. It injects a single floating-mic iframe and connects it to your form state through props and callbacks, with zero runtime dependencies. Your app keeps the form, its validation, its page navigation and its submission.

> Plain HTML, or not using React? See the [Vanilla JS guide](https://amphivox.com/docs/widget/javascript) for the `widget.js` script tag.

> **Two companion parts.** The [API reference](#part-2-the-api-reference) lists every prop, control, callback and type of `@amphivox/react`, 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 app                       @amphivox/react           AmphiVox iframe           voice agent
 ────────                       ───────────────           ───────────────           ───────────
 form state, its rules,  ──►    page prop / reportPage ──► relays the page   ──►    asks, checks,
 Next and submit                controls (open, mute…)     and your controls        writes values
                         ◄──    onField, onAdvancePage,   ◄── relays the agent ◄──
                                onSessionEnd, …
```

| Your app 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 ([`<VoiceWidget>`](#voicewidget-props)), or your own UI ([`useVoiceWidget()`](#usevoicewidget-options) with [`hidden`](#props-and-options)) | 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 | Always your app, through [`onField`](#onfield-fieldid-value-writeid): React owns your form state | How an answer gets from the agent into your form. (The vanilla `widget.js` also has [autofill](https://amphivox.com/docs/widget/javascript#autofill), which fills plain HTML inputs for you; a React app doesn't need it.) |

Every widget key is created in the **Widgets** area of the AmphiVox dashboard, in one of two modes. The `mode` you pass 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 app 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, in your own app. |
| `'developer-defined'` | In your code: you pass the whole [`schema`](#schema) as a prop. Your code supplies the schema for each session. | Your app already has its own form, and you want to add the voice agent to it without building anything in AmphiVox. |



### Install

```bash
npm install @amphivox/react
```

Peer dependency: `react >= 16.8` (tested on 18 and 19). Ships ESM, CJS, and TypeScript types.

Add your app's origin to the widget key's allowed websites in the **Widgets** area, or connections are rejected.

> **Serve your app 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.

### Quick start

Render [`<VoiceWidget>`](#voicewidget-props) with the page on screen and its values. It renders nothing visible (the floating mic lives in the injected iframe) and reports the page to the agent whenever `page` changes.

```tsx
import { useState } from 'react'
import { VoiceWidget, isSkip } from '@amphivox/react'

// Every field id, by page: copy it from the widget's page in the Widgets area.
const PAGE_FIELDS = [['full_name', 'email'], ['goal']]

function ApplicationForm() {
  const [page, setPage] = useState(0)   // the page number on screen: 0, 1, 2…
  const [values, setValues] = useState<Record<string, unknown>>({})
  const pageValues = Object.fromEntries(PAGE_FIELDS[page].map(id => [id, values[id] ?? null]))

  return (
    <form>
      {/* …your inputs for this page, bound to `values`… */}
      <VoiceWidget
        mode="form-linked"
        widgetKey="YOUR_WIDGET_KEY"
        page={{ page, values: pageValues }}
        onField={(fieldId, value) => {
          if (isSkip(value)) return   // a skipped optional field: not an answer
          setValues(v => ({ ...v, [fieldId]: value }))
        }}
        onAdvancePage={() => {             // your own Next
          if (!pageIsValid(page)) return false   // didn't move: the agent is told
          setPage(p => p + 1)
        }}
        onSessionEnd={(ending) => {
          if (ending.reason === 'complete') submitMyForm(values)   // your own validation + submit
        }}
      />
    </form>
  )
}
```

There is no [`schema`](#schema) prop: the widget loads your form by key. The widget key and every field id are on the widget's page in the **Widgets** area, whose React snippet fills in `PAGE_FIELDS` for you.

`page.values` names **exactly** that page's field ids, `null` for no answer. The component compares `page` by content, so rebuilding the same object every render sends nothing. On a one-page form [`onAdvancePage`](#onadvancepage) never fires; keep it anyway, so adding a page later doesn't strand your visitors on page 0.

> **When your Next refuses,** return `false` from `onAdvancePage`. The page didn't change, so the component has nothing new to report; `false` is how the agent learns the move was [blocked](#page-turns). Without it, the agent waits ~15 seconds before treating the move as blocked.

### Component or hook?

The package has two entry points. [`<VoiceWidget>`](#voicewidget-props) is exactly [`useVoiceWidget()`](#usevoicewidget-options) plus one effect that reports its `page` prop.

| | `<VoiceWidget>` | `useVoiceWidget()` |
|---|---|---|
| Reporting the page | Automatic, from the `page` prop, whenever its content changes | You call [`reportPage()`](#reportpage-snapshot): before [`open()`](#open-close), after every change, on every page change |
| Controls you get | None | `open`, [`close`](#open-close), [`mute`](#mute-unmute), [`unmute`](#mute-unmute), [`setLanguage`](#setlanguage-code-setvoicegender-gender), [`setVoiceGender`](#setlanguage-code-setvoicegender-gender), `reportPage`, [`reportWriteResult`](#reportwriteresult-result), [`reportAdvanceBlocked`](#reportadvanceblocked) |
| Refusing a write | Return a refusal from [`onField`](#onfield-fieldid-value-writeid) | Return it, or call `reportWriteResult` later (after an async check) |
| A Next that didn't move | Return `false` from [`onAdvancePage`](#onadvancepage) | Return `false`, or call `reportAdvanceBlocked()` |
| Choose it when | You use our dock | You [build your own UI](#build-your-own-ui), or refuse a write after an async check |

From here on, the snippets spread `widgetOptions`: the mode and key (and schema) from your quick start, in one constant:

```tsx
const widgetOptions = { mode: 'form-linked', widgetKey: 'YOUR_WIDGET_KEY' } as const
```

The same integration, both ways:

```tsx
// The component: the page is a prop.
<VoiceWidget {...widgetOptions} page={{ page, values: pageValues }} onField={applyValue} />

// The hook: the page is your job.
const widget = useVoiceWidget({ ...widgetOptions, onField: applyValue })
const snapshot = JSON.stringify({ page, values: pageValues })
useEffect(() => { widget.reportPage(JSON.parse(snapshot)) }, [widget, snapshot])
```

What the hook makes you responsible for, and what happens if you skip it:

- **Report before the first `open()`.** Without a report, `open()` fails with `widget_page_required`.
- **Report after every change.** A missing report leaves the agent working from stale values: it may ask again for an answer the visitor just typed.
- **Report on every page change.** The agent only asks about the page you last reported.
- **Mount the hook once.** Each mounted hook injects its own iframe and dock.

### A session, step by step

One conversation on a two-page form, showing what your app does at each step:

```text
1. Form renders      page={{ page: 0, values }}  (or reportPage)
2. Visitor taps mic  onSessionStart()
3. Visitor answers   onField('email', 'a@b.com', 'w_17')
                     → set it in state; the new `page` reports it
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:  setPage(1); the new page reports itself
                     → Next failed: return false from onAdvancePage:
                       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).

### Build your own UI

Our dock is the floating mic button and its panel. To use your own components instead, call [`useVoiceWidget()`](#usevoicewidget-options) with [`hidden: true`](#props-and-options) and build each part from these:

| The dock's part | What you use |
|---|---|
| Start and end a session | [`open()` / `close()`](#open-close); [`onSessionStart`](#onsessionstart) and [`onSessionEnd`](#onsessionend-ending) tell you when it happens |
| Mute | [`mute()` / `unmute()`](#mute-unmute); [`onMuteChange(muted)`](#live-status-callbacks) |
| Language and voice | [`setLanguage(code)` / `setVoiceGender(gender)`](#setlanguage-code-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-error) |

```tsx
function VoiceButton({ page, values }: { page: number; values: Record<string, unknown> }) {
  const [live, setLive] = useState(false)
  const [botTalking, setBotTalking] = useState(false)
  const [muted, setMuted] = useState(false)
  const [lines, setLines] = useState<{ speaker: string; text: string }[]>([])

  const widget = useVoiceWidget({
    ...widgetOptions,
    hidden: true,
    onField: applyValue,
    onSessionStart: () => { setLive(true); setLines([]) },
    onSessionEnd: () => setLive(false),
    onSpeaking: (who, active) => { if (who === 'bot') setBotTalking(active) },
    onMuteChange: setMuted,
    onTranscript: (speaker, text, index) =>
      setLines(l => { const next = [...l]; next[index] = { speaker, text }; return next }),
  })
  const snapshot = JSON.stringify({ page, values })
  useEffect(() => { widget.reportPage(JSON.parse(snapshot)) }, [widget, snapshot])

  return (
    <>
      <button type="button" onClick={live ? widget.close : widget.open}>{live ? 'Stop' : 'Answer by voice'}</button>
      <button type="button" onClick={muted ? widget.unmute : widget.mute} disabled={!live}>{muted ? 'Unmute' : 'Mute'}</button>
      <select onChange={e => widget.setLanguage(e.target.value)} disabled={live}>
        <option value="en">English</option>
        <option value="hi">हिन्दी</option>
      </select>
      {botTalking && <span>Speaking…</span>}
      <ul>{lines.map((line, i) => <li key={i}>{line.speaker}: {line.text}</li>)}</ul>
    </>
  )
}
```

Your form works exactly as before: the hook still needs [`reportPage()`](#reportpage-snapshot) (see [Component or hook?](#component-or-hook)), and answers still arrive in `onField`.

- **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 options they don't show are the ones from your [quick start](#quick-start), and `widgetOptions` is the constant from [Component or hook?](#component-or-hook).

#### Handle a blocked Next

Your own Next decides whether the page moves. When it doesn't, return `false` from [`onAdvancePage`](#onadvancepage) (or resolve to `false`, when your check is async):

```tsx
<VoiceWidget
  /* …the rest of your props… */
  page={{ page, values: pageValues }}
  onAdvancePage={async () => {
    const errors = await validatePage(page)
    if (errors.length > 0) return false   // the unchanged page is already reported
    setPage(p => p + 1)                   // the new page reports itself
  }}
/>
```

A rejected promise counts as "didn't move" too. With the hook you can also call [`reportAdvanceBlocked()`](#reportadvanceblocked) yourself instead; use one or the other, not both.

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. 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 control 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. In a React app the value lands in your state, so in practice you only send `validation`.

You can refuse straight away, or after an async check, whichever your rule needs:

| Your check | Refuse by | Works with |
|---|---|---|
| Synchronous | Returning `{ ok: false, cause, reason }` from [`onField`](#onfield-fieldid-value-writeid) (`false` alone is short for `{ ok: false, cause: 'write' }`) | The component and the hook |
| Async, such as a server round trip | Returning nothing from `onField`, then calling `reportWriteResult({ fieldId, writeId, ok: false, cause, reason })` with the same `writeId` | The hook only: the component returns no controls |

Straight away:

```tsx
onField={(fieldId, value) => {
  if (isSkip(value)) return
  if (fieldId === 'email' && registeredEmails.has(value as string)) {
    return { ok: false, cause: 'validation', reason: 'That email already has an account.' }
  }
  setValues(v => ({ ...v, [fieldId]: value }))
}}
```

After an async check:

```tsx
const widget = useVoiceWidget({
  ...widgetOptions,
  onField: (fieldId, value, writeId) => {
    if (isSkip(value)) return
    if (fieldId !== 'coupon') { setValues(v => ({ ...v, [fieldId]: value })); return }
    checkCoupon(value as string).then((valid) => {
      if (valid) setValues(v => ({ ...v, coupon: value }))
      else widget.reportWriteResult({
        fieldId, writeId: 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. 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 [`SKIP`](#skip-isskip-value) (test it with [`isSkip`](#skip-isskip-value)) with a `null` write id. Keep the field id, and pass the marker back in [`initialValues`](#props-and-options) so the next session doesn't ask again:

```tsx
import { SKIP, isSkip } from '@amphivox/react'

const [skipped, setSkipped] = useState<string[]>([])
const initialValues = Object.fromEntries(skipped.map(id => [id, SKIP]))

<VoiceWidget
  /* …the rest of your props… */
  initialValues={initialValues}
  onField={(fieldId, value) => {
    if (isSkip(value)) {                  // not an answer: leave the field as it is
      setSkipped(s => (s.includes(fieldId) ? s : [...s, fieldId]))
      return
    }
    setSkipped(s => s.filter(id => id !== fieldId))
    setValues(v => ({ ...v, [fieldId]: value }))
  }}
/>
```

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, drop its key from `initialValues`. 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 in your form. 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:

```tsx
<input
  type="file"
  onChange={async (e) => {
    const file = e.target.files?.[0]
    if (!file || !acceptsFile(file)) return     // your own size and type check
    await uploadToYourServer(file)
    setValues(v => ({ ...v, cv: { uploaded: true } }))   // `null` until then
  }}
/>
```

#### 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:

```tsx
<VoiceWidget
  /* …the rest of your props… */
  initialValues={{ full_name: user.name, email: user.email }}
/>
```

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`). The prop is compared by content and applies to the next session; `null` clears it.

#### Handle every ending

```tsx
onSessionEnd={(ending) => {
  switch (ending.reason) {
    case 'complete':                        // every answer is in: validate and submit
      submitMyForm(values)
      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 in your state
    case 'error':                           // a live session failed; answers already written stay valid
      setNotice('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-error) reports anything that stopped a session before it started, and messages from your app that the widget rejected. Switch on the `code`; show the `message`, which is safe to display:

```tsx
import type { WidgetError } from '@amphivox/react'

function handleVoiceError({ code, message }: WidgetError) {
  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 values and the schema disagree
      console.error('[voice page]', message)
      break
    case 'session_limit_exceeded':
    case 'session_busy':
      setNotice('Voice is busy right now. Try again in a minute.')
      break
    default:                                // the list can grow: always keep a default
      setNotice(message)
  }
}
```

Every code and what it means: [Errors](#errors-website-widget).

#### Use react-hook-form

When a form library owns your values and validation, connect each part of it to the widget:

```tsx
import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { useVoiceWidget, isSkip } from '@amphivox/react'

function ApplicationForm() {
  const [page, setPage] = useState(0)
  const { register, setValue, watch, trigger, getFieldState, handleSubmit } = useForm()
  const pageIds = PAGE_FIELDS[page]   // this page's field ids

  const widget = useVoiceWidget({
    ...widgetOptions,
    onField: (fieldId, value, writeId) => {
      if (isSkip(value)) return
      setValue(fieldId, value, { shouldDirty: true })
      // Your library's rules decide; a failed rule is a validation refusal.
      trigger(fieldId).then((ok) => {
        if (!ok && writeId) widget.reportWriteResult({
          fieldId, writeId, ok: false, cause: 'validation',
          reason: getFieldState(fieldId).error?.message ?? 'That answer is not accepted.',
        })
      })
    },
    onAdvancePage: async () => {
      if (!(await trigger(pageIds))) return false   // blocked: the agent is told
      setPage(p => p + 1)
    },
    onSessionEnd: (ending) => {
      if (ending.reason === 'complete') handleSubmit(submitMyForm)()
    },
  })

  // The page report comes from the library's own values.
  const all = watch()
  const snapshot = JSON.stringify({ page, values: Object.fromEntries(pageIds.map(id => [id, all[id] ?? null])) })
  useEffect(() => { widget.reportPage(JSON.parse(snapshot)) }, [widget, snapshot])

  return <form>{/* …inputs registered with `register`… */}</form>
}
```

`PAGE_FIELDS` lists your field ids page by page, as in the form-linked quick start; with a developer-defined schema, build it from `schema.fields`. The same pattern fits other form libraries: the write goes through the library's setter, the page report is built from its values, a failed rule becomes a `validation` refusal, its page check decides the Next, and its submit runs only on `complete`.

#### Use it in Next.js

All DOM work happens inside effects, so the package never runs on the server. In the App Router, add the client directive to the file that imports it:

```tsx
'use client'
import { VoiceWidget } from '@amphivox/react'
```

The hook supports StrictMode's development-only effect replay on first mount, and ordinary prop changes keep an active call running. A real unmount and remount does not keep the call. Changing [`widgetKey`](#props-and-options) or `mode` replaces the iframe, and unmounting removes it and every listener; either one ends the call.

#### Run two widgets on one page

Each hook ignores any message that didn't come from **its own** iframe, so several widgets on one page each receive only their own events. Each injects its own dock, so separate them:

```tsx
// Each key's own mode and key (and schema), like `widgetOptions`:
<VoiceWidget {...salesOptions} page={salesPage} position="bottom-right" />
<VoiceWidget {...supportOptions} page={supportPage} position="bottom-left" />
```

### Content Security Policy

If your app sends a `Content-Security-Policy` header, the widget needs one directive:

```
Content-Security-Policy: frame-src https://amphivox.com;
```

You don't need `script-src` for AmphiVox (the package is bundled into your JavaScript), nor `connect-src` or `media-src`: the package opens no connections of its own. The voice session runs inside the iframe under AmphiVox's CSP, and its audio over WebRTC.

### Troubleshooting

> **`widget_page_required`.** Pass `page` (or call [`reportPage`](#reportpage-snapshot)) before the session opens.

> **`widget_page_invalid`.** `page.values` named a field the page doesn't have, or missed one it does.

> **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 sends `Referrer-Policy: no-referrer` or `same-origin`. Both hide your page's origin from the documents it frames. Use the default `strict-origin-when-cross-origin` (or `origin`, `strict-origin`, `no-referrer-when-downgrade`). ([Why](https://developer.mozilla.org/en-US/docs/Web/API/Location/ancestorOrigins).)

> **Mic blocked.** Check HTTPS (or `localhost`), and that your server doesn't send `Permissions-Policy: microphone=()`.

> **Screen sleeps during a session.** Only a `Permissions-Policy: screen-wake-lock=()` header on your page overrides the iframe's own request: drop it, or include the widget's origin.

> **[`setLanguage`](#setlanguage-code-setvoicegender-gender) / [`setVoiceGender`](#setlanguage-code-setvoicegender-gender) did nothing mid-call.** By design: both apply to the next session.

> **The agent asks for a field my form doesn't have.** The form was edited in AmphiVox and your app hasn't caught up. Update your form's fields, ids and pages to match.

> **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`](#props-and-options) prop.

## Part 2: The API reference

Every export of `@amphivox/react`: the component and the hook, their props, the callbacks, the hook's controls, the helpers and the types, and the schema they accept. New to the package? Start with the [React 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, and what happens if you don't. Everything is exported from `@amphivox/react`. The snippets spread `widgetOptions`, the guide's constant holding your mode and key ([Component or hook?](#component-or-hook)).

### Entry points

Two ways to mount the same widget. Which to choose: [Component or hook?](#component-or-hook)

#### `<VoiceWidget {...props} />`

- **What it is:** the hook plus one effect that reports `page`. It renders nothing in your tree; the dock is an iframe it injects into `document.body`.
- **Props:** every [option and callback](#props-and-options), plus `page`.
- **You must:** pass `page` before the visitor opens a session, and keep it current.
- **Returns:** nothing you can call. To refuse a write after an async check, or to start a session from your own button, use the hook.

```tsx
<VoiceWidget {...widgetOptions} page={{ page, values: pageValues }} onField={applyValue} />
```

#### `useVoiceWidget(options)`

- **What it is:** the same widget, with its [controls](#controls) returned to you. Takes every [option and callback](#props-and-options) except `page`.
- **You must:** report the page yourself with [`reportPage`](#reportpage-snapshot) (see [Component or hook?](#component-or-hook)), and mount it once per widget.
- **Returns:** the [controls](#controls). The object stays the same for the life of the hook, so it is safe in effect dependencies.

```tsx
const widget = useVoiceWidget({ ...widgetOptions, onField: applyValue })
```

### Props and options

The component takes these as props, the hook as its options object. The [callbacks](#callbacks) are passed the same way.

| Prop / 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). Compared by content. |
| `page` | — | Component only, and required there: the [page snapshot](#page-snapshots) `{ page, values }`, reported whenever its content changes. The hook takes [`reportPage`](#reportpage-snapshot) instead. |
| `initialValues` | — | Known answers or remembered `SKIP` markers from any page, keyed by field id. Compared by content. |
| `position` | `'bottom-right'` | Or `'bottom-left'`. |
| `offsetX` / `offsetY` | `0` | Pixels from the anchored side / bottom. |
| `language` | `'en'` | The language for the first session; a change applies to the next. |
| `voiceGender` | `'female'` | The voice for the first session, `'female'` or `'male'`; a change applies to the next. |
| `showTranscript` | `true` | Show the transcript inside the dock. Read once, at mount. |
| `hidden` | `false` | Hide the built-in dock (see [Build your own UI](#build-your-own-ui)). |

`page`, `schema` and `initialValues` are compared by content, so rebuilding the same object every render sends nothing. Only a change to `widgetKey` or `mode` re-injects the iframe.

### Callbacks

Passed like the props. Every one is optional, but a working integration handles `onField`, `onAdvancePage` and `onSessionEnd`. Callbacks are read from a ref, so passing a new function on every render never restarts anything.

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

- **Fires when:** the agent has an answer for a field (a [write](#writes)), or the visitor skipped one (`isSkip(value)`, `writeId === null`).
- **You must:** check for a skip first, then put the value in your state. 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`). With the hook you can also refuse later with [`reportWriteResult`](#reportwriteresult-result). See [Refuse an answer](#refuse-an-answer).
- **If you don't handle it:** the answers never reach your form.

```tsx
onField={(fieldId, value) => { if (!isSkip(value)) setValues(v => ({ ...v, [fieldId]: value })) }}
```

#### `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. If it moves, the new page reports itself (component) or you report it (hook).
- **If it didn't move:** return `false`, or a promise that resolves to `false` (a rejected promise counts too). The blocked move is reported for you, with the same outcome as [`reportAdvanceBlocked()`](#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.

```tsx
onAdvancePage={() => { if (!pageIsValid(page)) return false; setPage(p => p + 1) }}
```

#### `onSessionStart()`

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

```tsx
onSessionStart={() => setLive(true)}
```

#### `onSessionEnd(ending)`

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

```tsx
onSessionEnd={({ reason }) => { if (reason === 'complete') submitMyForm(values) }}
```

#### `onError(error)`

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

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

#### `onReconcileRequest()`

- **Fires when:** the agent asked for a fresh page report. The widget has already answered with your last one, 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. |

### Controls

Returned by [`useVoiceWidget`](#usevoicewidget-options) only: `<VoiceWidget>` returns none.

**Before the widget has loaded**, the controls 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`, `setLanguage`, `setVoiceGender`) are kept and applied once it has loaded.

#### `reportPage(snapshot)`

- **What it is:** a [page snapshot](#page-snapshots), `{ page, values }`: the page number on screen and every one of its field values. The component's `page` prop does the same job.
- **Call it:** before the first session, after every change to the page's values, and on every page change.
- **You must:** name exactly the page's field ids in `values`, with `null` for no answer.
- **If you don't:** `open()` fails with `widget_page_required`; a missing or extra field id fails with `widget_page_invalid`; a stale snapshot means the agent works from old values.

```tsx
widget.reportPage({ page: 1, values: { goal: null, budget: 5000 } })
```

#### `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.

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

#### `reportAdvanceBlocked()`

- **Call it:** from `onAdvancePage`, when your Next did not move. Returning `false` from `onAdvancePage` does the same and works with the component too; use one or the other, not both.
- **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).

```tsx
onAdvancePage: () => { if (!pageIsValid(page)) widget.reportAdvanceBlocked(); else setPage(p => p + 1) }
```

#### `open()` / `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.

```tsx
<button type="button" onClick={widget.open}>Answer by voice</button>
```

#### `mute()` / `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.

```tsx
<button type="button" onClick={muted ? widget.unmute : widget.mute}>{muted ? 'Unmute' : 'Mute'}</button>
```

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

- **Call them:** to choose the language and voice for the **next** session; a session already running keeps its own. Changing the `language` or `voiceGender` prop does the same. 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.

```tsx
widget.setLanguage('hi')
```

### Helpers

#### `SKIP` / `isSkip(value)`

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

```tsx
if (isSkip(value)) return
```

### Types

Exported for TypeScript: `VoiceWidgetOptions`, `VoiceWidgetProps`, `VoiceWidgetControls`, `VoiceGender`, `WidgetMode`, `WidgetSchema`, `WidgetField`, `WidgetFieldType`, `WidgetAddressComponent`, `WidgetPageSnapshot`, `WidgetWriteResult`, `WidgetWriteRefusal`, `WidgetWriteFailureCause`, `WidgetSessionEnding`, `WidgetError`, `WidgetErrorCode`.

### 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 app.

Your schema is the form in AmphiVox, and it lives there, never in code: the widget loads it by key, so there is no `schema` prop. Edit it in the widget's editor in the **Widgets** area, or, for a widget linked to one of your published forms, in that form's own editor.

**Keeping your app in step with it is your job.** If the form gains a required field, renames an id or moves a field to another page, your app must follow. Until it does, the agent asks for fields your form can't hold, and you see refused writes, blocked page turns or a `hands_off`. An edit takes effect from the next session; a session already running keeps the version it started with.

The form builder enforces the limits: up to **200** fields in total, **60 per page**, and **10** pages. File limits are the builder's too: `maxSizeMb` from 1 to 20, and `extensions` from the supported upload list.

## 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](https://amphivox.com/docs/widget/javascript) or the [React guide](#part-1-the-integration-guide); 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 `isSkip(value)` first. A skip is not an answer: never put it in state, validate it, or submit it.
- [ ] Report the page before the first session, after every change to its values, and on every page change: the `page` prop of `<VoiceWidget>`, or `reportPage` from `useVoiceWidget`. `values` names exactly the page's field ids, `null` for no answer.
- [ ] Handle `onAdvancePage` on any form that may ever have more than one page: run your own Next, and return `false` when it did not move (or call `reportAdvanceBlocked()` from the hook).
- [ ] 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` prop.
- [ ] Mount `useVoiceWidget` (or `<VoiceWidget>`) once per widget: each one injects its own iframe.
