JavaScript SDK / Quickstart
Quickstart
Two pieces of code, on two machines. Your server mints a
short-lived session token with your API key. Your page takes
that token, connects, and puts the avatar on screen. Swap
https://your-box.example.com for your box.
A full API key (zsk_live_) can create avatars, upload voices and
mint more tokens. Anything in page JavaScript is readable by every visitor, so
a full key in a bundle is a full key given away. The browser gets a
session token (zsk_temp_) and nothing else. See
Management API.
1. Mint a token on your server
// your backend. Never bundled for the browser.
import { ZeliClient } from "@zeligate/zeli-avatar";
const admin = new ZeliClient({
serverUrl: process.env.ZELI_BOX_URL!,
apiKey: process.env.ZELI_API_KEY!, // zsk_live_...
});
export async function mintToken() {
const { token, expiresAt, scope } = await admin.management.createSessionToken({
expiresInSeconds: 300,
});
return { token, expiresAt, scope }; // scope is always "tts"
}The token expires in minutes and is stream only: the box refuses every mutating route for it, so a leaked one cannot delete an avatar or mint another token. The box clamps the lifetime, currently to at most 600 seconds.
2. Put the avatar on the page
<video id="stage" playsinline></video>import { ZeliClient } from "@zeligate/zeli-avatar";
const { token } = await fetch("/api/avatar-token").then((r) => r.json());
const client = new ZeliClient({
serverUrl: "https://your-box.example.com",
sessionToken: token,
avatar: { avatarId: "01-presenter-male__confident" },
});
const { session, muted } = await client.streamToVideoElement("stage");
await session.talk("Hello, welcome to Zeli.");That is the whole thing: connect, attach, play. streamToVideoElement takes an
element, an element id or a CSS selector, sets playsInline so iOS does not go
fullscreen, and starts playback.
3. Handle the muted start
Browsers refuse to start an unmuted video until the visitor has interacted with
the page, so on a cold load the avatar plays silently and muted comes back
true. That is a state the browser puts you in rather than an error, which is
why it is reported instead of thrown. Offer a control and restore sound from the
click:
if (muted) {
unmuteButton.hidden = false;
unmuteButton.onclick = () => session.unmute();
}session.unmute() must be called from inside a click or tap
handler. Called from anywhere else the browser simply refuses again, which
looks like the method is broken when it is the policy doing what it says.
4. Listen in
import { ZeliEvent } from "@zeligate/zeli-avatar";
client.on(ZeliEvent.MESSAGE_RECEIVED, (m) => console.log(`${m.role}: ${m.content}`));
// Worth registering. A box that could not prepare your avatar streams a
// stand-in face and says so ONLY here.
client.on(ZeliEvent.SERVER_WARNING, (message) => console.warn("[box]", message));
await session.sendUserMessage("Introduce yourself in one sentence.");client.on returns an unsubscribe function, which is what a framework cleanup
hook wants:
const off = client.on(ZeliEvent.SERVER_WARNING, report);
// later
off();5. Stop
await session.stopStreaming();Symbol.asyncDispose is wired, so await using works on a runtime that
supports it.
What just happened
- Your server exchanged a long-lived API key for a short-lived, stream only token, so the key never left your infrastructure.
- The SDK negotiated a WebRTC media connection to the box and opened the
control gateway, then emitted
SESSION_READY. talkwent straight to text to speech.sendUserMessageruns the box's conversational loop instead: the reply streams back asMESSAGE_STREAM_EVENT_RECEIVEDchunks and a finalMESSAGE_RECEIVED, while the avatar speaks it on the video track.