Zeli AvatarDeveloper docs

JavaScript SDK / Session

Session

A Session is what client.connect() hands back. You do not construct it. One box instance serves one live session at a time.

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

Identity and state

getActiveSessionId(): string

The box-issued id for this session.

isStreaming(): boolean

Whether this session is still streaming.

MemberTypeDescription
requestedAvatarstring | nullThe avatar id this session asked for. Not proof of what is on screen: the box echoes the requested id back verbatim, including when it could not prepare it.
substitutedAvatarstring | nullThe face actually rendering, when it is not the one requested. null in the normal case.
tonesstring[]Tone names this avatar accepts in talk({ tone }). Per avatar, so it can never be a constant in an SDK.
hasControlChannelbooleantrue when the full control gateway is available, which is what enables talk, talk streams and streamed events.
tracksMediaStreamTrackLike[]Inbound tracks, in arrival order.
mediaStreamunknownThe inbound tracks as a MediaStream, or null under Node.
infoSessionInfo{ sessionId, avatar, tones } as the gateway reported it.
Check what you were given

Asking for an avatar the box cannot prepare is not an error there. The box answers 200, echoes the id you asked for, and streams a clip it does have. Read substitutedAvatar, or listen for ZeliEvent.SERVER_WARNING, which is the simplest way to catch it.

Driving the avatar

sendUserMessage(text): Promise<void>

Sends user text through the conversational model; the avatar speaks the reply. Throws SessionError on empty text. Falls back to an HTTP POST /api/chat when the control gateway is absent, so this is the one drive method that works everywhere.

talk(text, options?): Promise<void>

Speaks text straight through text to speech, bypassing the model and the transcript. Optional tone colours the delivery. Requires the control gateway.

A tone this box never advertised is not an error: the gateway passes any string to the voice engine, which quietly delivers the default emotion. The SDK raises a SERVER_WARNING so the mismatch is visible rather than silent.

createTalkMessageStream(options?): TalkMessageStream

Returns a TalkMessageStream for pushing text to text to speech as it is produced. Requires the control gateway.

tonestringOptional

Tone variant to speak this utterance in.

correlationIdstringOptional

Your own id for this utterance, so an id minted upstream threads straight through. Omitted, one is generated. See Correlation ids.

interruptPersona(correlationId?): Promise<void>

Stops the avatar mid-utterance, which is barge-in. Requires the control gateway. Emits TALK_STREAM_INTERRUPTED.

Pass correlationId to stop one specific utterance. Omitted, the most recent thing this session asked to be said is stopped, which is what a caller means by stop almost every time.

Any talk stream it stops is marked before the frame goes out rather than after. The send can reject, and a caller who saw interruptPersona() throw should still see the stream reporting interrupted rather than a state claiming the utterance is fine when the avatar may well have stopped. With an explicit target, only the stream carrying that id is marked.

Gateway-gated methods

talk, createTalkMessageStream and interruptPersona need the box's control gateway (/api/session/ws). On a box without it they throw SessionError. Check session.hasControlChannel and use sendUserMessage instead, or upgrade the box.

newConversation(): Promise<string | null>

Close this conversation and start a fresh one, without tearing down the session. Returns the new conversation id, or null from a box old enough not to report one.

const id = await session.newConversation();
// the avatar is still on screen, still connected, and has forgotten the chat

Nothing is erased. The box keeps the conversation you just closed, so its turns stay browsable, and opens a new one beside it. That is why this is called newConversation rather than clear.

The persona and the system prompt survive. Only the turn list rotates, so the avatar keeps its character and loses its memory of what was said.

It also clears the local transcript, so client.getMessageHistory() agrees with the box rather than continuing to show a conversation the avatar has forgotten. Subscribers to MESSAGE_HISTORY_UPDATED receive the empty history.

The session stays live

This is not a teardown. The WebRTC connection is untouched, so the avatar does not disappear and come back. That matters most where it happens often: a kiosk, a shared demo screen, or a support desk moving to the next person.

Scoped to your credential. A box is shared, and the conversation id is never taken from the caller, so this cannot reach anybody else's history.

Talking to the avatar

startMicrophone(options?): Promise<MicrophoneSession>

Opens the microphone, streams what it hears to the box, and feeds the recognised words into the same conversation sendUserMessage uses. Speaking and typing are one conversation on the box, so both sides of a spoken turn land in the same transcript.

const mic = await session.startMicrophone();
 
client.on(ZeliEvent.TRANSCRIPT_PARTIAL, (t) => caption(t.text));
client.on(ZeliEvent.MESSAGE_STREAM_EVENT_RECEIVED, (c) => append(c.content));
client.on(ZeliEvent.MICROPHONE_CLOSED, (end) => {
  if (end.retired) offerToReconnect(end.reason);
});
 
await mic.setMuted(true);    // pauses, keeping the session and the permission
await mic.stop();            // releases the device and closes the socket

In a browser the audio comes from getUserMedia, so this call triggers a permission prompt. Anywhere else, pass audioCapture. The SDK converts whatever rate the device runs at to what the box reads, which is 16 kHz mono signed 16 bit little endian, and sends it as binary frames.

