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 intodocument.body. - Props: every option and callback, plus
page. - You must: pass
pagebefore 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)
- What it is: the same widget, with its controls returned to you. Takes every option and callback except
page. - You must: report the page yourself with
reportPage(see Component or hook? (opens in a new tab)), and mount it once per widget. - Returns: the controls. The object stays the same for the life of the hook, so it is safe in effect dependencies.
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 / 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. 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 / 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 (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
writeIdcan arrive twice, so applying it again must change nothing. - To refuse: return
{ ok: false, cause: 'validation', reason }or{ ok: false, cause: 'write' }(orfalse). With the hook you can also refuse later withreportWriteResult. 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 tofalse(a rejected promise counts too). The blocked move is reported for you, with the same outcome asreportAdvanceBlocked(). - 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)
- Fires when: a session that started has ended, exactly once.
endingis aWidgetSessionEnding,{ reason, detail? }; see Endings (opens in a new tab). - You must: submit only on
reason === 'complete'. See Handle every ending (opens in a new tab).
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.
erroris aWidgetError,{ code, message }. - You must: switch on
codeand 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)):
| 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 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'spageprop 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, withnullfor no answer. - If you don't:
open()fails withwidget_page_required; a missing or extra field id fails withwidget_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
onFieldhas returned, for example after an async check. To refuse straight away, return the refusal fromonFieldinstead. Both are the same refusal: see Refuse an answer (opens in a new tab). - You must: pass the
fieldIdandwriteIdfromonField,ok: false, and acause('validation'or'write', the default) with a shortreason. - 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. ReturningfalsefromonAdvancePagedoes 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 endingmanual. - If you call
open()too early: before the widget has loaded, it fails withwidget_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.
onMuteChangereports 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
languageorvoiceGenderprop 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:
SKIPis the valueonFieldreceives, with anullwrite id, when the visitor skips an optional field;isSkiptests 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
SKIPto: send a skip back ininitialValuesorpage.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 hasid,label(up to 120 characters),typeandpage, plus any rules (opens in a new tab). Any other key (pages,title,ui,placeholder) is rejected.pageis the field's page number. Pages run0, 1, 2…with none skipped, up to 10 pages.typeis one ofstr,long,int,float,bool,date,enum,email,phone,url,file,address,legal.- Your app stores the files, so the file limits are yours:
maxSizeMbis any positive number, andextensionsany list of up to 50, each up to 20 characters. Case and a leading dot don't matter (.pdfandPDFare the same): they are normalised toPDF.
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.