Zeli AvatarDeveloper docs

JavaScript SDK / ZeliClient

ZeliClient

The client holds your configuration and event handlers, opens sessions, and carries the server side management namespace. One instance serves one live session at a time.

import { ZeliClient } from "@zeligate/zeli-avatar";

Constructor

new ZeliClient(options: ZeliClientOptions)
serverUrlstringRequired

Base URL of the box, for example https://your-box.example.com. Must start with http:// or https://.

sessionTokenstringOptional

A short-lived zsk_temp_ credential. This is the browser-safe one. Takes precedence over apiKey when both are set, because the only reason to have both is a copy and paste mid-migration and the safer one should win.

apiKeystringOptional

A full zsk_live_ key. Server side only. Omit only for a box running with auth open.

avatarAvatarConfigOptional

{ avatarId?, voiceId? }. These are the only two persona fields that reach a box.

connectTimeoutMsnumberOptionaldefault: 30000

Budget for the media plane and the gateway handshake.

iceServersRTCIceServer[]Optional

ICE servers for NAT traversal. Defaults to a public STUN server.

peerConnectionFactoryPeerConnectionFactoryOptional

Supply WebRTC where the runtime has none, which means Node.

webSocketFactoryWebSocketFactoryOptional

Supply a WebSocket where the runtime has none, which means Node before v22.

fetchFetchLikeOptional

Supply fetch, for a proxy or for a test.

Raises ConfigurationError

A missing serverUrl, or one that does not start with http:// or https://, throws at construction. So does naming a persona field the box does not read, see Inert persona fields.

Credentials

There are two credentials and they are not interchangeable.

CredentialPrefixLivesCan do
Session tokenzsk_temp_The browserStream. Scope is tts, and every mutating route refuses it.
API keyzsk_live_Your server, onlyEverything, including creating avatars and minting more tokens.

Your backend mints a token per visitor with its full key, through management.createSessionToken, or by posting the route directly:

POST https://your-box.example.com/v1/streaming.create_token
X-Api-Key: zsk_live_...
-> { "data": { "token": "zsk_temp_...", "expires_at": ..., "scope": "tts" } }
A full key in page JavaScript is a full key given away

Every visitor can read a bundle. A leaked zsk_live_ key can create avatars, upload voices, change settings and mint tokens. A leaked zsk_temp_ token expires in minutes and can only stream.

Persona fields, and which ones reach a box

Four fields travel. avatarId and llmId go on the connection body, voiceId rides every gateway command, and maxSessionLengthSeconds goes on the gateway's upgrade URL. Both lists are exported, so a caller can check either one rather than reading this page:

import { WIRE_AVATAR_FIELDS, INERT_AVATAR_FIELDS } from "@zeligate/zeli-avatar";
// WIRE_AVATAR_FIELDS:  avatarId, voiceId, llmId, maxSessionLengthSeconds
// INERT_AVATAR_FIELDS: name, systemPrompt, languageCode, emotionResponsive,
//                      enhance, enhanceStrength, loopMode

An avatar's system prompt, language and render settings are configured once in the portal, per box rather than per session. Naming one of them in avatar throws a ConfigurationError that says where the setting actually lives, rather than being accepted and silently ignored.

Two fields left that list

llmId and maxSessionLengthSeconds were both on the inert list, and both were refused at construction time, after the box had grown the thing each was asking for. That is the inverse of a silently ignored field and it is worse: it turns a capability the product backs into an error. The session ceiling is the sharper case, because a browser is exactly where an abandoned tab holds hardware open, so the SDK that could not arm the cap was the one that most needed it.

Events

addListener(event, listener): void

Register a listener. Returns nothing.

removeListener(event, listener): void

Remove a listener registered with addListener.

on(event, listener): () => void

Register a listener and get back a function that removes it again.

off(event, listener): void

Remove a listener. Equivalent to removeListener.

on and off are not deprecated

addListener and removeListener are the canonical names. on and off are kept alongside them because on differs in what it gives back, not only in spelling: it hands you an unsubscribe closure, which is the ergonomic every framework cleanup hook wants. Collapsing the two would take a capability away rather than rename one. Use whichever suits the call site.

import { ZeliEvent } from "@zeligate/zeli-avatar";
 
const off = client.on(ZeliEvent.MESSAGE_RECEIVED, (m) => render(m));
client.addListener(ZeliEvent.SERVER_WARNING, report);
// later
off();
client.removeListener(ZeliEvent.SERVER_WARNING, report);

Handlers are typed per event. See Events.

Connecting

connect(sessionOptions?): Promise<Session>

Opens a Session. Optionally takes SessionOptions:

