API reference · For developers

React API reference

On this pageEntry points

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 (opens in a new tab), which puts these together; what the agent does with each of them is in The AmphiVox Voice Agent (opens in a new tab).

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? (opens in a new tab)).

Entry points

Two ways to mount the same widget. Which to choose: Component or hook? (opens in a new tab)

<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, 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.
<VoiceWidget {...widgetOptions} page={{ page, values: pageValues }} onField={applyValue} />

useVoiceWidget(options)

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

Props and options

The component takes these as props, the hook as its options object. The callbacks are passed the same way.

Prop / optionDefaultDescription
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. Compared by content.
page—Component only, and required there: the page snapshot (opens in a new tab) { page, values }, reported whenever its content changes. The hook takes reportPage 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 / offsetY0Pixels 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.
showTranscripttrueShow the transcript inside the dock. Read once, at mount.
hiddenfalseHide the built-in dock (see Build your own UI (opens in a new tab)).

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 (opens in a new tab)), 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. See Refuse an answer (opens in a new tab).
  • If you don't handle it: the answers never reach your form.
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 (opens in a new tab).
  • 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().
  • If you don't handle it: after ~15 seconds the move counts as blocked, so every multi-page session ends on its first page.
onAdvancePage={() => { if (!pageIsValid(page)) return false; setPage(p => p + 1) }}

onSessionStart()

  • Fires when: the session is live.
onSessionStart={() => setLive(true)}

onSessionEnd(ending)

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 (opens in a new tab).
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 (opens in a new tab)):

CallbackFires 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 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 (opens in a new tab), { 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.
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 instead. Both are the same refusal: see Refuse an answer (opens in a new tab).
  • 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.
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 (opens in a new tab).
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.
<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 (opens in a new tab). A muted visitor counts as silent and the session can time out.
<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 (opens in a new tab).
  • 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.
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 (opens in a new tab).
  • 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 (opens in a new tab).
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 (opens in a new tab) of the contract. It never describes how a field looks (control type, placeholder, page titles); that stays in your app.

Your form lives in AmphiVox; your page mirrors it.

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.

You pass the schema in code; nothing is stored.

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 (opens in a new tab). 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:

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 (opens in a new tab). 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 (opens in a new tab)): 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.