Voice Widget

Embeddable voice agent for any website. Visitors click a mic button, talk to your agent in real time, and the agent calls your HostWebhook workflows as tools mid-conversation. Audio runs over WebRTC directly between the visitor and the provider — HostWebhook stays on the workflow path, not the audio path.

How it works

Three providers ship today: ElevenLabs, Vapi, and Retell. You provision the agent inside HostWebhook (Voice Agents → New) and link it to a Chat Trigger. The trigger's id becomes the public handle the widget uses. When the visitor presses the mic button:

textflow
Browser <hw-voice>
   |
   1. POST /api/voice-sessions/start { chatId, sessionId }
   |   (HMAC if the trigger is in 'signed' mode)
   v
HostWebhook API
   |
   2. Mints a one-shot ticket from the provider:
        - ElevenLabs: signed WebSocket URL  (~30s TTL)
        - Vapi:       public key            (~1h TTL)
        - Retell:     access token          (~10min TTL)
   |
   v
Browser receives ticket, opens audio pipe to provider
   |
   3. Tool calls during the conversation POST back to your
      HostWebhook webhook → flows through your pipeline.

Install

As a single script tag (no build step)

htmlany HTML page
<script
  src="https://www.hostwebhook.com/voice-widget.js"
  data-chat-id="<your-chat-trigger-id>"
  data-agent-name="Support agent"
  data-primary="#7c3aed"
  defer
></script>

The loader auto-pins the latest published version of @hostwebhook/voice-widget (cache 1h on our edge). New releases roll out without touching your site.

As an npm package (React / typed JSX)

bashterminal
npm install @hostwebhook/voice-widget
tsxVoiceLauncher.tsx
import { HwVoice } from '@hostwebhook/voice-widget/react';

export function VoiceLauncher() {
  return (
    <HwVoice
      chatId="<your-chat-trigger-id>"
      agentName="Support agent"
      primaryColor="#7c3aed"
      mode="modal"
    />
  );
}
The Web Component (<hw-voice>) is registered on import — the React wrapper just forwards typed props to it. Same component under the hood, identical UX.

Display modes

Modal (default)

Renders a floating mic button in fixed position. Click opens a full-screen overlay with the call panel centered. Best for most embeds — visitors discover voice without you needing to carve out page real estate.

htmlmodal mode
<hw-voice mode="modal" chat-id="..." />

Inline

Renders the panel directly inside its container — sized to the container, no overlay. Use when voice is the primary action of the page.

htmlinline mode
<div style="height: 600px;">
  <hw-voice mode="inline" chat-id="..." />
</div>

Authentication

Voice sessions reuse the Chat Trigger's auth boundary. Two modes — set on the trigger itself.

Public mode

Anyone with the chat id can start a session. Cap abuse via the trigger's rate limit (the voice rate is automatically chatRate / 4 since each call costs more than a single text message).

htmlpublic
<hw-voice chat-id="ct_abc123" />

Signed mode (HMAC)

Only your authenticated visitors can start a session. Your backend signs HMAC-SHA256(secret, sessionId:ts) and returns { sig, ts }. The widget POSTs a signed start request — invalid signatures get a 401.

tsxReact + signed mode
<HwVoice
  chatId="ct_abc123"
  authProvider={async ({ sessionId }) => {
    // Hits your own server which knows the trigger's secret.
    const res = await fetch('/api/voice-auth', {
      method: 'POST',
      body: JSON.stringify({ sessionId }),
    });
    return res.json();   // { sig: '<hex>', ts: <unix-seconds> }
  }}
/>
htmlHTML + signed mode (declarative)
<script src="https://www.hostwebhook.com/voice-widget.js"
        data-chat-id="ct_abc123" defer></script>
<script>
  // Inject the auth provider once the loader runs.
  document.addEventListener('DOMContentLoaded', () => {
    HwVoice.setAuthProvider(async ({ sessionId }) => {
      const res = await fetch('/api/voice-auth', {
        method: 'POST',
        body: JSON.stringify({ sessionId }),
      });
      return res.json();
    });
  });
</script>
The provider API key (ElevenLabs / Vapi / Retell) never reaches the browser. The widget only ever sees the per-session ticket your backend mints. Rotate provider keys at the credential level — every new session picks up the rotation on its next start.

Attributes / props

