Zeli AvatarDeveloper docs

API reference / Sessions

Sessions

A session on this service is one live WebRTC connection to one GPU avatar server, and an avatar server serves exactly one at a time. Opening a new one closes the previous one. The routes here mint the credential a browser streams with, report whether a turn is possible, drive and stop the live session, and read back the conversation history it produced.

A session has no id, and that shapes this whole group

Nothing in our data model assigns an identifier to a live session, because a avatar server runs one. So there is no /sessions/{id} anywhere: the stop route stops the one live session unconditionally, and history is addressed by conversation id, which is a different thing with a different lifetime. A conversation is the model's turn history for one caller; a session is one connection.

MethodPathPurposeAuth
POST/v1/streaming.create_tokenMint a short lived, stream only tokenfull
GET/api/chat/statusCan a turn happen right nowany
POST/api/chatOne conversational turnany
POST/api/talkSpeak exact text, no modelany
POST/api/interruptBarge in on the current utteranceany
POST/api/chat/resetStart a fresh conversationany
POST/disconnectTear down the live sessionany
WSS/api/session/wsThe SDK's control and event channelany
GET/auth/sessionsYour sessions, newest first, one pagePortal session
GET/auth/sessions/{id}One session: summary, transcript, recordingPortal session
PATCH/auth/sessions/{id}Rename, annotate, tag or star onePortal session
DELETE/auth/sessions/{id}Erase one, with everything recorded in itPortal session

Mint a session token

POST/v1/streaming.create_token

Call this from your server with a full API key and hand the result to the browser. The token it returns is stream only: it can drive an avatar but can never mutate server state.

Token creation is available before a stream starts. Present a full portal-created API key through X-Api-Key or Authorization: Bearer; a streaming token cannot mint another token.

ttl_secondsintegerOptionaldefault: 300

Requested lifetime in seconds. Clamped server side to between 30 and 600. ttl is accepted as an alias.

curl -X POST "https://zeli-avatar.zelibot.xyz/v1/streaming.create_token" \
  -H "X-Api-Key: zsk_live_..." \
  -H "Content-Type: application/json" \
  -d '{"ttl_seconds": 300}'

Response 200. This is one of the three routes that wraps its payload in data:

{
  "data": {
    "token": "zsk_temp_...",
    "expires_at": 1770000000,
    "scope": "tts"
  }
}

expires_at is unix seconds. scope is always tts. The token is an opaque string, not a JWT, so do not try to decode it; ask the server.

Errors are returned as unwrapped code and message, which is unusual on this surface and is called out on the overview:

StatuscodeWhen
401missing_api_keyNo credential was presented
401invalid_api_keyThe key is unknown, revoked or expired
401insufficient_scopeA session token was used to mint another token
503mint_failedThe key store could not issue a token

Readiness

GET/api/chat/status

Returns current readiness for the caller's conversation and voice configuration. This status is not a capacity reservation.

Takes no parameters. The answer is scoped to the calling credential, so somebody speaking on their own voice provider account is told about their engine.

{
  "llm": true,
  "tts": true,
  "ready": true,
  "voice_origin": "byo",
  "voice_provider": "cartesia",
  "voice": true,
  "transcribe": true,
  "llm_state": "ready",
  "tts_state": "ready",
  "llm_detail": "",
  "tts_detail": "",
  "starting": false,
  "voice_notice": "",
  "reason": ""
}
FieldTypeMeaning
readybooleanllm && tts. The one field to branch on
llm, ttsbooleanEach subsystem, separately
voice, transcribebooleanWhether microphone input is usable
voice_originstringWhose account this session speaks on
voice_providerstringThe provider id it resolved to
llm_state, tts_statestringready, starting or a failure state
startingbooleanEither subsystem is still warming up. Poll rather than fail
voice_noticestringEmpty unless this caller's own key could not be used
reasonstringA sentence naming what is down. Empty when ready
voice_notice is the field a person should see

It is non empty exactly when a reply will be billed to the avatar server's voice engine rather than to the account the caller chose. Swallowing it is the deception the bring your own key path exists to remove, so surface it.

