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.
| Member | Type | Description |
|---|---|---|
requestedAvatar | string | null | The 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. |
substitutedAvatar | string | null | The face actually rendering, when it is not the one requested. null in the normal case. |
tones | string[] | Tone names this avatar accepts in talk({ tone }). Per avatar, so it can never be a constant in an SDK. |
hasControlChannel | boolean | true when the full control gateway is available, which is what enables talk, talk streams and streamed events. |
tracks | MediaStreamTrackLike[] | Inbound tracks, in arrival order. |
mediaStream | unknown | The inbound tracks as a MediaStream, or null under Node. |
info | SessionInfo | { sessionId, avatar, tones } as the gateway reported it. |
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.
Tone variant to speak this utterance in.
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.
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 chatNothing 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.
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 socketIn 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.
| Option | Type | Description |
|---|---|---|
audioCapture | AudioCaptureFactory | Where the audio comes from. Defaults to the browser's own microphone. |
muted | boolean | Start muted, so nothing is heard until you say so. |
avatarId, voiceId | string | The face and voice this socket speaks with. Default to the session's own. |
maxReconnectAttempts | number | How many times a dropped socket is redialled. Default 5; 0 disables it. |
reconnectDelayMs | number | The first redial delay, doubling up to 5 seconds. Default 500. |
readyTimeoutMs | number | How long to wait for the box to say it is listening. Defaults to the client's connect timeout. |
MicrophoneSession
| Member | Description |
|---|---|
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, ready | What 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.
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 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.
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 exitWhen 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.
| Deprecated | Use instead |
|---|---|
sessionId (getter) | getActiveSessionId() |
isActive (getter) | isStreaming() |
sendMessage(text) | sendUserMessage(text) |
createTalkStream(options) | createTalkMessageStream(options) |
interrupt() | interruptPersona(correlationId?) |
close() | stopStreaming() |
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.