receiveAudiobooleanOptionaldefault: true

Subscribe to the inbound audio track.

receiveVideobooleanOptionaldefault: true

Subscribe to the inbound video track.

idleAvatarIdstringOptional

A separate idle clip to hold between utterances. Omit and the box picks the requested avatar's own idle clip.

streamToVideoElement(target, sessionOptions?)

Connect and put the avatar on screen in one call. Returns { session, muted, reason? }. target is a <video> element, its id, or a CSS selector.

The element is resolved before connecting, because a typo in an element id is by far the most likely failure and finding it out first costs nothing, while finding it out afterwards costs a connected session. On any failure after the session is live the session is closed before the error propagates, so a page that could not find its video element does not also leak a session on the box.

See Media for the muted flag.

Catalogues

listAvatars(): Promise<AvatarCatalogue>

The avatars this box can stream, from GET /api/avatars. The catalogue returns three views of the same set: avatars (every streamable id, tone suffix included), groups (one entry per person, keyed by the base id), and preparing (ids still being built).

listVoices(): Promise<VoiceCatalogue>

The voices this box can speak with, from GET /api/voices. An entry's id goes straight into avatar.voiceId. Entries flagged recommended are the box's own shortlist, worth defaulting a picker to.

waitUntilAvatarReady(avatarId, options?): Promise<void>

Polls until avatarId is ready to stream, or throws TimeoutError.

timeoutMsnumberOptionaldefault: 300000

How long to keep polling.

pollIntervalMsnumberOptionaldefault: 2000

Gap between catalogue reads.

The pure helpers behind it are exported too, for when you already hold a catalogue:

import { avatarIsReady, baseAvatarId, TONE_SEPARATOR } from "@zeligate/zeli-avatar";
 
const catalogue = await client.listAvatars();
avatarIsReady(catalogue, "01-presenter-male__confident");   // boolean
baseAvatarId("01-presenter-male__confident");               // "01-presenter-male"
TONE_SEPARATOR;                                             // "__"

Transcript

getMessageHistory(): Message[]

The conversation accumulated so far. See Events.

Custom tools

registerToolCallHandler(name, handler, options?): () => void

Answers one of your custom tools. Returns the function that unregisters it. Register before connect(): the persona can call a tool on its first turn, and a call that finds no handler is refused at once.

const unregister = client.registerToolCallHandler(
  "open_device_settings",
  async ({ section }, call) => {
    await settingsPanel.open(section, { signal: call.signal });
    return { opened: true };
  },
);

handler(args, call) may be sync or async. An async handler never holds up the socket; long synchronous work blocks the thread, as any JavaScript does. What it returns is sent as the result. A thrown ToolCallError is sent as a failed result carrying its message, and the persona says it could not be done. Any other throw is sent as the generic "The tool failed." and delivered whole to ZeliEvent.ERROR, so a driver's error text never reaches the model. A result may be at most 256 KB of JSON (MAX_TOOL_RESULT_BYTES); a larger one is answered as a failure. call.signal aborts when the answer can no longer matter: the timeout ran out, the server gave up, or the socket closed. options.timeoutMs, in milliseconds, gives up sooner than the server would. One handler per name; a second is refused with ConfigurationError.

Whatever a handler returns or throws is read by a language model as untrusted data. Never return a secret.

pendingToolCalls: number

How many tool calls are waiting on a handler right now. Zero after a session closes: a call in flight at that moment is aborted and reported on TOOL_CALL_FAILED with session_closed.

Management

management: ManagementApi

Everything that is not the live conversation: creating avatars and voices, reading and writing configuration, minting session tokens. A namespace rather than more methods on the client, so the privileged half is visible in one place instead of scattered through the surface a page uses.

Server side only

Most of management needs a full API key. Reach for it from a server. See Management API.

Example

import { ZeliClient, ZeliEvent } from "@zeligate/zeli-avatar";
 
const client = new ZeliClient({
  serverUrl: "https://your-box.example.com",
  sessionToken: token,
  avatar: { avatarId: "01-presenter-male__confident" },
});
 
client.on(ZeliEvent.MESSAGE_RECEIVED, (m) => console.log(`${m.role}: ${m.content}`));
client.on(ZeliEvent.SERVER_WARNING, (message) => console.warn("[box]", message));
 
const { session, muted } = await client.streamToVideoElement("stage");
if (muted) showUnmuteButton(() => session.unmute());
 
await session.sendUserMessage("Introduce yourself in one sentence.");
await session.waitUntilClosed();
Zeli Avatar · real-time avatars over WebRTC · self-hostable · AU data residency · source