ai_bot_voice
v1.0.10
Published
Ambernexus AI voice bubble widget — a framework-agnostic Web Component for browser-based real-time voice conversations with React, Vue, Next.js, Angular, Vite, and vanilla HTML/JS support.
Maintainers
Readme
ai_bot_voice
A framework-agnostic AI voice bubble widget for the browser. Shipped as a Web Component (custom element), so it works in React, Next.js, Vue, Angular, Vite, Create React App, plain HTML/JS, and via CDN — no framework-specific lock-in.
- Real-time voice conversation over WebSocket (PCM-16 in/out)
- Backend-agnostic — you supply a signed WebSocket URL from any server (Node, Python, PHP, Go, .NET, …); the widget makes no backend calls of its own
- Unlimited conversation restarts in a single session — no close/reopen needed
- Microphone capture + playback with mute/unmute, level metering, and waveform
- Animated trigger pill + floating panel with bubbles, orb, and call timer
- Themeable via CSS custom properties and HTML attributes
- SSR-safe — importing the package in Node does not touch
documentorcustomElements - ESM + CJS + UMD builds with TypeScript declarations
- Thin React + Vue wrappers exposing typed props and events
Install
npm install ai_bot_voice
# or
yarn add ai_bot_voice
# or
pnpm add ai_bot_voiceCDN (no install)
<script src="https://cdn.jsdelivr.net/npm/ai_bot_voice/dist/ambernexus-ai_bot_voice.min.js"></script>
<!-- or -->
<script src="https://unpkg.com/ai_bot_voice/dist/ambernexus-ai_bot_voice.min.js"></script>The UMD bundle registers the <ambernexus-bubble-widget> element and also exposes a global window.AmbernexusAiBotVoice namespace containing { AmbernexusBubbleWidget, register, autoInit, TAG_NAME }.
Usage
Vanilla HTML + JS
<ambernexus-bubble-widget
button-label="Ask Nexus AI"
primary-color="#db2777"
accent-color="#f472b6"
width="320px"
height="380px"
></ambernexus-bubble-widget>
<script src="https://cdn.jsdelivr.net/npm/ai_bot_voice/dist/ambernexus-ai_bot_voice.min.js"></script>
<script>
const w = document.querySelector("ambernexus-bubble-widget");
w.addEventListener("aw:start", () => console.log("started"));
w.addEventListener("aw:mode", (e) => console.log("mode", e.detail.mode));
w.addEventListener("aw:error", (e) => console.error(e.detail.message));
// Register a provider. The widget calls it for a FRESH signed URL on every "Start
// call", so users can end and restart conversations without closing the widget.
w.setSignedUrlProvider(async () => {
const res = await fetch("https://your-backend.example.com/api/signedUrl", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({})
});
const { signedUrl } = await res.json();
return signedUrl;
});
</script>The widget holds no secrets and makes no backend requests of its own. You are
responsible for calling your own backend, retrieving the signed URL, and giving it to the
widget. The recommended way is a provider callback (setSignedUrlProvider, or the
signedUrlProvider prop in React/Vue): the widget invokes it for a fresh URL on every
call start, so the same widget supports unlimited conversation restarts without being
closed and reopened. For a one-shot call you can instead set the static signed-url
attribute (or configure({ signedUrl })). Either way the widget connects the WebSocket
directly to the URL — it works with any backend implementation and you keep full
control over authentication and session config. See
Providing the signed URL below.
Auto-init from data-ai-bot-voice
<div data-ai-bot-voice></div>
<script src="https://cdn.jsdelivr.net/npm/ai_bot_voice/dist/ambernexus-ai_bot_voice.min.js"></script>
<script>
const [w] = AmbernexusAiBotVoice.autoInit();
w.setSignedUrlProvider(async () => {
const res = await fetch("https://your-backend.example.com/api/signedUrl", { method: "POST" });
const { signedUrl } = await res.json();
return signedUrl;
});
</script>React
import { AmbernexusBubbleWidget } from "ai_bot_voice/react";
export default function App() {
// Called for a fresh signed URL on every call start — supports unlimited restarts.
const getSignedUrl = async () => {
const res = await fetch("https://your-backend.example.com/api/signedUrl", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({})
});
const data = await res.json();
return data.signedUrl;
};
return (
<AmbernexusBubbleWidget
signedUrlProvider={getSignedUrl}
primaryColor="#db2777"
width="320px"
height="380px"
onStart={() => console.log("started")}
onMode={(e) => console.log("mode", e.detail.mode)}
onError={(e) => console.error(e.detail.message)}
/>
);
}Next.js (App Router)
The widget uses browser-only APIs (document, customElements, WebSocket, AudioContext), so render it in a client component:
"use client";
import { AmbernexusBubbleWidget } from "ai_bot_voice/react";
export default function Page() {
const getSignedUrl = async () => {
const res = await fetch("/api/signedUrl", { method: "POST" });
const { signedUrl } = await res.json();
return signedUrl;
};
return <AmbernexusBubbleWidget signedUrlProvider={getSignedUrl} />;
}Hydration safety
As of 1.0.2, the widget is hydration-safe. The host element (<ambernexus-bubble-widget>) is never mutated by the constructor or connectedCallback — all dynamic state (data-mode, data-call-active, the --aw-* CSS variables) lives on an internal <div class="root"> inside the shadow DOM, which is invisible to React's reconciler. The HTML React renders on the server matches the HTML it sees on the client.
If you're on 1.0.0 or 1.0.1 you may have seen warnings like:
A tree hydrated but some attributes of the server rendered HTML didn't match the client properties.
- data-mode="idle"- data-call-active="false"- style={{--aw-primary:"#e51515", …}}
Upgrade to 1.0.2+ to fix this — no app-side changes required.
Optional extra safety (only if you cannot upgrade)
If you must stay on an older version, you can keep the widget out of SSR with next/dynamic:
// WidgetClient.tsx
"use client";
import { AmbernexusBubbleWidget } from "ai_bot_voice/react";
export default function WidgetClient(props) {
return <AmbernexusBubbleWidget {...props} />;
}// app/page.tsx
import dynamic from "next/dynamic";
const Widget = dynamic(() => import("./WidgetClient"), { ssr: false });
export default function Page() {
return <Widget />;
}Vue 3
<script setup>
import { AmbernexusBubbleWidget } from "ai_bot_voice/vue";
// Called for a fresh signed URL on every call start — supports unlimited restarts.
async function getSignedUrl() {
const res = await fetch("https://your-backend.example.com/api/signedUrl", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({})
});
const data = await res.json();
return data.signedUrl;
}
</script>
<template>
<AmbernexusBubbleWidget
:signed-url-provider="getSignedUrl"
primary-color="#db2777"
@aw:start="() => console.log('started')"
@aw:error="(e) => console.error(e.detail.message)"
/>
</template>If you'd rather use the custom element directly in a Vue template, tell Vue not to compile it as a Vue component:
// vite.config.js / vue.config.js
export default {
plugins: [
vue({
template: {
compilerOptions: {
isCustomElement: (tag) => tag === "ambernexus-bubble-widget"
}
}
})
]
};<script setup>
import "ai_bot_voice";
</script>
<template>
<ambernexus-bubble-widget signed-url="wss://your-voice-api.example.com/v1/convai/conversation?token=…" />
</template>Angular
In your module, add CUSTOM_ELEMENTS_SCHEMA so Angular's template compiler accepts the unknown tag:
import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core";
import "ai_bot_voice";
@NgModule({
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class AppModule {}<ambernexus-bubble-widget signed-url="wss://your-voice-api.example.com/v1/convai/conversation?token=…"></ambernexus-bubble-widget>Vite / Create React App / vanilla bundlers
import "ai_bot_voice";
// the custom element is now registered globallyAttributes
All attributes are optional. To start a call you must supply a signed URL — via the
signed-url attribute below or a provider callback (see
Conversations & restarts).
| Attribute | Type | Default | Notes |
| ---------------------- | ------- | -------------------------------- | -------------------------------------------------- |
| signed-url | string | — | The ws:///wss:// signed URL you fetched from your own backend. Used as a fallback when no provider callback is registered. Not auto-refreshed on restart — prefer a provider for multi-session use. |
| button-label | string | "Ask Revo AI" | Trigger pill label. |
| bubble-count | number | 28 | Animated background bubble count. |
| primary-color | CSS | #4466ee | --aw-primary |
| accent-color | CSS | #f37d2c | --aw-accent |
| bg-color | CSS | rgba(17, 24, 47, 0.96) | --aw-bg |
| text-color | CSS | #ffffff | --aw-text |
| width | CSS | 340px | --aw-width |
| height | CSS | 460px | --aw-height |
The widget connects its WebSocket directly to whatever signed URL you provide; it
makes no HTTP requests of its own. If no usable ws:///wss:// URL can be resolved when
a call is started (no provider, no valid signed-url), the widget shows an error and
dispatches aw:error with the message "signedUrl is required".
CSS custom properties
The same values can be set via :host or any ancestor — every attribute above is implemented as a CSS variable (--aw-*) for easy theming.
Events
The custom element dispatches the following CustomEvents:
| Event | detail |
| ----------- | ---------------------------------------------- |
| aw:open | — |
| aw:close | — |
| aw:start | — |
| aw:stop | — |
| aw:mode | { mode: "idle" \| "connecting" \| "listening" \| "speaking" } |
| aw:mute | { muted: boolean } |
| aw:error | { message: string } |
The React wrapper forwards these as onOpen, onClose, onStart, onStop, onMode, onMute, onError. The Vue wrapper emits them with their original aw:* names.
Imperative API
const w = document.querySelector("ambernexus-bubble-widget");
w.open(); // open the panel
w.close(); // close the panel
w.start(); // begin a call (resolves the signed URL automatically)
w.start("wss://…token=…"); // begin a call with an explicit signed URL
w.stop(); // end a call
w.startConversation(url); // alias for start(url)
w.endConversation(); // alias for stop()
w.setSignedUrlProvider(fn); // register an async () => signedUrl callback
w.toggleMute(); // toggle the microphone
w.setMuted(true); // set mute explicitly
w.configure({ primaryColor: "#f00", signedUrlProvider: async () => "wss://…token=…" });Conversations & restarts
The widget supports unlimited conversations in a single session — no close/reopen or
page refresh required. Every start() (whether triggered by the built-in button,
start(), or startConversation()):
- Fully resets the previous session — closes the WebSocket, stops the microphone stream, tears down the audio processors/contexts, clears timers, removes socket listeners, and resets conversation state.
- Resolves a fresh signed URL, in priority order:
- an explicit argument to
start(url)/startConversation(url), - the registered provider callback (re-invoked every time — recommended), then
- the static
signed-urlattribute.
- an explicit argument to
- Opens a brand-new WebSocket connection and begins the conversation.
A previous, possibly expired, signed URL is never reused. The recommended pattern is to register a provider once so the host is asked for a fresh URL on every start:
w.setSignedUrlProvider(async () => {
const res = await fetch("/api/signedUrl", { method: "POST" });
const { signedUrl } = await res.json();
return signedUrl;
});Open widget → Start → End → Start again → provider called → fresh session → conversation startsThe static
signed-urlattribute is convenient for a single call, but it is not refreshed automatically — by the time the user restarts, that URL may be expired. Use a provider callback for any flow that allows restarting.
Providing the signed URL
Works with any backend language or framework
The widget is completely backend-agnostic. It never calls your backend itself and it
has no SDK, transport, or response-shape requirements. Its only input is a finished
wss:// (or ws://) URL string. How you produce that string — language, framework,
hosting, route name, auth scheme, response envelope — is entirely up to you.
The whole contract: your code obtains a signed WebSocket URL and gives the string to the widget. That's it.
Because the contract is just "return a URL string," any stack works equally well:
| Language | Frameworks you might use | | ----------- | ---------------------------------------------------------- | | Node.js | Express, Fastify, NestJS, Next.js / Nuxt route handlers | | Python | FastAPI, Flask, Django, Starlette | | PHP | Laravel, Symfony, plain PHP | | Go | net/http, Gin, Echo, Fiber | | Ruby | Rails, Sinatra | | Java / Kotlin | Spring Boot, Ktor, Micronaut | | C# / .NET | ASP.NET Core minimal APIs or controllers | | Rust | Axum, Actix | | Serverless | AWS Lambda, Cloudflare Workers, Vercel/Netlify functions |
You hand the resulting URL to the widget in one of these ways (see Conversations & restarts for when to use which):
- Provider callback —
setSignedUrlProvider(async () => "wss://…")or thesignedUrlProviderprop. Re-run on every call start; recommended for restartable use. - Static value — the
signed-urlattribute,configure({ signedUrl }), or thesignedUrlprop. Fine for a single call.
Why a backend at all?
Voice providers issue signed URLs using a secret API key. That key must never reach the browser. So your backend does three things, in any language:
- Holds the secret (the Voice API key lives only on the server, e.g. in an env var).
- Requests a short-lived signed URL from the Voice provider, adding your agent/session config.
- Returns the URL string to the browser, which passes it to the widget.
The widget then connects its WebSocket directly to that URL. The reference Express
backend in backendNode/express-backend is one ready-made
implementation — but it is just an example of the pattern above, not a requirement.
Architecture
Your app (fetch) Your backend External Voice API
│ │ │
│ POST /api/signedUrl │ │
│ { dynamic_variables: {...} } │ │
│─────────────────────────────────────►│ │
│ │ POST .../conversation/signedUrl │
│ │ x-api-key: <secret> │
│ │ { agentId, userId, overrides… } │
│ │─────────────────────────────────►│
│ │ │
│ │ { signedUrl: "wss......" } │
│ │◄─────────────────────────────────│
│ { signedUrl: "wss......" } │ │
│◄─────────────────────────────────────│ │
│ │ │
│ passes signedUrl to the widget ───► widget connects directly ─────────►│Flow
Your app ──POST /api/signedUrl──► Your backend ──x-api-key──► Voice API
◄──── { signedUrl } ──── ◄── { signedUrl } ──
provider returns signedUrl ──► widget connects to the signedUrl (re-runs on every start)Reference endpoint: POST /api/signedUrl
The shape below is what the reference Express backend exposes. Treat it as an example, not a spec — your endpoint can use any path, method, request body, or response envelope. The widget never sees this endpoint; only your own fetch code does, so you control it entirely.
| | |
| ------------ | ------------------------------- |
| Method | POST |
| URL | /api/signedUrl |
| Auth | None (server holds the API key) |
| Content-Type | application/json |
Request body (all optional)
| Field | Type | Description |
| ------------------- | ------ | -------------------------------------------------------------------------------------------------------- |
| dynamic_variables | object | Per-call values forwarded to the agent (e.g. user_name, plan) for personalization. Defaults to {}. |
Send dynamic_variables from your app when you want to personalize the call; otherwise
an empty body ({}) is fine. The backend forwards the body to the Voice API as:
{
"agentId": "<VOICE_AGENT_ID>",
"userId": "<VOICE_USER_ID>",
"overrides": {
"timezone": "<VOICE_TIMEZONE>",
"dynamic_variables": { /* your dynamic_variables */ }
},
"secsLeft": 600,
"origin": "browser"
}Example request
curl -X POST https://your-backend.example.com/api/signedUrl \
-H "Content-Type: application/json" \
-d '{ "dynamic_variables": { "user_name": "Naga", "plan": "premium" } }'PowerShell note: quoting differs — use
curl -Method POST https://your-backend.example.com/api/signedUrl -ContentType "application/json" -Body '{}'
Success response — 200 OK
{
"signedUrl": "wss......"
}Read the signedUrl field from this response and pass it to the widget. (The exact
response shape is up to your backend — the widget only consumes the final URL string.)
Error response — 500 Internal Server Error
{
"message": "Failed to generate signed URL",
"error": "<details from the Voice API or request failure>"
}A malformed JSON request body returns 400 { "success": false, "message": "Invalid JSON in request body" }.
Server examples (any language)
Each example does the same three things — keep the API key server-side, call the Voice API,
return the signedUrl. Adapt the route, framework, and response shape to your stack; only
your own fetch code needs to know about them.
import express from "express";
const app = express();
app.use(express.json());
app.post("/api/signedUrl", async (req, res) => {
try {
const r = await fetch(process.env.VOICE_API_URL, {
method: "POST",
headers: { "Content-Type": "application/json", "x-api-key": process.env.VOICE_API_KEY },
body: JSON.stringify({
agentId: process.env.VOICE_AGENT_ID,
userId: process.env.VOICE_USER_ID ?? "12",
overrides: { timezone: "Asia/Kolkata", dynamic_variables: req.body?.dynamic_variables ?? {} },
secsLeft: 600,
origin: "browser"
})
});
const data = await r.json();
res.json({ signedUrl: data.signedUrl });
} catch (e) {
res.status(500).json({ message: "Failed to generate signed URL", error: String(e) });
}
});import os, httpx
from fastapi import FastAPI, Request
app = FastAPI()
@app.post("/api/signedUrl")
async def signed_url(req: Request):
body = await req.json() if await req.body() else {}
async with httpx.AsyncClient() as client:
r = await client.post(
os.environ["VOICE_API_URL"],
headers={"x-api-key": os.environ["VOICE_API_KEY"]},
json={
"agentId": os.environ["VOICE_AGENT_ID"],
"userId": os.getenv("VOICE_USER_ID", "12"),
"overrides": {"timezone": "Asia/Kolkata",
"dynamic_variables": body.get("dynamic_variables", {})},
"secsLeft": 600,
"origin": "browser",
},
)
return {"signedUrl": r.json()["signedUrl"]}<?php
// POST /api/signedUrl
$input = json_decode(file_get_contents('php://input'), true) ?? [];
$ch = curl_init(getenv('VOICE_API_URL'));
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'x-api-key: ' . getenv('VOICE_API_KEY')],
CURLOPT_POSTFIELDS => json_encode([
'agentId' => getenv('VOICE_AGENT_ID'),
'userId' => getenv('VOICE_USER_ID') ?: '12',
'overrides' => ['timezone' => 'Asia/Kolkata', 'dynamic_variables' => $input['dynamic_variables'] ?? (object)[]],
'secsLeft' => 600,
'origin' => 'browser',
]),
]);
$data = json_decode(curl_exec($ch), true);
header('Content-Type: application/json');
echo json_encode(['signedUrl' => $data['signedUrl']]);func signedURL(w http.ResponseWriter, r *http.Request) {
var in struct{ DynamicVariables map[string]any `json:"dynamic_variables"` }
_ = json.NewDecoder(r.Body).Decode(&in)
payload, _ := json.Marshal(map[string]any{
"agentId": os.Getenv("VOICE_AGENT_ID"),
"userId": "12",
"overrides": map[string]any{"timezone": "Asia/Kolkata", "dynamic_variables": in.DynamicVariables},
"secsLeft": 600,
"origin": "browser",
})
req, _ := http.NewRequest("POST", os.Getenv("VOICE_API_URL"), bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-api-key", os.Getenv("VOICE_API_KEY"))
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
var out map[string]any
json.NewDecoder(resp.Body).Decode(&out)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"signedUrl": out["signedUrl"]})
}CORS: if your backend is on a different origin than the page hosting the widget, enable CORS for the browser
fetch(e.g. allow your site's origin and thePOSTmethod). The widget's WebSocket connection to the signed URL is not subject to CORS.
Backend environment variables
These are the variables the reference Express backend reads — your own backend can name its config however it likes. The names that actually matter are the Voice provider's secret and endpoint; the rest are session defaults you can hard-code or expose as needed.
| Variable | Description | Default |
| ----------------- | ---------------------------------------------------- | -------------- |
| VOICE_API_URL | URL of the external Voice API signed-URL endpoint | — |
| VOICE_API_KEY | Secret API key for the Voice API (server-side only) | — |
| VOICE_AGENT_ID | Voice agent identifier | — |
| VOICE_USER_ID | User identifier forwarded to the Voice API | 12 |
| VOICE_TIMEZONE | Timezone forwarded to the Voice API | Asia/Kolkata |
| VOICE_SECS_LEFT | Session length in seconds | 600 |
| VOICE_ORIGIN | Origin label forwarded to the Voice API | browser |
For local development, point your fetch at your dev server, e.g.
fetch("https://your-backend.example.com/api/signedUrl", { method: "POST" }), then pass the
returned signedUrl to the widget.
SSR notes
- Importing the package on the server is safe: the template DOM is created lazily on first instantiation, and
customElements.defineis guarded by atypeof window !== "undefined"check. - The element only renders in the browser. If you SSR a host page that contains
<ambernexus-bubble-widget>, the tag will be emitted as-is and upgrade on the client once the script is loaded. - For frameworks that complain about unknown tags (Vue, Angular), see the integration notes above.
Browser support
Modern evergreen browsers (Chrome, Firefox, Safari, Edge, mobile Safari/Chrome). The widget uses AudioContext, MediaDevices.getUserMedia, WebSocket, and CSS color-mix()/backdrop-filter. Microphone capture requires a secure origin (HTTPS or localhost).
Local development
npm install
npm run build # produces dist/ (ESM, CJS, UMD, min, .d.ts)
npm run build:watch # rebuild on changeOpen examples/html-js/index.html after building to exercise the UMD bundle locally (serve through any static server — npx serve . works).
License
ISC © Ambernexus
