SDK@ear3/voice-interviewer

@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-interviewer
import { Ear3VoiceInterview } from '@ear3/voice-interviewer'          // widget
import { VoiceClient } from '@ear3/voice-interviewer/headless'        // headless
Which one do I want?

Want 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

  1. 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.
  2. 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.
  3. 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

PropTypeRequiredNotes
interviewIdstringDeployment id (where?)
publishableKeystringpk_live_… / pk_test_… (browser-safe)
apiBasestringOverride Ear3 host
metadataRecord<string, unknown>Attached to the session, echoed in webhooks
participantNamestringRespondent name shown in the dashboard
participantExternalIdstringYour own respondent id — echoed on the responses API
titlestringLanding headline
descriptionstringLanding description
respondentFirstNamestringRenders Hello, {name}! above the title
estimatedMinutesstring | numberRenders the estimated-time row
autoStartbooleanSkip the landing screen, connect on mount
branding'visible' | 'hidden'Powered-by badge. Free tier renders it
brandingRefstringAttribution ref appended to the badge link
onComplete({ sessionId, transcript })Interview finished (naturally or via End)
onError(error: VoiceError) => voidConnect / RTVI failures
onFeedback(message: string) => voidCompletion-screen feedback text
className / styleOuter 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.

Three layers, pick the one that fits

<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:

  1. Instantiates a VoiceClient (see below) with a transcriptPlugin
  2. Calls POST /api/v1/interviews/{interviewId}/sessions to mint the session + fetch Daily room credentials
  3. Asks for mic permission in your origin
  4. Connects the WebRTC room and waits for bot-ready
  5. Renders the built-in status / transcript / connect-disconnect UI
  6. Fires onComplete when the bot signals interview.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 prop

The 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

PropTypeRequiredNotes
interviewIdstringDeployment id (where?)
publishableKeystringpk_live_… or pk_test_…
metadataRecord<string, unknown>Round-trips into transcripts + webhooks
apiBasestringOverride Ear3 host. Default https://app.ear3.ai
participantNamestringRespondent name shown in the dashboard. Default 'sdk-respondent'
participantExternalIdstringYour own respondent id — echoed back as participant_external_id on the responses API
autoDisconnectbooleanHang up when the bot ends the interview (releases the mic, finalizes the audio recording). Default true
autoConnectbooleanAuto-connect on mount. Default true
renderControls(controls: Ear3InterviewControls) => ReactNodeReplace the default UI
styleCSSPropertiesInline style on the outer wrapper
classNamestringClass name on the outer wrapper
onComplete(event: Ear3InterviewCompleteEvent) => voidFires when the bot emits the end_conversation server-message
onError(err: VoiceError) => voidFires on connect / RTVI failures
debugbooleanVerbose 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

OptionTypeNotes
interviewIdstringDeployment id
publishableKeystringpk_live_… / pk_test_…
apiBasestringDefault https://app.ear3.ai
metadataRecord<string, unknown>Round-trips into transcripts + webhooks
participantNamestringRespondent name shown in the dashboard. Default 'sdk-respondent'
participantExternalIdstringYour own respondent id — echoed back as participant_external_id on the responses API
autoDisconnectbooleanHang up when the bot ends the interview. Default true
pluginsVoicePlugin[]Registered on construction, install() called immediately
debugbooleanVerbose console.log tracing of server messages + persist calls. Default false

Methods

MethodNotes
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)

EventPayloadFires when
stateChangeVoiceStateAny state transition
botSpeakingbooleanBot TTS starts / stops
userSpeakingbooleanVAD detects user start / stop
transcriptTranscriptEntryA turn is finalized (user or bot)
transcriptUpdateTranscriptEntryPartial user turn or bot TTS chunk
botTrackMediaStreamTrackBot audio track arrived — attach it to an <audio> element
completesessionId: stringBot sent the end_conversation server message
errorEar3VoiceError ({ code, message, status?, cause })Connect failure or RTVI error
devicesLoadedDeviceInfo[]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:

PluginReadsState accessor
transcriptPlugin()transcript, transcriptUpdate, completegetTranscriptState(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.

ComponentNotes
<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).

You lose the SDK contract

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=sdk

Returns { 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 wantUse
The Ear3 experience, one componentpackage root → <Ear3VoiceInterview>
Your own UI on managed mechanics/headless<Ear3Interview renderControls>
Full control, any framework/headlessVoiceClient + hooks

Microphone permission UX

The mic prompt appears in your originAllow 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.


  • @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

Built by Ear3 — voice interviews for any app.
⌘/