One conversational turn

POST/api/chat

Runs the whole loop: your message goes to the model, the model returns words and a delivery tone, and the reply is spoken. The response shape depends on whether a live session is connected, and the response transport depends on whether you opted in to streaming, which is off unless you ask for it.

messagestringRequired

What the person said. Blank is a 400.

voicestringOptional

Voice id for this turn. Falls back to the engine's own default; when there is no default either, the call is a 400 naming the missing field rather than borrowing somebody else's voice.

avatarstringOptional

Avatar for this turn. Falls back to this caller's saved avatar, then to the avatar server's active one.

streambooleanOptionaldefault: false

Ask for the turn as it happens rather than as one body. See Stream the turn as it happens. Must be the JSON boolean true.

With a live session connected, the reply is streamed straight into it and no audio comes back, because the avatar is already speaking:

{
  "reply": "Good question. The short answer is yes.",
  "tone": "confident",
  "avatar": "01-presenter-male__confident",
  "tools": [],
  "streamed": true,
  "identity": "01-presenter-male",
  "tones": ["neutral", "confident", "warm"],
  "voice_notice": ""
}

With no live session, the reply is synthesized to a file for you to lip sync with POST /offer. Note audio appears and streamed, avatar and tools do not:

{
  "reply": "Good question. The short answer is yes.",
  "tone": "confident",
  "audio": "chat_00042.wav",
  "identity": "01-presenter-male",
  "tones": ["neutral", "confident", "warm"],
  "voice_notice": ""
}

tools names, in wording meant for a person, which tools ran during the turn, so a page can explain why the avatar paused. It is empty on a turn that used none.

A 502 means the model or the voice engine failed. The body carries {"error": "..."} and, on an empty generation, interrupted. A caller that asked for the stream is told the same thing in an error frame instead, for the reason given below.

Stream the turn as it happens

Both shapes above are what this route answers by default, and that is unchanged: a caller that asks for nothing gets exactly the single JSON body it has always got. A caller that opts in is given the turn as it happens instead, as NDJSON: one JSON object per line, written while the avatar is still speaking, so the words land with the speech rather than after it.

There are two ways to ask, and either one on its own is enough:

Ask withWhereExact value
A request headerAcceptapplication/x-ndjson
A body fieldstreamtrue

The header is matched case insensitively and the media type may sit among other values. The body field must be the JSON boolean true, which exists for a client that cannot set headers at all. Anything else is not an opt in, and the turn comes back as one body.

curl -N -X POST "https://zeli-avatar.zelibot.xyz/api/chat" \
  -H "X-Api-Key: zsk_temp_..." \
  -H "Content-Type: application/json" \
  -H "Accept: application/x-ndjson" \
  -d '{"message": "Walk me through the tiers.", "voice": "<voice id>"}'

Response 200, Content-Type: application/x-ndjson. It also carries Cache-Control: no-store and X-Accel-Buffering: no, so nothing between you and the avatar server holds the body back to buffer it. Read it a line at a time; a frame can be split across two network reads, so cut lines from a buffer rather than from whatever arrived.

{"type":"reply_chunk","text":"Good question.","tone":"confident"}
{"type":"tool","name":"current_time","label":"Checking the time"}
{"type":"reply_chunk","text":"The short answer is yes.","tone":"confident"}
{"type":"reply","reply":"Good question. The short answer is yes.","tone":"confident","avatar":"01-presenter-male__confident","tools":[],"streamed":true,"identity":"01-presenter-male","tones":["neutral","confident","warm"],"voice_notice":""}
typeWhen it arrivesFields
reply_chunkOnce per clause, as that clause is handed to the voicetext, tone
toolWhile a tool runs, not once it is overname, label
replyLast, on a turn that produced wordsThe whole body documented above, plus type
errorLast, on a turn that produced nothingerror, and interrupted on the live session path
The mid turn frames are the ones the microphone already sends