HTML attributeReact propDescription
chat-idchatIdHW Chat Trigger id. Recommended path — unlocks signed mode and rate limiting.
agent-idagentIdProvider-side agent id. Direct mode (ElevenLabs public agents only). For Vapi / Retell, use chat-id.
agent-nameagentNameFriendly name shown in the call panel header.
modemodemodal (default) or inline.
primary-colorprimaryColorHex color (e.g. #7c3aed) — drives orb gradient, mic button, glow, accents.
themethemelight, dark, or auto (system).
api-baseapiBaseHostWebhook API URL override. Defaults to https://api.hostwebhook.com.
voice-providervoiceProviderelevenlabs, vapi, or retell. Inferred from the chat trigger when chat-id is set.
voice-sdk-urlvoiceSdkUrlCDN URL override for the provider SDK. Useful under strict CSP.
authProviderImperative only. Function returning { sig, ts } for signed mode.
signedUrlProviderImperative only. Returns a fully-formed ticket — bypasses chat-id / agent-id resolution. Use when you mint tickets through your own org-scoped webhook.

Events

The element re-emits a single hw-voice CustomEvent with a discriminated detail payload — listen with standard addEventListener.

tsxevent types
type HwVoiceEvent =
  | { type: 'mode'; mode: 'idle' | 'connecting' | 'listening' | 'speaking' | 'ended' }
  | { type: 'turn'; turn: { role: 'user' | 'agent'; text: string; final: boolean } }
  | { type: 'duration'; seconds: number }
  | { type: 'end'; reason: string }
  | { type: 'error'; error: Error };
tslisten via vanilla JS
document.querySelector('hw-voice')?.addEventListener('hw-voice', (e) => {
  const detail = (e as CustomEvent).detail;
  if (detail.type === 'turn') console.log(detail.turn.role, detail.turn.text);
});

Imperative API

The element exposes methods you can call from JavaScript / via a React ref.

tsmethods
interface HwVoiceHandle {
  start(): Promise<void>;       // Open the modal + start the call
  end(): Promise<void>;         // End the current call
  setVolume(v: number): void;   // 0..1 (provider-dependent)
  setMuted(m: boolean): void;   // Toggle mic mute
  element: HwVoiceElement | null;
}
tsxReact ref
const ref = useRef<HwVoiceHandle>(null);
return (
  <>
    <button onClick={() => ref.current?.start()}>Start call</button>
    <HwVoice ref={ref} chatId="..." />
  </>
);
tsvanilla JS (script-loader)
// The loader exposes a global with the same shape:
window.HwVoice.start();
window.HwVoice.end();
window.HwVoice.setAuthProvider(async ({ sessionId }) => ({ sig, ts }));

Provider notes

ElevenLabs

Most mature integration. Native input + output audio levels (the orb pulses on both your voice and the agent's). Public agents work in direct mode (agent-id); private agents need a chat-id for signed URLs.

htmlElevenLabs direct (public agent)
<hw-voice
  agent-id="agent_abc123"
  voice-provider="elevenlabs"
  primary-color="#7c3aed"
></hw-voice>

Vapi

The HostWebhook credential for Vapi must be your Public Key (NOT the private/server key) — public keys are designed to be exposed to browsers. Vapi exposes only the agent's output audio level natively; the orb still pulses but the input level is approximated from speech-state events.

Retell

The HostWebhook credential for Retell is the API Key (server-side). Each call mints a one-shot access token via POST /v2/create-web-call — counts against Retell's call quota even if the visitor abandons before speaking. We compute audio levels client-side from the provider's raw audio stream (RMS over each chunk).


Troubleshooting

CSP & strict bundlers

The voice provider's browser SDK is fetched on demand from esm.sh the first time a visitor presses the mic button — the widget itself stays small (~35 kB gzipped) and the ~150 kB of audio plumbing only loads when needed.

Modern bundlers (Webpack, Turbopack, Vite) try to pre-bundle dynamic import() URLs at build time, which produces broken inline copies of the SDK in some environments. To dodge that, the widget compiles its dynamic import via the Function constructor — which the bundler can't peer into. Function requires unsafe-eval in your script-src CSP directive.

Three resolution layers run in order at load time, so most sites work out of the box:

  1. If globalThis.__hwVoiceImport is a function, the widget uses it directly (escape hatch — see below).
  2. Otherwise, new Function('u', 'return import(u)') if unsafe-eval is allowed.
  3. As a last resort, a native (u) => import(u) fallback. This works on script-tag embeds even without unsafe-eval; only fails when both a build-time bundler intercepts the import and CSP blocks Function.
Escape hatch for strict-CSP + bundled apps. Set the loader yourself before the widget code runs. Anything that returns a Promise resolving to the SDK module works:
htmlHTML — script tag embed
<script>
  // Tell the widget how to load external SDKs.
  // Webpack ignore comment keeps the bundler from pre-bundling the URL.
  window.__hwVoiceImport = (url) =>
    import(/* webpackIgnore: true */ /* @vite-ignore */ url);
</script>
<script src="https://www.hostwebhook.com/voice-widget.js" data-chat-id="..." defer></script>
tsxReact / Next.js — set before mounting <HwVoice>
if (typeof window !== "undefined") {
  // Note: webpackIgnore needs to live next to a literal-looking
  // import() for your bundler to honor it. The widget consumes
  // whatever this returns — a custom proxy / lazy chunk also works.
  window.__hwVoiceImport = (url) =>
    import(/* webpackIgnore: true */ url);
}

If you're self-hosting the SDK module (e.g. mirrored on your own CDN), the same hook lets you swap the URL or add request headers without forking the widget.

"Provider … requires a server-minted ticket"

The widget only supports direct mode for ElevenLabs (public agents that don't need a signed URL). Vapi and Retell always need a ticket minted by the HostWebhook backend, which means you must pass a data-chat-id linking to the trigger that owns the agent. If you see this error, the chat-id is missing or the trigger doesn't have a voiceAgentId set.

Microphone permission denied

The widget requests navigator.mediaDevices.getUserMedia before opening the audio pipe — if the browser denies (or the page is loaded over plain http://), the call rejects with a clear message and the orb returns to idle. The page must be served over HTTPS or localhost for browser mic APIs to be available.


Related reference

  • API Reference — full schema for POST /api/voice-sessions/start
  • Voice Agents — provision the agent that the widget connects to
  • Chat Triggers — link the trigger that owns the auth + rate-limit boundary