Zeli AvatarDeveloper docs

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.

The one thing to get right

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();
}
unmute() needs a real gesture

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

  1. Your server exchanged a long-lived API key for a short-lived, stream only token, so the key never left your infrastructure.
  2. The SDK negotiated a WebRTC media connection to the box and opened the control gateway, then emitted SESSION_READY.
  3. talk went straight to text to speech. sendUserMessage runs the box's conversational loop instead: the reply streams back as MESSAGE_STREAM_EVENT_RECEIVED chunks and a final MESSAGE_RECEIVED, while the avatar speaks it on the video track.

Next steps

Zeli Avatar · real-time avatars over WebRTC · self-hostable · AU data residency · source