@ear3/voice-interviewer
One package, two entry points. The package root is the branded
turnkey widget — zero UI work, pixel-matched to Ear3’s hosted responder
page. The /headless subpath is the SDK layer underneath it —
VoiceClient, React hooks, plugins, and a bare <Ear3Interview renderControls> — for a fully custom UI.
npm install @ear3/voice-interviewerimport { Ear3VoiceInterview } from '@ear3/voice-interviewer' // widget
import { VoiceClient } from '@ear3/voice-interviewer/headless' // headlessWant a polished, production-ready visual with zero UI work? Use the
package root. Need a fully custom look, or building a headless /
server-side integration? Use /headless — it does not pull in the
widget’s WebGL/Three.js dependencies, so it stays lean for non-widget
use (including server-side createSession() calls).
<Ear3VoiceInterview /> — the branded widget (package root)
Turnkey, branded voice interview widget — the Ear3 hosted responder experience as a React component. One component, three screens, pixel-matched to the responder page: landing card → live conversation with the signature Plasma orb → completion card with stats and feedback.
import { Ear3VoiceInterview } from '@ear3/voice-interviewer'
<Ear3VoiceInterview
interviewId={process.env.NEXT_PUBLIC_EAR3_INTERVIEW_ID!}
publishableKey={process.env.NEXT_PUBLIC_EAR3_VOICE_INTERVIEWER_KEY!}
onComplete={({ sessionId }) => router.push(`/done?session=${sessionId}`)}
/>Brand colors and typography are fixed by design — this is the
Ear3-branded experience. If you need your own look, use the
/headless subpath below: the renderControls prop or the fully
headless VoiceClient + hooks.
The three screens
- Landing — “AI Voice Interview” badge, your
title/description, the “What to expect” list, auto-saved + confidential rows, a keep-tab-open warning, and the orange Connect a microphone and start CTA. - Live session — Ear3’s WebGL Plasma orb, state-colored like the responder (orange while speaking, green while listening, purple while thinking), the mic bubble with mute, restart and settings controls, and an End button with a confirmation dialog.
- Completion — green check, answers/duration stats, an optional
feedback box (
onFeedback), and “What happens next?”.
Styles ship inline with the component (styled from the first SSR paint) — no Tailwind, no CSS imports, no setup.
Widget props
| Prop | Type | Required | Notes |
|---|---|---|---|
interviewId | string | ✓ | Deployment id (where?) |
publishableKey | string | ✓ | pk_live_… / pk_test_… (browser-safe) |
apiBase | string | Override Ear3 host | |
metadata | Record<string, unknown> | Attached to the session, echoed in webhooks | |
participantName | string | Respondent name shown in the dashboard | |
participantExternalId | string | Your own respondent id — echoed on the responses API | |
title | string | Landing headline | |
description | string | Landing description | |
respondentFirstName | string | Renders Hello, {name}! above the title | |
estimatedMinutes | string | number | Renders the estimated-time row | |
autoStart | boolean | Skip the landing screen, connect on mount | |
branding | 'visible' | 'hidden' | Powered-by badge. Free tier renders it | |
brandingRef | string | Attribution ref appended to the badge link | |
onComplete | ({ sessionId, transcript }) | Interview finished (naturally or via End) | |
onError | (error: VoiceError) => void | Connect / RTVI failures | |
onFeedback | (message: string) => void | Completion-screen feedback text | |
className / style | — | Outer wrapper styling |
VoiceError shape, error codes, and edge cases — see Errors.
Widget building blocks
For hosts that want to remix the widget while keeping the headless
client underneath, the pieces are exported directly from the package
root: PlasmaOrb (with BotState + stateLabel), MicBubble,
Branding, and ensureStyles() (inject the stylesheet once when
composing your own tree).
@ear3/voice-interviewer/headless — build your own UI
Same package, custom-UI subpath. Everything below imports from
@ear3/voice-interviewer/headless, not the package root.
<Ear3Interview> is the drop-in (still un-styled — customize via
renderControls). Underneath it is a headless VoiceClient plus
React hooks / plugins — reach for those when you want your own
provider or event routing. Below that is a thin RTVI factory
(createRtviClient / useRtviClient) if you want the raw
Pipecat client and full callback surface.
Depends on
@pipecat-ai/client-js and
@pipecat-ai/daily-transport
(installed as transitive deps — adds ~150 KB gzipped to your bundle;
none of the widget’s WebGL/Three.js weight).
<Ear3Interview /> — drop-in, un-styled component
Runs the RTVI client inside your React tree — no iframe, mic prompt asked in your origin, full control over the voice UI.
'use client'
import { Ear3Interview } from '@ear3/voice-interviewer/headless'
import { useRouter } from 'next/navigation'
export default function InterviewPage() {
const router = useRouter()
return (
<Ear3Interview
interviewId={process.env.NEXT_PUBLIC_EAR3_INTERVIEW_ID!}
publishableKey={process.env.NEXT_PUBLIC_EAR3_VOICE_INTERVIEWER_KEY!}
metadata={{ userId: '42' }}
onComplete={(e) => router.push(`/done?session=${e.sessionId}`)}
/>
)
}That’s it. The component:
- Instantiates a
VoiceClient(see below) with atranscriptPlugin - Calls
POST /api/v1/interviews/{interviewId}/sessionsto mint the session + fetch Daily room credentials - Asks for mic permission in your origin
- Connects the WebRTC room and waits for
bot-ready - Renders the built-in status / transcript / connect-disconnect UI
- Fires
onCompletewhen the bot signalsinterview.completed
Under the hood
Your React tree
├── <Ear3Interview>
│ │
│ ├── new VoiceClient({ interviewId, publishableKey, metadata, plugins })
│ │
│ ├── POST /api/v1/interviews/{id}/sessions ──► app.ear3.ai
│ │ └─► spawns Pipecat Cloud worker
│ │ └─► returns { room_url, token }
│ │
│ ├── PipecatClient (from @pipecat-ai/client-js)
│ │ └── DailyTransport (WebRTC)
│ │ audio in/out ◄──► Daily SFU ◄──► Pipecat Cloud worker
│ │ (STT · LLM · TTS · VAD)
│ │
│ └── Default UI or your `renderControls` render propThe Pipecat Cloud worker is the same one that powers the iframe path — same STT (Deepgram), same LLM, same TTS (Cartesia), same VAD (Silero).
Custom UI
Pass renderControls — the built-in UI is then not rendered at
all, while session mint, audio playback, persistence and
auto-disconnect stay handled for you. The function receives the full
state + connect/disconnect handlers:
<Ear3Interview
interviewId={…}
publishableKey={…}
autoConnect={false}
renderControls={({ status, botReady, transcript, botSpeaking, userSpeaking, connect, disconnect }) => (
<div className="my-voice-ui">
<MyStatusPill status={status} />
<MyWaveform speaking={botSpeaking || userSpeaking} />
<MyTranscript entries={transcript} />
{status === 'idle' && (
<button onClick={connect} disabled={!botReady}>Talk to Ear3</button>
)}
{status === 'connected' && (
<button onClick={disconnect}>End</button>
)}
</div>
)}
/>Props
| Prop | Type | Required | Notes |
|---|---|---|---|
interviewId | string | ✓ | Deployment id (where?) |
publishableKey | string | ✓ | pk_live_… or pk_test_… |
metadata | Record<string, unknown> | Round-trips into transcripts + webhooks | |
apiBase | string | Override Ear3 host. Default https://app.ear3.ai | |
participantName | string | Respondent name shown in the dashboard. Default 'sdk-respondent' | |
participantExternalId | string | Your own respondent id — echoed back as participant_external_id on the responses API | |
autoDisconnect | boolean | Hang up when the bot ends the interview (releases the mic, finalizes the audio recording). Default true | |
autoConnect | boolean | Auto-connect on mount. Default true | |
renderControls | (controls: Ear3InterviewControls) => ReactNode | Replace the default UI | |
style | CSSProperties | Inline style on the outer wrapper | |
className | string | Class name on the outer wrapper | |
onComplete | (event: Ear3InterviewCompleteEvent) => void | Fires when the bot emits the end_conversation server-message | |
onError | (err: VoiceError) => void | Fires on connect / RTVI failures | |
debug | boolean | Verbose console.log tracing of server messages + persist calls. Default false |
Ear3InterviewControls
interface Ear3InterviewControls {
status: 'idle' | 'connecting' | 'connected' | 'ended' | 'error'
botReady: boolean
transcript: TranscriptEntry[]
botSpeaking: boolean
userSpeaking: boolean
connect: () => Promise<void>
disconnect: () => Promise<void>
}
interface TranscriptEntry {
id: string // stable per turn — use as React key
role: 'user' | 'bot'
text: string
final: boolean // false for in-progress user partials + bot TTS chunks
timestamp: number // ms epoch
}
interface Ear3InterviewCompleteEvent {
type: 'ear3:complete'
sessionId: string
transcript: TranscriptEntry[] // full conversation snapshot, oldest → newest
}Transcript ids are stable per turn (bot-1, user-2, …) — one entry
per turn, updated in place while the turn is live, flipped to
final: true when the speaking role switches.
VoiceClient + hooks
<Ear3Interview> is a thin wrapper around these primitives. Skip it if
you want your own provider, your own event routing, or to control when
the client is instantiated.
new VoiceClient(options)
import { VoiceClient, transcriptPlugin, reconnectPlugin } from '@ear3/voice-interviewer/headless'
const client = new VoiceClient({
interviewId: 'dpl_…',
publishableKey: 'pk_live_…',
metadata: { userId: '42' },
plugins: [transcriptPlugin(), reconnectPlugin({ maxRetries: 5 })],
})
await client.connect()Options
| Option | Type | Notes |
|---|---|---|
interviewId | string | Deployment id |
publishableKey | string | pk_live_… / pk_test_… |
apiBase | string | Default https://app.ear3.ai |
metadata | Record<string, unknown> | Round-trips into transcripts + webhooks |
participantName | string | Respondent name shown in the dashboard. Default 'sdk-respondent' |
participantExternalId | string | Your own respondent id — echoed back as participant_external_id on the responses API |
autoDisconnect | boolean | Hang up when the bot ends the interview. Default true |
plugins | VoicePlugin[] | Registered on construction, install() called immediately |
debug | boolean | Verbose console.log tracing of server messages + persist calls. Default false |
Methods
| Method | Notes |
|---|---|
connect() | POST /api/v1/interviews/{id}/sessions, spin up transport, connect Daily room |
disconnect() | Close the Daily room. Safe to call in any state |
use(plugin) | Register a plugin at runtime. Chainable |
destroy() | disconnect + uninstall plugins + remove all listeners |
on(event, fn) | Typed event subscription |
off(event, fn) | Unsubscribe |
State machine
idle → connecting → ready ⇄ speaking (bot is talking, TTS playing)
⇄ listening (VAD detects user speech)
→ ended
→ error (call connect() again to retry)Events (VoiceEventMap)
| Event | Payload | Fires when |
|---|---|---|
stateChange | VoiceState | Any state transition |
botSpeaking | boolean | Bot TTS starts / stops |
userSpeaking | boolean | VAD detects user start / stop |
transcript | TranscriptEntry | A turn is finalized (user or bot) |
transcriptUpdate | TranscriptEntry | Partial user turn or bot TTS chunk |
botTrack | MediaStreamTrack | Bot audio track arrived — attach it to an <audio> element |
complete | sessionId: string | Bot sent the end_conversation server message |
error | Ear3VoiceError ({ code, message, status?, cause }) | Connect failure or RTVI error |
devicesLoaded | DeviceInfo[] | devicePlugin refreshed the list |
deviceChanged | (kind, deviceId) | setMic / setSpeaker called |
<VoiceProvider client={vc}> + hooks
Wrap your tree with the provider, then read state from hooks. One thing
<Ear3Interview> normally does for you is now on your side: attach the
bot’s remote audio track to an <audio> element via the botTrack
event — WebRTC does not play sound without a DOM sink, so without this
the bot stays silent:
import { useMemo, useRef, useEffect } from 'react'
import { VoiceClient, VoiceProvider, useVoice, useTranscript, transcriptPlugin } from '@ear3/voice-interviewer/headless'
function App() {
const client = useMemo(
() =>
new VoiceClient({
interviewId,
publishableKey,
plugins: [transcriptPlugin()],
}),
[]
)
const audioRef = useRef<HTMLAudioElement>(null)
useEffect(() => {
client.on('botTrack', (track) => {
// REQUIRED: audio sink for the bot's voice
audioRef.current!.srcObject = new MediaStream([track])
void audioRef.current!.play().catch(() => {})
})
client.on('complete', (sessionId) => {
/* navigate / thank the respondent */
})
return () => client.destroy()
}, [client])
return (
<VoiceProvider client={client}>
<audio ref={audioRef} autoPlay playsInline hidden />
<VoiceUI />
</VoiceProvider>
)
}
function VoiceUI() {
const { state, botSpeaking, connect, disconnect } = useVoice()
const { entries } = useTranscript()
return /* your UI */
}Persistence (transcript, audio recording id, completion) lives inside
VoiceClient, so saving works identically with or without your own UI
— or the branded widget above.
useVoice()
{
state: VoiceState
botSpeaking: boolean
userSpeaking: boolean
connect: () => Promise<void>
disconnect: () => Promise<void>
}useTranscript() — requires transcriptPlugin
{
entries: TranscriptEntry[]
clear: () => void
}useDevices() — requires devicePlugin
{
devices: DeviceInfo[] // all audio in/out
mics: DeviceInfo[] // audioinput only
speakers: DeviceInfo[] // audiooutput only
activeMicId: string | null
activeSpeakerId: string | null
setMic: (deviceId: string) => void
setSpeaker: (deviceId: string) => void
refresh: () => Promise<void>
}Calling a device hook without its plugin installed throws — install before mounting the provider.
useVoiceClient() / useClientEvent() — low-level
useVoiceClient() returns the raw VoiceClient from the provider.
useClientEvent(event, handler) subscribes with automatic cleanup on
unmount and refs the handler so referential-instability doesn’t
resubscribe on every render.
Plugins
Plugins are { name, install(client), uninstall?(client) }. They can
listen on the client, expose state through a getXxxState(client)
helper, and emit new events. Three ship in-package:
| Plugin | Reads | State accessor |
|---|---|---|
transcriptPlugin() | transcript, transcriptUpdate, complete | getTranscriptState(client) |
devicePlugin() | stateChange (refresh on ready) | getDevicesState(client) |
reconnectPlugin({ maxRetries, delayMs }) | error, stateChange | — (fire-and-forget) |
reconnectPlugin defaults to maxRetries: 3, delayMs: 2000, linear
backoff (delayMs × attempts). Resets after a successful ready, stops
retrying on ended.
Prebuilt components
Inline-styled primitives — useful for prototyping, easy to swap out.
| Component | Notes |
|---|---|
<VoiceStatus /> | Text label for the current state |
<VoiceControls /> | Connect / End button (picks the right one per state) |
<Transcript /> | Scrollable list of transcript entries |
<DeviceSelector /> | Mic + speaker dropdowns |
<VoiceAgent showDevices? /> | All of the above stacked in a bordered card |
All accept className + style. They render nothing meaningful until
you’re inside a <VoiceProvider> with a live VoiceClient.
RTVI escape hatch
If you want the raw PipecatClient and callback surface — no state
machine, no plugins, no React wrapper — use these directly.
createRtviClient(options?)
import { createRtviClient } from '@ear3/voice-interviewer/headless'
const { client, transport, destroy } = createRtviClient({
callbacks: {
onUserTranscript: (data) => { /* … */ },
onBotStartedSpeaking: () => { /* … */ },
// 11 more optional callbacks
},
initDevices: true, // default — calls client.initDevices() immediately
})
await client.startBotAndConnect({
endpoint: 'https://app.ear3.ai/api/v1/interviews/<interviewId>/sessions',
requestData: { publishableKey: '…' },
})Returns { client, transport, destroy() }. destroy() disconnects if
still connected — nothing else. You’re responsible for cleanup and error
handling.
useRtviClient(options?) — React
Same factory in hook form, with useRef lifecycle handled for you.
const { client, transport, isReady, botReady, transportState, connect, disconnect } =
useRtviClient({
enabled: true,
callbacks: { onUserTranscript, onBotStartedSpeaking, /* … */ },
})
useEffect(() => {
if (isReady) {
void connect({
endpoint: `https://app.ear3.ai/api/v1/interviews/${interviewId}/sessions`,
requestData: { publishableKey },
})
}
}, [isReady])Toggle enabled: false to tear down the client (or unmount the
component — cleanup runs either way).
At the RTVI layer you’re talking directly to Pipecat + Daily.
<Ear3Interview> guarantees the request payload and event shapes
Ear3 supports; here it’s your responsibility to keep them in sync
with the backend. Prefer the headless VoiceClient unless you have a
reason.
createSession(params)
Standalone session mint. Useful for server components or email links that want a session URL without loading the full voice stack.
import { createSession } from '@ear3/voice-interviewer/headless'
const session = await createSession({
interviewId: 'dpl_…',
publishableKey: process.env.NEXT_PUBLIC_EAR3_KEY!,
participantName: 'Olena K.', // dashboard display name (optional)
participantExternalId: 'crm_42', // your own respondent id (optional)
metadata: { source: 'email-blast' },
})
// session.sessionUrl → https://app.ear3.ai/v2/responder/<hash>?from=sdkReturns { sessionId, sessionUrl, expiresAt }. Throws SessionError
(with .status) on non-2xx. Supports AbortSignal and a custom fetch
implementation:
await createSession({
interviewId,
publishableKey,
fetch: myFetchWithRetries,
signal: controller.signal,
})Choosing a layer
| You want | Use |
|---|---|
| The Ear3 experience, one component | package root → <Ear3VoiceInterview> |
| Your own UI on managed mechanics | /headless → <Ear3Interview renderControls> |
| Full control, any framework | /headless → VoiceClient + hooks |
Microphone permission UX
The mic prompt appears in your origin —
Allow app.your-domain.com to use your microphone?. That’s usually what
users expect and what browsers remember on subsequent visits.
<Ear3Interview> calls initDevices() inside VoiceClient.connect() —
if you want the prompt earlier, pre-instantiate the client and call
connect() on user click.
Errors + edge cases
Every failure VoiceClient surfaces goes through onError as an
Ear3VoiceError — a real Error instance ({ code, message, status?, cause }), with code drawn from Ear3ErrorCode / Ear3ErrorCodes.
Full code table, the Ear3VoiceError shape, and the WebRTC / bot-preempted
edge cases now live on their own page — see Errors.
Related
- @ear3/server — server-side companion for webhooks + retrieval
- Concepts — mental model for keys, sessions, deployments
- Recipes — copy-paste patterns for common needs
- Troubleshooting — ten most-common failure modes