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:
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)
<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)
npm install @hostwebhook/voice-widgetimport { HwVoice } from '@hostwebhook/voice-widget/react';
export function VoiceLauncher() {
return (
<HwVoice
chatId="<your-chat-trigger-id>"
agentName="Support agent"
primaryColor="#7c3aed"
mode="modal"
/>
);
}<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.
<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.
<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).
<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.
<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> }
}}
/><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>Attributes / props
| HTML attribute | React prop | Description |
|---|---|---|
chat-id | chatId | HW Chat Trigger id. Recommended path — unlocks signed mode and rate limiting. |
agent-id | agentId | Provider-side agent id. Direct mode (ElevenLabs public agents only). For Vapi / Retell, use chat-id. |
agent-name | agentName | Friendly name shown in the call panel header. |
mode | mode | modal (default) or inline. |
primary-color | primaryColor | Hex color (e.g. #7c3aed) — drives orb gradient, mic button, glow, accents. |
theme | theme | light, dark, or auto (system). |
api-base | apiBase | HostWebhook API URL override. Defaults to https://api.hostwebhook.com. |
voice-provider | voiceProvider | elevenlabs, vapi, or retell. Inferred from the chat trigger when chat-id is set. |
voice-sdk-url | voiceSdkUrl | CDN URL override for the provider SDK. Useful under strict CSP. |
| — | authProvider | Imperative only. Function returning { sig, ts } for signed mode. |
| — | signedUrlProvider | Imperative 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.
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 };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.
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;
}const ref = useRef<HwVoiceHandle>(null);
return (
<>
<button onClick={() => ref.current?.start()}>Start call</button>
<HwVoice ref={ref} chatId="..." />
</>
);// 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.
<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:
- If
globalThis.__hwVoiceImportis a function, the widget uses it directly (escape hatch — see below). - Otherwise,
new Function('u', 'return import(u)')ifunsafe-evalis allowed. - As a last resort, a native
(u) => import(u)fallback. This works on script-tag embeds even withoutunsafe-eval; only fails when both a build-time bundler intercepts the import and CSP blocksFunction.
Promise resolving to the SDK module works:<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>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