# React 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](/docs/widget/react), 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, and what happens if you don't. Entries that differ between the widget modes carry a **Form-linked / Developer-defined** switch: pick yours on any of them and every such entry follows. Everything is exported from `@amphivox/react`. The snippets spread `widgetOptions`, the guide's constant holding your mode and key ([Component or hook?](/docs/widget/react#component-or-hook)).

## Entry points

Two ways to mount the same widget. Which to choose: [Component or hook?](/docs/widget/react#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?](/docs/widget/react#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](/docs/agents/voice-agent#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](/docs/widget/react#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](/docs/agents/voice-agent#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](/docs/widget/react#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](/docs/agents/voice-agent#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](/docs/agents/voice-agent#endings).
- **You must:** submit only on `reason === 'complete'`. See [Handle every ending](/docs/widget/react#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](/docs/widget/react#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](/docs/widget/react#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](/docs/agents/voice-agent#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](/docs/widget/react#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](/docs/agents/voice-agent#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](/docs/agents/voice-agent#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](/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.

```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](/docs/agents/voice-agent#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](/docs/widget/react#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](/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 app.

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

:::developer-defined
You pass the schema as the `schema` prop (typed `WidgetSchema`), 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 app 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:

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

const schema: WidgetSchema = {
    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/react#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. Change the `schema` prop only while no session is running. A change made during a session is refused with `widget_schema_locked` and discarded, not kept for later.
:::