reply_chunk and tool are the same objects, field for field, that /api/voice/ws pushes during a spoken turn, so a client that renders one door renders the other with no second event language to learn. The two closing frames are where the two differ, and only there: this route's reply carries the whole JSON body rather than the socket's shorter one, and a turn that produced nothing ends here as {"type": "error"} where the socket sends reply_error. The socket's own state, metric and transcription frames have no counterpart here, because there is no microphone in a typed turn.

tone travels on every clause rather than once at the end, because a reply can change tone partway through and the avatar's face already follows it.

The closing reply frame is authoritative over every clause streamed before it: it carries the transcript as the moderation screen left it. Every clause you receive has already passed that screen and has already been synthesized into speech, so a refused sentence never reaches the stream at all.

Two things about errors, because the status line is spent early:

  • A refusal found before the first frame is written is still an ordinary HTTP status, exactly as it is for a plain caller: a blank message is a 400, a turn with no voice to speak in is a 400, and a model or voice engine that is not configured is a 503.
  • A failure found after the first frame cannot be a status any more, because 200 went out with the headers. It arrives as the error frame, which is what an empty or interrupted generation looks like to a streaming caller.

With no live session the request still streams, and the whole turn is the one closing reply frame, carrying the same audio name the plain body carries. So typed chat has one way to be read whether or not an avatar is on stage.

The SDKs do not ask for this

Both the Python and the JavaScript SDK read the single JSON body, so nothing about their behaviour changes. This is for a client reading the route directly.

Speak exact text

POST/api/talk

Bypasses the model entirely: the text is spoken as written and nothing is committed to conversation history.

textstringRequired

What to say. content is accepted as an alias. Blank is a 400.

voicestringOptional

Voice id for this utterance.

tonestringOptionaldefault: neutral

Delivery tone, which also picks the avatar variant.

curl -X POST "https://zeli-avatar.zelibot.xyz/api/talk" \
  -H "X-Api-Key: zsk_temp_..." \
  -H "Content-Type: application/json" \
  -d '{"text": "Welcome back.", "tone": "warm"}'

Response 200: { "ok": true, "spoken": "Welcome back." }. A 409 means there is no live session, so call /connect first. A 503 means text to speech is not configured.

A voice fallback is not reported on this route

When your own provider key cannot be used, this route quietly speaks with the avatar server's engine and the response says nothing about it. The same event on the control WebSocket arrives as a warning. If knowing matters, drive the avatar over the socket, or poll /api/chat/status and read voice_notice.

Barge in

POST/api/interrupt

Stops the current utterance without clearing history. The body is never read, so send an empty one. Response 200: { "ok": true }, whether or not anything was speaking.

The stop names the generation it saw, so a turn that finishes first is not silenced retroactively. On a shared avatar server that matters: the sentence you would otherwise cut may belong to somebody else.

Start a fresh conversation

POST/api/chat/reset

Closes this caller's conversation and opens a new one, then interrupts this caller's own utterance if one is in flight. Only this caller's, never another tenant's on the same box.

This is the third route that uses a data wrapper:

{ "ok": true, "data": { "conversation_id": "c_01H..." } }

conversation_id is null when nothing rotated, which happens when the caller had no conversation to begin with. That is the honest answer rather than an invented id.

Stop the live session

POST/disconnect

Tears down the one live session on the box. Takes no body and no id: there is no session identifier to send, and the teardown is synchronous, so the session is gone when the response is written.

Response 200: { "ok": true }.

The control WebSocket

WSS/api/session/ws

The SDK's single control and event channel, and the primary way to drive a session. A browser cannot set headers on a WebSocket upgrade, which is the whole reason the ?session_token= query form of the credential exists.

This is the one surface on the service that uses camelCase. Both SDKs speak it for you and map it onto their own event names (see Events and models); everything below is the wire itself, for a client reading the socket directly.

The credential on the upgrade is frozen for the life of the socket, and so is the identity behind it. A session that outlives its token is told so and keeps the account, history and persona it was authorised with, rather than being quietly demoted to an anonymous caller mid conversation.

What you send

