@skillful-ai/agent-widget
v0.2.0
Published
Embeddable chat + live voice-call widget for Skillful agents (WordPress / any site)
Keywords
Readme
@skillful-ai/agent-widget
Embeddable chat + live voice-call widget for Skillful agents. Drop one
<script>tag onto any website — WordPress, plain HTML, React — and get a floating bubble that does text chat and a real-time browser voice call, both backed by the same Skillful Agents API.
- 🪶 One script tag — no build step required on the host site.
- 🎨 Fully themeable — colors, position, sizing, copy, light/dark.
- 🔌 Shadow-DOM isolated — never clashes with the host page's CSS.
- 💬 Chat over SSE · 📞 Voice over WebRTC/Socket.IO with live transcript.
- 🔐 Key-safe — the API key stays on your server; the browser only gets a short-lived token.
- 🧪 Customization Studio — a live playground that generates the exact embed snippet.
Table of contents
- Quick start
- Configuration reference
- Authentication: test vs production
- Chat & voice use different credentials
- Conversation history & privacy
- WordPress
- Customization Studio (playground)
- Browser support & requirements
- Local development
- Project structure
Install
Two ways to use it — pick whichever fits the host site:
A. Script tag (any website — WordPress, Webflow, plain HTML). No install, no build. Load the bundle from the CDN:
<script src="https://cdn.jsdelivr.net/npm/@skillful-ai/[email protected]/dist/widget.js"></script>B. npm (React / Vue / any bundler). Install and import:
npm install @skillful-ai/agent-widgetBoth options expose the exact same configuration; only the way you load it differs. The CDN bundle is fully self-contained (styles + voice worklet); the npm build ships ESM + TypeScript types.
Quick start
Once the script tag is on the page, one call mounts a floating chat bubble:
<script src="https://cdn.jsdelivr.net/npm/@skillful-ai/[email protected]/dist/widget.js"></script>
<script>
SkillfulChat.init({
apiUrl: 'https://api.agents.skillfulai.io',
agentId: '<AGENT_UUID>',
token: '<EMBED_JWT>', // minted server-side — see Authentication
title: 'Support',
greeting:'Hi! How can I help?',
theme: { mode: 'dark', position: 'bottom-right' },
});
</script>That's the entire integration for a static site. The only value you have to produce
on your side is token (the Authentication section
explains exactly how, with copy-paste code).
SkillfulChat.init(config) returns an instance:
const agent = SkillfulChat.init({ ... });
agent.open(); // open the panel
agent.close(); // close it
agent.updateToken(jwt); // swap in a refreshed token
agent.destroy(); // remove the widget entirelyTo add voice, set voice.enabled (a 📞 button appears in the header):
SkillfulChat.init({
apiUrl: '…', agentId: '…', token: '<EMBED_JWT>',
voice: { enabled: true, apiKey: '<RAW_API_KEY>', disableBargeIn: true },
});Use with a bundler (React, Vue, etc.)
The npm package exports an ESM build with TypeScript types:
import { mountWidget } from '@skillful-ai/agent-widget'
import type { WidgetConfig } from '@skillful-ai/agent-widget'
// Mounts into its own Shadow-DOM host (same as the script tag).
const widget = mountWidget({ apiUrl: '…', agentId: '…', token: '…' })
// later: widget.destroy()In React, call mountWidget from an effect and destroy() on cleanup, or render the
SkillfulChatWidget component directly. Either way, react / react-dom are optional
peer deps — if your app already has React, the widget reuses it.
Configuration reference
SkillfulChat.init({
// ── required ──
apiUrl: string, // Skillful Agents API base URL
agentId: string, // agent UUID
token: string, // chat auth token (embed JWT — see Authentication)
// ── optional ──
organizationId?: string,
title?: string, // header title
greeting?: string, // first assistant message
placeholder?: string, // input placeholder
avatar?: string, // avatar image URL
showBranding?: boolean, // "Powered by Skillful" footer (default true)
session?: 'ephemeral' | 'persistent', // history model (default 'ephemeral')
theme?: {
mode?: 'light' | 'dark' | 'auto',
position?: 'bottom-right' | 'bottom-left',
primaryColor?: string, // brand color (hex)
backgroundColor?: string,
textColor?: string,
borderColor?: string,
fontFamily?: string,
borderRadius?: number, // px
bubbleSize?: number, // px
panelWidth?: number, // px
panelHeight?: number, // px
zIndex?: number,
},
voice?: {
enabled: boolean, // adds the "Talk" button + call view
apiKey?: string, // RAW API key / wallet JWT for the voice socket
agentId?: string, // defaults to top-level agentId
organizationId?: string,
agentName?: string, // name shown in the call UI
disableBargeIn?: boolean, // default true (open-speaker safe)
workletUrl?: string, // default '/audio/worklet-processor.js'
},
leadForm?: { // intro mode: form instead of a greeting
enabled: boolean,
title?: string,
description?: string,
submitLabel?: string,
fields?: Array<{ key, label, type?: 'text'|'email'|'tel', required?, placeholder? }>,
messageTemplate?: string, // {key} placeholders → submitted values
},
// callbacks
onMessage?: (m) => void,
onOpen?: () => void,
onClose?: () => void,
onError?: (err) => void,
});Authentication: test vs production
The widget is agnostic about how you get the token — it just takes one. That lets you start with the easy path and harden later with zero widget changes.
🧪 Test / quick (insecure — local only)
Mint the token in the browser, or pass the raw API key. Fast for trying things; never ship this — the API key would be visible in page source. The Customization Studio does this for you.
🔒 Production (server-side minting — like Zeno)
Your backend holds the API key and signs a short-lived embed JWT; the browser
only ever sees that token. This is exactly how the Zeno product does it
(zenobet-main-api skillful.service.generateEmbedToken). The token is a
standard HS256 JWT:
jwt.sign(
{ sub: '<unique-per-visitor>', agentId: '<AGENT_UUID>', iss: '<client-user-id>' },
API_KEY, // the HMAC secret — stays on the server
{ algorithm: 'HS256', expiresIn: '1h' },
)issmust be the client user id that owns the API key (the backend looks up that user's keys to verify the signature).subshould be unique per visitor/session so conversations don't co-mingle.
For WordPress, the included mu-plugin does this minting in PHP for you.
⚠️ Chat and voice use different credentials
Verified against the backend — this is the #1 gotcha:
| Channel | Endpoint | Accepts |
|---------|----------|---------|
| Chat | POST /api/embed/chat/{agentId} (REST/SSE) | the embed JWT (HS256, signed with the API key). Not a raw API key. |
| Voice | /socket.io connect auth | the raw API key (UUID) or a wallet JWT. Not the embed JWT. |
So put the embed JWT in token (chat) and the raw API key in voice.apiKey.
On a public site this exposes the key for voice — see the voice note.
Intro modes — greeting vs lead form
By default the widget opens with a greeting bubble. Set leadForm to switch to
lead-capture mode instead: on open it shows a short form (e.g. name / phone /
email), and on submit it fills messageTemplate with the values and sends that as the
user's first message — so there's no greeting, the first bubble is the visitor's,
and the agent replies to it (and now has their details). Each field's required flag is
set per deployment; email fields are format-validated.
leadForm: {
enabled: true,
title: 'Mielőtt kezdenénk…',
submitLabel: 'Indítás',
fields: [
{ key: 'name', label: 'Név', type: 'text', required: true },
{ key: 'phone', label: 'Telefonszám', type: 'tel', required: true },
{ key: 'email', label: 'E-mail cím', type: 'email', required: false },
],
messageTemplate:
'Üdvözlöm! A nevem {name}, a telefonszámom pedig {phone}, az e-mail címem pedig {email}.',
}The form reappears on a fresh conversation (new tab / "New conversation" button).
Conversation history & privacy
History lives in the visitor's own browser, namespaced per agent. The session
option chooses the store and therefore the isolation model:
| session | Store | Behavior |
|-----------|-------|----------|
| 'ephemeral' (default) | sessionStorage | One conversation per browser tab; survives reload in that tab but auto-clears when the tab closes and is never shared across tabs. Best for support / anonymous / shared devices — the next visitor never inherits the last one's chat. |
| 'persistent' | localStorage | Conversation resumes across reloads and future visits. Use only when the token sub is a real per-user account id. |
A "New conversation" button in the header clears and restarts at any time (either
mode). Server-side isolation also relies on a unique sub per visitor in the token.
WordPress
A ready-to-use PHP mu-plugin lives in wordpress/. It mints the
chat token server-side (API key never reaches the browser) and loads the widget
from jsDelivr — drop the file in wp-content/mu-plugins/, set four constants in
wp-config.php, done. Full guide: wordpress/README.md.
Voice on WordPress: the voice socket currently needs the raw API key in the browser, so the plugin keeps voice off by default. Enable it only on internal/trusted pages until the backend accepts the embed token on the socket too.
Customization Studio (playground)
playground.html is a live studio for tuning a deployment: toggle chat-only vs
chat + voice, pick brand colors / position / sizing, edit the copy, and copy the
generated embed snippet. Run it with npm run dev → http://localhost:5173/playground.html.
Browser support & requirements
- HTTPS is required for microphone access (voice). Any real site is fine;
localhostcounts as secure. - The Agents API must allow CORS from the embedding origin (chat REST and Socket.IO).
- Voice uses the AudioWorklet + getUserMedia APIs (all modern browsers; test iOS Safari, which is the fussiest).
- The AudioWorklet file is loaded at runtime from
voice.workletUrl— it can't be inlined intowidget.js, so make suredist/audio/worklet-processor.jsis reachable (the CDN URL handles this automatically). - Shadow DOM isolates the widget from the host page's CSS.
Local development
npm install
npm run dev # dev server + Customization Studio at http://localhost:5173
npm run build # → dist/widget.js (IIFE), dist/chat-widget.js (ESM), dist/audio/, *.d.ts
npm run typecheck # tsc --noEmitPublishing (npm → jsDelivr CDN)
npm version patch # bump
npm publish --access public
# → served at https://cdn.jsdelivr.net/npm/@skillful-ai/agent-widget@<version>/dist/widget.jsprepublishOnly rebuilds automatically. Only dist/ + this README are published —
no source, no playground, no secrets.
Project structure
| Path | What |
|------|------|
| src/init.ts | IIFE entry — exposes window.SkillfulChat.init() |
| src/index.ts | ESM entry — mountWidget, SkillfulChatWidget, types |
| src/core/use-chat.ts | Chat over SSE (src/core/api-client.ts, sse-parser.ts) |
| src/core/storage.ts | Session/history storage (ephemeral vs persistent) |
| src/core/voice/use-voice-call.ts | Realtime voice call over Socket.IO |
| src/core/voice/{audio-recorder,audio-player,speech-detector}.ts | Mic capture · playback · VAD |
| public/audio/worklet-processor.js | 16 kHz PCM AudioWorklet → dist/audio/ |
| src/components/CallPanel.tsx | Voice-call UI (reactive orb + transcript) |
| src/components/* | Chat bubble, panel, header, messages |
| playground.html | Customization Studio |
| wordpress/ | PHP mu-plugin + install guide |
Built on the proven chat widget (SSE, Shadow-DOM) with the voice engine ported from
huntline-app-fe (Socket.IO realtime + AudioWorklet). Chat and voice hit the same
apiUrl: chat via REST/SSE, voice via io(apiUrl, { path: '/socket.io' }).