Resolves once the box says it is listening. A box with no microphone, a credential it will not accept, and a refusal it explains all throw here rather than arriving later as an ending. A refusal throws MicrophoneRefusedError, whose closed field carries the code, the box's sentence, and whether trying again could work.

OptionTypeDescription
audioCaptureAudioCaptureFactoryWhere the audio comes from. Defaults to the browser's own microphone.
mutedbooleanStart muted, so nothing is heard until you say so.
avatarId, voiceIdstringThe face and voice this socket speaks with. Default to the session's own.
maxReconnectAttemptsnumberHow many times a dropped socket is redialled. Default 5; 0 disables it.
reconnectDelayMsnumberThe first redial delay, doubling up to 5 seconds. Default 500.
readyTimeoutMsnumberHow long to wait for the box to say it is listening. Defaults to the client's connect timeout.

MicrophoneSession

MemberDescription
setMuted(muted)Mute or unmute. Applied at the device where the capture source supports it, so the operating system's indicator agrees with your UI, and stated to the box so audio already in flight does not still become a turn.
configure({ avatarId, voiceId })Change the face or voice mid session. Re-sent automatically after a redial.
stop()Say goodbye, close, release the device. Idempotent.
waitUntilClosed()Resolves with MicrophoneClosed when it ends, however it ends.
resume()Ask the box to end a pause the persona began. Resolves true when the request was sent. Safe at any time and never throws: false with no open socket. The box ends the pause, so isPaused stays true until its answer arrives.
isOpen, isMuted, isPaused, sampleRate, readyWhat it is doing right now. isPaused is the persona's hold, kept for you: it clears on the box's say so, on a reply, and when the microphone ends.

session.isPaused and session.resume() are the same two on the session, and answer false when it has no microphone. session.activeMicrophone is the one this session has open, or null. Ending the session releases it, so a session that closes while somebody is still talking does not leave the microphone light on.

One microphone at a time

The box has a single microphone slot. A second startMicrophone() on a live one throws rather than spending a round trip to be told 4003, and a second browser tab is refused by the box with that code.

A dropped microphone repairs itself

A network drop is redialled with backoff, and the credential is renewed before each redial, because a WebSocket url is assembled once and the credential in it is frozen for the life of the socket. Refusals the box explains are not redialled into: no number of retries reaches a fix that needs a person. You are told either way, through MICROPHONE_RECONNECTING and MICROPHONE_CLOSED.

Media

streamToVideoElement(target): Promise<AttachResult>

Show this session on a <video> element. Returns { muted, reason? }.

unmute(): Promise<void>

Restore sound after streamToVideoElement reported muted: true. Must be called from inside a user gesture handler. Throws SessionError if nothing has been attached yet.

Both are covered in detail on Media.

Correlation ids

Every outbound utterance carries a correlation id, whether it went out as a message, a talk, or a talk stream. The id is a lowercase UUID v4, dashes included, generated by newCorrelationId() unless you supply your own.

import { newCorrelationId } from "@zeligate/zeli-avatar";
 
const id = newCorrelationId();   // "3f2a1c8e-9b4d-4e11-a7c6-2d5f80b31e94"
const talk = session.createTalkMessageStream({ correlationId: id });
// ... later, stop exactly this one
await session.interruptPersona(id);

Supplying your own is the only way the avatar's speech lines up with the rest of a distributed trace: pass a request id or a trace id minted upstream and it threads straight through to AVATAR_SPEECH_STARTED, MESSAGE_STREAM_EVENT_RECEIVED and TALK_STREAM_INTERRUPTED.

Dashes included, deliberately

The dashes used to be stripped. That was self-consistent across our own two SDKs and would be rejected by a strict validator, and a correlation id is precisely the field that ends up compared across two systems, so self-consistent is not the bar. The fallback used on runtimes without crypto.randomUUID builds a real v4, version nibble and variant bits pinned as the spec requires.

Lifecycle

waitUntilClosed(): Promise<void>

Resolves when the session ends, however it ends.

stopStreaming(): Promise<void>

Ends the session and releases the box. Also wired to Symbol.asyncDispose, so await using works on a runtime that supports it.

await using session = await client.connect();
await session.talk("Hello.");
// stopStreaming() runs at scope exit
Ending is not always instant

When a session ends without a clean shutdown, the SDK emits CONNECTION_CLOSED and isStreaming() goes false, but detection can take up to about 30 seconds: nothing on the wire announces it, so WebRTC notices when ICE consent freshness lapses. Handle the gap rather than expecting an immediate callback.

The close code names what actually ended it. See ConnectionCloseCode.

Deprecated aliases

@zeligate/zeli-avatar is published, so a rename that broke installed code would be a worse outcome than a name that disagreed with the reference. Every name this package shipped before still works and is removed at 1.0.

DeprecatedUse instead
sessionId (getter)getActiveSessionId()
isActive (getter)isStreaming()
sendMessage(text)sendUserMessage(text)
createTalkStream(options)createTalkMessageStream(options)
interrupt()interruptPersona(correlationId?)
close()stopStreaming()
Not on this list: client.on and client.off

Those are not deprecated. See ZeliClient events: on returns an unsubscribe closure that addListener has no equivalent for, so the two differ in capability, not only in spelling.

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