typeFieldsWhat it does
messagecontent, plus optional avatar, voice, correlationIdOne conversational turn: the model answers and the avatar speaks it
talkcontent, plus optional voice, tone, correlationIdSpeaks the text as written. No model, and nothing committed to history
talkstreamcontent, startOfSpeech, endOfSpeech, plus optional voice, tone, correlationIdFeeds in a reply generated somewhere else. Text is buffered per correlationId and spoken when endOfSpeech arrives
interruptnoneBarge in on the current utterance. History is untouched
authtokenHands the socket a fresh credential. A WebSocket cannot answer with a 401, so this is the only way to renew one without reopening
heartbeatnoneKeep alive. Deliberately not counted as activity, so a tab nobody is sitting at still reaches the idle limit

What comes back

Seventeen event types, and the count is the point: the reply itself arrives on message_stream, one clause at a time as each clause is handed to the voice. message closes the turn rather than being the first place the words appear, so a client that switches on message alone shows nothing at all until the avatar has stopped speaking.

typeWhen it arrivesFields
session_readyOnce, as soon as the socket opens, before anything else and whether or not a media session exists yetsessionId, avatar, tones
user_messageWhen a message turn is accepted, echoing your own words back before the model runsid, role, content, correlationId
message_streamOnce per clause of the reply, as that clause is handed to the voiceid, role, content, contentIndex, endOfSpeech, tone, correlationId
messageLast on a turn that produced words, carrying the whole reply. A talk turn sends it too, carrying the text you suppliedid, role, content, tone, avatar, correlationId
avatar_speech_startedWhen the first audio of an utterance is readycorrelationId
avatar_speech_endedWhen that utterance has finishedcorrelationId
interruptedAcknowledges an interrupt, whether or not anything was speakingnone
warningSomething worth knowing that did not stop the turn: no media session yet, or a voice account that is not the one you asked formessage
auth_requiredOnce per lapse, when the socket's credential ages out. Answer it with an auth messagemessage
auth_stateAfter every auth message, saying whether the credential now on the socket is livelive
errorA turn refused, or the session ended. fatal says whicherror, fatal
session_expiredWhen the idle window or the maximum session length runs out. A close frame follows immediately, carrying the same codereason (idle or max_length), message, seconds, code
avatar.stateEvery change of what the avatar is doing, never repeated for the same state. Sent once on open (listening) and again when the session stops (idle)state (idle, listening, thinking or speaking), correlationId
avatar.speak_startedWhen the first audible audio of a reply starts. A tool wait ends a speech and a later one starts a new onecorrelationId
avatar.speak_endedWhen that speech ends. interrupted is true when the visitor cut it offinterrupted, correlationId
user.transcriptionThe visitor's words for a message turn, before the model runs. Only this socket sends it: a LiveKit room carries audio and the box holds no transcript theretext, final, correlationId
session.stoppedOnce, after the state goes idle, saying why the session endedreason (user_closed, idle_timeout, max_duration, server_error or not_released)

The five dotted types are the typed state events. They are additive: avatar_speech_started, avatar_speech_ended and interrupted above keep going out unchanged. A normal turn arrives as avatar.state listening, user.transcription, avatar.state thinking, avatar.state speaking, avatar.speak_started, avatar.speak_ended, avatar.state listening. The same events (without user.transcription) are published to a LiveKit room as one JSON text stream per event on the text topic zeli.avatar_events, sent by the avatar's own participant. The Python SDK's zeli.livekit.AvatarSession subscribes and fires the typed event handlers for you; the JavaScript SDK has no LiveKit client, so there you read that topic with livekit-client.

The credential renewal frames are the ones clients forget

auth_required is said once per lapse, so a client that ignores it gets no second reminder. A credential that merely aged out is reported and the session continues; a credential that was revoked ends the session as a fatal error, because that is somebody deliberately cutting access rather than a clock running out. Both SDKs answer auth_required for you when the client was built with a token provider, sending the renewed credential back down the same socket so the media connection is never touched, and both surface auth_state and session_expired as events. A client that has no provider to ask is told so through a warning rather than left to go quiet.

