Embed element
The <speko-voice> custom element — a voice widget for any page.
@spekoai/embed ships one custom element, <speko-voice>. It renders a docked launcher, a transcript panel and mute, connects through VoiceConversation, and takes no framework. Point it at a URL on your own backend that mints the session:
<script type="module">
import '@spekoai/embed';
</script>
<speko-voice token-endpoint="/api/speko-session" dock="bottom-right"></speko-voice>There is no CDN build yet, so the import above needs a bundler. A <script src="…"> tag that
registers the element from a URL is not published.
Importing the package registers the element. The package is marked sideEffects: true so a bundler cannot tree-shake that registration away.
npm install @spekoai/embedAttributes
Everything else on the element is either refused or ignored.
| Attribute | Value | Meaning |
|---|---|---|
token-endpoint | URL | Your endpoint that mints the session. The normal way to supply credentials. |
transport-token | string | A token you already minted. Read once, then removed from the DOM. |
transport-url | wss://… | The transport URL that came with that token. |
label | string | Accessible name for the launcher and panel. Defaults to Speko voice assistant. |
dock | bottom-right | bottom-left | top-right | top-left | Pins the widget to a viewport corner. Omit it and the element lays out inline where you placed it. |
open | boolean | Present means the panel is expanded. Reflected, so it tracks the visitor's clicks. |
The token endpoint
The element sends POST to token-endpoint with accept: application/json and no request body. The endpoint runs on your origin and already knows who the visitor is from its own session. The page has nothing to contribute, and deliberately no channel through which it could.
Answer with a JSON object:
| Field | Type | Required | Meaning |
|---|---|---|---|
transportToken | string | yes | Pass through unchanged from POST /v1/sessions. |
transportUrl | string | yes | Must be a WebSocket URL (wss://). Pass through unchanged. |
sessionId | string | no | Surfaces on the element as sessionId, for correlating with your own logs. |
Validation is strict, and it catches two mistakes early: a backend that proxies the whole POST /v1/sessions response, and one that returns an HTML error page with a 200.
// POST /api/speko-session — your server, your session cookie
export async function POST(request: Request) {
const candidate = await currentUser(request); // your auth, not Speko's
const session = await fetch('https://api.speko.dev/v1/sessions', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SPEKO_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
mode: 'cascade',
agentId: process.env.SPEKO_AGENT_ID,
variables: { candidate_first_name: candidate.firstName },
}),
}).then((r) => r.json());
// Forward these three and nothing else.
return Response.json({
transportToken: session.transportToken,
transportUrl: session.transportUrl,
sessionId: session.sessionId,
});
}Everything that shapes the call is bound in that mint call, before a transport token exists — the agent, per-session variables, tool credentials and webhook tags.
Attributes it refuses
The element presents a session; it never configures one. The attributes below are refused, not ignored: the element paints the reason into the panel, names the attribute, and will not dial. An ignored attribute teaches an embedder nothing — they ship, it looks fine, and the value quietly never reaches the agent.
The concrete case this was designed against is an interview widget on a job application page. If variables were an attribute, the candidate would be the one supplying their own name — and their own scoped tool token.
| Refused attribute | Why |
|---|---|
variables, dynamic-variables | Per-session variables are bound at mint, before the transport token exists. |
tool-secrets | Tool credentials go to tool execution only and must never enter the page. |
webhook-tags | Webhook routing decides which of your endpoints hears about this session. |
api-key | A Speko API key mints sessions for your whole organization. |
agent-id | Your backend picks the agent at mint time; the page cannot be trusted to. |
override-prompt, override-first-message, override-language, override-voice-id | Agent configuration, not page markup. |
Each belongs in the POST /v1/sessions call your token-endpoint makes, where the visitor cannot reach it. The list covers the migration surface — the attributes someone moving off another vendor's widget would copy across, plus the Speko-shaped equivalents.
States
The current state is readable as element.state and mirrored to a data-state attribute you can style against.
| State | Meaning |
|---|---|
idle | No call. The launcher is ready. |
authorizing | Fetching credentials from token-endpoint. |
connecting | Joining the transport. The microphone prompt happens here. |
connected | Live. |
ending | Hanging up. |
error | The last attempt failed, or an attribute was refused. start() retries. |
Events
All four bubble and cross shadow boundaries, so you can listen on document.
| Event | detail |
|---|---|
speko-connect | { conversationId, sessionId } — sessionId is null when the endpoint omitted it. |
speko-disconnect | { reason } |
speko-error | { code, message } — code is one of the error codes; message is written to be shown to a visitor. |
speko-state-change | { state, previous } |
const widget = document.querySelector('speko-voice');
widget.addEventListener('speko-connect', (event) => {
analytics.track('voice_call_started', { sessionId: event.detail.sessionId });
});
widget.addEventListener('speko-error', (event) => {
console.error(event.detail.code, event.detail.message);
});JavaScript API
| Member | Type | Notes |
|---|---|---|
state | getter | Current state. |
sessionId | getter | From the token endpoint, once connected. |
muted | getter | Microphone state. |
open | getter/setter | Expands or collapses the panel. Reflects the open attribute. |
tokenEndpoint | getter/setter | Reflects the token-endpoint attribute. |
transportToken | setter | Write-only. A token set here never touches the DOM, which makes it the better path for scripted embedders. Reading it back returns nothing. |
transportUrl | getter/setter | The URL paired with that token. |
start() | Promise<void> | Begins a call. A no-op unless the state is idle or error. |
stop() | Promise<void> | Ends the call, or cancels one still connecting. |
setMuted(muted) | Promise<void> | Mutes or unmutes. Optimistic, and rolls back if the transport refuses. |
start(), stop() and setMuted() report their own failures through speko-error, so a rejected promise is not the channel to watch.
await document.querySelector('speko-voice').start();Theming
The element renders into a shadow root, so page CSS cannot reach inside it. Theming is by custom property, set on the element or on any ancestor. Defaults live on :host at single-class specificity, which means one declaration of your own wins.
Colours have a dark-scheme default under prefers-color-scheme: dark, so overriding one value in light mode only will show through in dark mode.
| Property | Default | Controls |
|---|---|---|
--speko-accent | #1f6feb | Launcher, user bubbles, focus rings |
--speko-accent-text | #ffffff | Text on the accent colour |
--speko-surface | #ffffff | Panel background |
--speko-surface-muted | #f4f6f8 | Panel header and footer |
--speko-text | #14171c | Body text |
--speko-text-muted | #5c636e | Status line, timestamps |
--speko-border | #e4e7ec | Panel and control borders |
--speko-live | #178a5a | The connected indicator |
--speko-danger | #c0362c | Errors and the hang-up control |
--speko-agent-bubble | #f1f3f6 | Agent transcript bubble |
--speko-agent-bubble-text | var(--speko-text) | Agent bubble text |
--speko-user-bubble | var(--speko-accent) | Visitor transcript bubble |
--speko-user-bubble-text | var(--speko-accent-text) | Visitor bubble text |
--speko-font | system stack | Font family |
--speko-font-size | 14px | Base size |
--speko-radius | 18px | Corner radius |
--speko-shadow | layered | Panel and launcher shadow |
--speko-launcher-size | 56px | Launcher diameter |
--speko-panel-width | 360px | Panel width when docked |
--speko-transcript-height | 250px | Scrolling transcript height |
Motion is four durations and three easings: --speko-dur-instant (100ms), --speko-dur-fast (150ms), --speko-dur-base (200ms), --speko-dur-slow (300ms), --speko-ease-standard, --speko-ease-out, --speko-ease-in. Under prefers-reduced-motion: reduce the durations drop to 1ms and the widget stops moving without changing shape.
speko-voice {
--speko-accent: #0f766e;
--speko-radius: 8px;
--speko-panel-width: 400px;
}Icons are inline SVG and there are no webfonts or remote assets, so the widget renders under a strict Content-Security-Policy without changes on your side.
Errors
Every code below arrives as event.detail.code on speko-error. The element never rejects a promise at you — the same codes are exported as the EmbedErrorCode type if you want to switch on them in TypeScript.
| Code | Cause | What to do |
|---|---|---|
TOKEN_ENDPOINT_FAILED | The endpoint was unreachable, non-2xx, or returned non-JSON. | Check the endpoint's own logs. The element reports status only, never the failed body. |
TOKEN_ENDPOINT_INVALID | The response was missing transportToken or transportUrl, or the URL was not wss://. | Return the two fields at the top level, unchanged from POST /v1/sessions. |
NOT_CONFIGURED | Neither token-endpoint nor a token pair was set. | Set token-endpoint. |
MICROPHONE_DENIED | The visitor denied the microphone, or the device has none. | The minted token is unspent — start() again after they grant it. |
CONNECTION_FAILED | The transport refused or dropped the connection. | Retry. Check the session in the dashboard if it repeats. |
REFUSED_ATTRIBUTE | A refused attribute is on the element. | Move the value into your POST /v1/sessions call and remove the attribute. |