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)Base URL of the box, for example https://your-box.example.com.
Must start with http:// or https://.
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.
A full zsk_live_ key. Server side only. Omit
only for a box running with auth open.
{ avatarId?, voiceId? }. These are the only two persona
fields that reach a box.
Budget for the media plane and the gateway handshake.
ICE servers for NAT traversal. Defaults to a public STUN server.
Supply WebRTC where the runtime has none, which means Node.
Supply a WebSocket where the runtime has none, which means Node before v22.
Supply fetch, for a proxy or for a test.
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.
| Credential | Prefix | Lives | Can do |
|---|---|---|---|
| Session token | zsk_temp_ | The browser | Stream. Scope is tts, and every mutating route refuses it. |
| API key | zsk_live_ | Your server, only | Everything, 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" } }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, loopModeAn 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.
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.
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:
Subscribe to the inbound audio track.
Subscribe to the inbound video track.
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.
How long to keep polling.
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.
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();