Two clocks can end a session, and session_expired names which: an idle window that is the avatar server's policy, and a ceiling you asked for by putting max_session_length_seconds on the upgrade URL. Omit it for the avatar server's default, or pass 0 to ask for no ceiling at all.

Treat an unrecognised type as ignorable rather than as an error. The list above is what the avatar server sends today, and a client that throws on anything else is a client that breaks the next time one is added.

Session history

Session history is served by the always on service, so it is there whether or not an avatar server is running, and whichever avatar server the session ran on. Sessions in the portal is built on these routes. They take a portal session (the signed in cookie or a signed-in identity token), not an API key. Results belong to the signed-in person, which is what makes another person's history unreachable rather than merely filtered.

Replaces /conversations

Earlier versions of this page documented GET /conversations, GET /conversations/{id} and DELETE /conversations/{id}. Those are routes on the avatar server, and this host never forwards /conversations to an avatar server, so they were never reachable here. Use the routes below.

List sessions

GET/auth/sessions
limitintegerOptionaldefault: 20

Page size, clamped to 50.

cursorstringOptional

The next value from the previous page. Opaque, and validated against your account: a cursor that is not yours is a 400 invalid_cursor.

{
  "sessions": [
    {
      "id": "0192f1c4...",
      "title": "Pricing questions",
      "preview": "Can you walk me through the tiers?",
      "avatar_id": "jordan",
      "started_at": 1769990000,
      "ended_at": 1769991234,
      "duration_seconds": 1234,
      "turns": 8,
      "has_recording": true,
      "starred": false,
      "tags": []
    }
  ],
  "next": "eyJwayI6..."
}

next is absent on the last page. Each row carries more fields than shown here (the kind of session, its end reason, persona facts); read the ones you need and ignore the rest. 503 sessions_unavailable means the history store is not wired in this environment, and is never answered with an empty list.

Read one session

GET/auth/sessions/{id}

Answers {session, transcript, recording, consent}. recording and consent are short lived links, present only when the session was recorded and the file is still there; otherwise they are null and the answer is still 200. Somebody else's id is the same 404 session_not_found as an id nobody has.

Edit one session

PATCH/auth/sessions/{id}

Any of title, notes, tags and starred. Any other field is a 400 session_field_not_editable that names it. Answers 200 {session}.

Erase a session

DELETE/auth/sessions/{id}

Erases the transcript, the summary, the recording, the recording consent and the row. Always 202, with done and pending lists naming what is gone and what is still owed.

This erases, it does not redact

Some services keep the session record and its usage rows for billing and erase only the customer content. We do not: the row and the transcript both go, and nothing is retained behind them.

Not served in this group

Stated plainly rather than left for you to discover from a 404:

Persona share links and embeds have a separate visitor session flow. They are not SDK streaming tokens.

  • Session recordings. Conversations in the Live Studio are recorded automatically, with a visible "This conversation is recorded, your voice included" notice for as long as the recording runs. A recording keeps both sides of the call in one video: the avatar's picture and voice, and the person's own voice as their microphone hears it (never their camera). They are listed, played and erased from Sessions in the portal, not from this group. A session started through this API or the SDKs is not recorded: neither asks the avatar server to record. A persona conversation (a share link or an embed) is recorded the same way, the visitor's voice included, and the visitor is told so before they press Start: "This conversation, including your voice, is recorded and transcribed." Their camera is never used. The notice itself is public: GET /auth/recordings/terms answers {text, version, digest} with no credential and no avatar server, so the wording a recording names can be read and hashed by anybody.
  • Session reports, insights or summaries. Nothing of the kind is generated or stored.
  • Per turn and aggregate latency analytics. Half the wire contract already exists: a correlationId threads a turn through the control WebSocket and avatar_speech_started and avatar_speech_ended both carry it. What is missing is server side capture of those timestamps and a route to read them back.
Zeli Avatar · real-time avatars over WebRTC · self-hostable · AU data residency · source