Zeli AvatarDeveloper docs

JavaScript SDK / Management API

Management API

Server side onlyNeeds a full API key

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

This must never reach a browser bundle

ManagementApi takes a long-lived API key (zsk_live_). Anything in page JavaScript is readable by every visitor, and a leaked full key can create avatars, upload voices, change settings, delete things and mint more tokens. The browser gets a short-lived session token (zsk_temp_) and nothing else. If you find yourself importing this into client code, the fix is a server route, not a smaller key.

The boundary, in one picture

Runs whereCredentialCan do
ManagementApiYour serverzsk_live_ API keyEverything on this page
ZeliClient session halfThe browserzsk_temp_ session tokenStream, talk, interrupt

The token is stream only, expires in minutes, and the box refuses every mutating route for it, so a leaked one cannot do damage. That property only holds while the key that minted it stays on your server.

import { ZeliClient, type ManagementApi } from "@zeligate/zeli-avatar";
 
// your backend, never bundled for the browser
const admin = new ZeliClient({
  serverUrl: process.env.ZELI_BOX_URL!,
  apiKey: process.env.ZELI_API_KEY!,
});
 
const api: ManagementApi = admin.management;

Session tokens

createSessionToken(options?): Promise<SessionToken>

Mint the short-lived credential a browser streams with. Call this from a server. It needs a full API key, and the whole point of the token it returns is that the full key never reaches a browser.

expiresInSecondsnumberOptional

Requested lifetime. The box clamps it, currently to at most 600.

Returns { token, expiresAt, scope }. expiresAt is unix seconds. scope is always tts: stream only, and it cannot mint another.

app.post("/api/avatar-token", requireLogin, async (req, res) => {
  const { token, expiresAt } = await admin.management.createSessionToken({
    expiresInSeconds: 300,
  });
  res.json({ token, expiresAt });
});

Configuration

getSettings(): Promise<SettingsSnapshot>

The calling tenant's effective settings, with the model and text to speech catalogues: { settings, models, effort_levels, reasoning_levels, tts_providers, persisted }.

updateSettings(patch): Promise<SettingsSnapshot & { ok, saved }>

Every field of SettingsPatch is optional: model, live_model, system_prompt, max_tokens, top_p, temperature, effort, reasoning, tts_provider, avatar, emotion_responsive, voice_id, plus per-provider voice ids.

getStatus(): Promise<BoxStatus>

Readiness of the two subsystems a reply needs: { llm, tts, ready, voice_origin, voice_provider, llm_state, tts_state, starting }.

clearConversation(): Promise<void>

Resets this caller's conversation. Only this caller's, never another tenant's on the same box.

Avatars

listAvatars(): Promise<AvatarCatalogue>

Every avatar on the box, with its tone variants and what is still preparing.

createAvatar(options): Promise<{ avatar, status, tones, note, active }>

Create an avatar from a portrait or a video.

fileBlobRequired

The portrait or footage. A browser File, or a Blob plus filename.

filenamestringOptional

Names the avatar, because the box derives the id from the filename. Two uploads called portrait.png are the same avatar and the second overwrites the first, which is worth knowing before it happens to you.

tonesstring[]Optional

Tone variants to build.

consentTokenstringOptional

Consent evidence for the likeness, where the box requires it.

framingstringOptional

Crop and framing for the generated clips.

gestureAmplitudestringOptional

How much the avatar moves.

Answers immediately with status: "preparing". A multi-tone build is minutes of paid generation, so the upload is not held open for it. Poll listAvatarClips for the outcome. The returned tones are the ones actually coming, which may be fewer than you asked for, and note says why.

createPhotoAvatar(options): Promise<{ avatar_id, clips }>

Takes everything createAvatar does, plus a required avatarId. Unlike createAvatar this one is synchronous on the box and holds the connection until every clip is rendered, which can be minutes. It is also gated behind a feature flag and a consent check on the photo, and throws ConfigurationError where the box does not have it enabled.

deleteAvatar(avatarId): Promise<{ deleted, active }>

Deletes an uploaded avatar, never a stock one. A 404 means "not found, or not yours" and deliberately does not distinguish the two, so this cannot be used to discover what another tenant owns.

listAvatarClips(avatarId): Promise<AvatarClipList>

How each emotional variant is getting on. Accepts an avatar id or any of its variant ids. Each AvatarClip carries { tone, state, variant_id, duration_s, updated_at, detail }, where state is generated, generating, failed or not_requested.

Sparse on purpose

A tone the box has never been asked for is absent rather than listed as not_requested. Treat a missing tone as not requested, not as an error.

retryAvatarClip(avatarId, tone)

Ask for one tone to be generated again. Answers 202, not 200: the build takes minutes and the outcome arrives through listAvatarClips. A 409 means one is already running for that tone, so it is a reason to wait rather than to retry in a loop.

getAvatarClip(avatarId, tone?): Promise<BinaryAsset>

One rendered clip as MP4 bytes, { bytes, contentType }.

getAvatarPreview(avatarId): Promise<BinaryAsset>

A still frame as image bytes. A 404 has two meanings worth telling apart by their message: an unknown avatar, or one that exists but has not been prepared yet. The second is worth retrying and the first is not.

Voices

listVoices(): Promise<VoiceCatalogue>

Every voice the caller's engine offers.

previewVoice(voiceId?): Promise<BinaryAsset>

Audition a voice: a short WAV of a fixed sentence.

createVoice(options): Promise<{ ok, voice_id, voices, current }>

Clone a voice from a reference clip.

fileBlobRequired

The reference clip. Must be at least 6 seconds of audio; the box measures it and refuses shorter ones rather than producing a bad clone.

namestringRequired

Display name. The box rejects a blank one.

filenamestringOptional

Needed when file is a bare Blob rather than a File, since the box validates the extension.

Slow, and available only where the box runs the on-instance voice engine.

prepareVoice(voiceId): Promise<Record<string, unknown>>

Ask the engine to make a voice ready to speak. Call it after createVoice and when switching voice, so a UI can show progress while the clone runs rather than appearing to hang.

uploadAudio(file, filename?): Promise<{ name }>

Upload an audio file for the box to play.

Example: the whole server side

import { ZeliClient } from "@zeligate/zeli-avatar";
 
const admin = new ZeliClient({
  serverUrl: process.env.ZELI_BOX_URL!,
  apiKey: process.env.ZELI_API_KEY!,
});
 
// 1. build an avatar from a portrait
const created = await admin.management.createAvatar({
  file: portrait,
  filename: "acme-host.png",
  tones: ["neutral", "confident"],
});
console.log(created.status, created.tones.join(", "), created.note);
 
// 2. wait for it, then check what actually rendered
await admin.waitUntilAvatarReady(created.avatar);
const { clips } = await admin.management.listAvatarClips(created.avatar);
for (const clip of clips) console.log(clip.tone, clip.state, clip.detail);
 
// 3. hand the browser a token, and only a token
const { token } = await admin.management.createSessionToken({ expiresInSeconds: 300 });
Zeli Avatar · real-time avatars over WebRTC · self-hostable · AU data residency · source