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/conversationsThis caller's conversations, newest firstany
GET/conversations/{id}One whole transcriptany
DELETE/conversations/{id}Erase a conversation and its transcriptany

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.

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.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
400auth_disabledAuth is not configured on this box, so no token is needed
401insufficient_scopeA session token was used to mint another token
503mint_failedThe key store could not issue a token

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.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 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.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 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.

Conversation history

Three routes, and the only paginated surface on the service. History is scoped to the calling credential; the request names no owner anywhere, which is what makes another caller's history unreachable rather than merely filtered.

List conversations

GET/conversations
limitintegerOptionaldefault: 20

Page size, clamped to 50. An unparseable value is not an error; the default applies.

cursorstringOptional

The next_cursor from the previous page. Opaque, and validated against your own partition: a cursor that is not yours is a 400.

{
  "items": [
    {
      "id": "c_01H...",
      "title": "Pricing questions",
      "preview": "Can you walk me through the tiers?",
      "turns": 8,
      "created_at": 1769990000,
      "updated_at": 1769991234
    }
  ],
  "next_cursor": null
}

created_at and updated_at are unix integers, not ISO timestamps. turns here is a count.

Read one conversation

GET/conversations/{id}

Returns the same row fields as the list, plus the transcript.

{
  "id": "c_01H...",
  "title": "Pricing questions",
  "preview": "Can you walk me through the tiers?",
  "turns": [
    { "role": "user", "text": "Can you walk me through the tiers?", "ts": 1769990000, "tone": null },
    { "role": "assistant", "text": "Of course.", "ts": 1769990004, "tone": null }
  ],
  "created_at": 1769990000,
  "updated_at": 1769991234
}
turns has two types on the same resource

In the list response turns is an integer count. In this response it is an array of turn objects. That is a real inconsistency in our surface rather than a documentation slip, and a generated client cannot type one field two ways, so treat the two responses as separate shapes.

role comes straight from the model's messages array, so it is user or assistant. tone is always null: the delivery tone is chosen per streamed reply and is never written into history, and the field is present so the shape stays stable if that changes.

When the row exists but the stored transcript cannot be read, the answer is a 200 with an explicit flag rather than an error, and it names neither the storage location nor the underlying failure:

{ "id": "c_01H...", "turns": [], "transcript_unavailable": true }

Erase a conversation

DELETE/conversations/{id}

Deletes the index row first, then the stored transcript on a best effort basis. Response 200: { "ok": true, "id": "c_01H..." }. A 404 means no such conversation of yours.

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" notice for as long as the recording runs, and 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.
  • 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