Speko Docs

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/embed

Attributes

Everything else on the element is either refused or ignored.

AttributeValueMeaning
token-endpointURLYour endpoint that mints the session. The normal way to supply credentials.
transport-tokenstringA token you already minted. Read once, then removed from the DOM.
transport-urlwss://…The transport URL that came with that token.
labelstringAccessible name for the launcher and panel. Defaults to Speko voice assistant.
dockbottom-right | bottom-left | top-right | top-leftPins the widget to a viewport corner. Omit it and the element lays out inline where you placed it.
openbooleanPresent 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:

FieldTypeRequiredMeaning
transportTokenstringyesPass through unchanged from POST /v1/sessions.
transportUrlstringyesMust be a WebSocket URL (wss://). Pass through unchanged.
sessionIdstringnoSurfaces 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 attributeWhy
variables, dynamic-variablesPer-session variables are bound at mint, before the transport token exists.
tool-secretsTool credentials go to tool execution only and must never enter the page.
webhook-tagsWebhook routing decides which of your endpoints hears about this session.
api-keyA Speko API key mints sessions for your whole organization.
agent-idYour backend picks the agent at mint time; the page cannot be trusted to.
override-prompt, override-first-message, override-language, override-voice-idAgent 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.

StateMeaning
idleNo call. The launcher is ready.
authorizingFetching credentials from token-endpoint.
connectingJoining the transport. The microphone prompt happens here.
connectedLive.
endingHanging up.
errorThe last attempt failed, or an attribute was refused. start() retries.

Events

All four bubble and cross shadow boundaries, so you can listen on document.

Eventdetail
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

MemberTypeNotes
stategetterCurrent state.
sessionIdgetterFrom the token endpoint, once connected.
mutedgetterMicrophone state.
opengetter/setterExpands or collapses the panel. Reflects the open attribute.
tokenEndpointgetter/setterReflects the token-endpoint attribute.
transportTokensetterWrite-only. A token set here never touches the DOM, which makes it the better path for scripted embedders. Reading it back returns nothing.
transportUrlgetter/setterThe 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.

PropertyDefaultControls
--speko-accent#1f6febLauncher, user bubbles, focus rings
--speko-accent-text#ffffffText on the accent colour
--speko-surface#ffffffPanel background
--speko-surface-muted#f4f6f8Panel header and footer
--speko-text#14171cBody text
--speko-text-muted#5c636eStatus line, timestamps
--speko-border#e4e7ecPanel and control borders
--speko-live#178a5aThe connected indicator
--speko-danger#c0362cErrors and the hang-up control
--speko-agent-bubble#f1f3f6Agent transcript bubble
--speko-agent-bubble-textvar(--speko-text)Agent bubble text
--speko-user-bubblevar(--speko-accent)Visitor transcript bubble
--speko-user-bubble-textvar(--speko-accent-text)Visitor bubble text
--speko-fontsystem stackFont family
--speko-font-size14pxBase size
--speko-radius18pxCorner radius
--speko-shadowlayeredPanel and launcher shadow
--speko-launcher-size56pxLauncher diameter
--speko-panel-width360pxPanel width when docked
--speko-transcript-height250pxScrolling 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.

CodeCauseWhat to do
TOKEN_ENDPOINT_FAILEDThe 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_INVALIDThe 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_CONFIGUREDNeither token-endpoint nor a token pair was set.Set token-endpoint.
MICROPHONE_DENIEDThe visitor denied the microphone, or the device has none.The minted token is unspent — start() again after they grant it.
CONNECTION_FAILEDThe transport refused or dropped the connection.Retry. Check the session in the dashboard if it repeats.
REFUSED_ATTRIBUTEA refused attribute is on the element.Move the value into your POST /v1/sessions call and remove the attribute.

Next

On this page