npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

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 document or customElements
  • 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_voice

CDN (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 globally

Attributes

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

  1. 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.
  2. Resolves a fresh signed URL, in priority order:
    1. an explicit argument to start(url) / startConversation(url),
    2. the registered provider callback (re-invoked every time — recommended), then
    3. the static signed-url attribute.
  3. 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 starts

The static signed-url attribute 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 callbacksetSignedUrlProvider(async () => "wss://…") or the signedUrlProvider prop. Re-run on every call start; recommended for restartable use.
  • Static value — the signed-url attribute, configure({ signedUrl }), or the signedUrl prop. 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:

  1. Holds the secret (the Voice API key lives only on the server, e.g. in an env var).
  2. Requests a short-lived signed URL from the Voice provider, adding your agent/session config.
  3. 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 response200 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 response500 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 the POST method). 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.define is guarded by a typeof 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 change

Open examples/html-js/index.html after building to exercise the UMD bundle locally (serve through any static server — npx serve . works).


License

ISC © Ambernexus