Zeli AvatarDeveloper docs

API reference / Sessions

Sessions

A session on this service is one live WebRTC connection to one GPU box, and a box 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 box 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.

It works with no box running. This path is answered by the always on service, not by the GPU box, so a backend can mint a token before a box has started, while one is being replaced, and when none is up at all. The key is checked exactly as the box checks it: X-Api-Key or Authorization: Bearer, a full scope portal key, and a stream only token is refused. The token lands in the same key table every box reads, so it works on whichever box the browser then reaches. The one difference: a box's own static ZELI_API_KEYS are that box's environment and are not known to the always on service, so they mint only against the box's own host.

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://avatar.zeligate.ai/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
400auth_disabledOnly from a box's own host, when that box runs with auth off

Readiness

GET/api/chat/status

The closest thing we have to a concurrency pre check. It answers "can this caller have a turn right now" with booleans and prose rather than with counts, because a box has no queue and no limit to report: it serves one session, and /connect closes any prior one.

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 box'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 box'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://avatar.zeligate.ai/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 box 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://avatar.zeligate.ai/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 box'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 box 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

Twelve 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
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 box's policy, and a ceiling you asked for by putting max_session_length_seconds on the upgrade URL. Omit it for the box'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 box 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 a box is running, and whichever box the session ran on. Sessions in the portal is built on these routes. They take a portal session (the signed in cookie or a Cognito bearer token), not an API key, and the request names no owner anywhere: the partition is 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 box, and this host never forwards /conversations to a box, 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 own partition: 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:

  • A widget token mint. There is no widget, and no origin gated, unauthenticated credential anywhere. Every route except /, /health, /static/* and OPTIONS requires a credential.
  • 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 box 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 box, 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