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
The widget talks to ElevenLabs agents (Vapi and Retell are coming soon). You add the agent as a Voice Agent node on the canvas and link it to a Chat Trigger (its Voice mode section). 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 ElevenLabs:
a signed WebSocket URL (~30s 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). |
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. 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 and Retell are coming soon.
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"
Direct mode only works for public ElevenLabs agents (the ones that don't need a signed URL). Any other agent needs 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.
The call doesn't start: 503 or 500
When the session can't be opened, POST /api/voice-sessions/start — and the agent's public session route, the other way into the same session — answers 503 with code: "voice_session_unavailable" if the voice provider couldn't open it, or 500 with code: "voice_session_failed" if the failure was HostWebhook's. Never a 502: Cloudflare would replace it with its own page, which the browser can't even read. The body is { error: "Voice session failed", code, message }, and message is the same sentence for every visitor — This voice call could not be started. Please try again in a moment. — without the provider, the key or the plan.
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