# Vanilla JS 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](/docs/widget/javascript), which puts these together; what the agent does with each of them is in [The AmphiVox Voice Agent](/docs/agents/voice-agent).

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. Entries that differ between the modes carry a **Form-linked / Developer-defined** switch: pick yours on any of them and every such entry follows.

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

:::form-linked
```js
VoiceForm.init({ mode: 'form-linked', widgetKey: 'YOUR_WIDGET_KEY', target: '#application-form' });
```
:::

:::developer-defined
```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](/docs/agents/voice-agent#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](/docs/widget/javascript#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](/docs/agents/voice-agent#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](/docs/agents/voice-agent#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](/docs/agents/voice-agent#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](/docs/agents/voice-agent#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](/docs/widget/javascript#autofill), and [What has to match](/docs/widget/javascript#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](/docs/widget/javascript#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](/docs/agents/voice-agent#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](/docs/widget/javascript#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](/docs/agents/voice-agent#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](/docs/agents/voice-agent#endings).
- **You must:** submit only on `reason === 'complete'`. See [Handle every ending](/docs/widget/javascript#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](/docs/widget/javascript#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](/docs/widget/javascript#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](/docs/agents/voice-agent#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](/docs/widget/javascript#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](/docs/agents/voice-agent#field-types) of the contract. It never describes how a field looks (control type, placeholder, page titles); that stays in your page.

:::form-linked
Your schema is the form in AmphiVox, and it lives there, never in code: the widget loads it by key, so `init()` takes no `schema`. 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 page 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 page must follow. Until it does, the agent asks for fields your page 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.
:::

:::developer-defined
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](/docs/agents/voice-agent#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](/docs/agents/voice-agent#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](/docs/widget/javascript#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](/docs/widget/javascript#change-the-schema-between-sessions).
:::
