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.
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.
| Method | Path | Purpose | Auth |
|---|---|---|---|
| POST | /v1/streaming.create_token | Mint a short lived, stream only token | full |
| GET | /api/chat/status | Can a turn happen right now | any |
| POST | /api/chat | One conversational turn | any |
| POST | /api/talk | Speak exact text, no model | any |
| POST | /api/interrupt | Barge in on the current utterance | any |
| POST | /api/chat/reset | Start a fresh conversation | any |
| POST | /disconnect | Tear down the live session | any |
| WSS | /api/session/ws | The SDK's control and event channel | any |
| GET | /conversations | This caller's conversations, newest first | any |
| GET | /conversations/{id} | One whole transcript | any |
| DELETE | /conversations/{id} | Erase a conversation and its transcript | any |
Mint a session 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.
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}'// your backend, never bundled for the browser
const { token, expiresAt, scope } = await admin.management.createSessionToken({
expiresInSeconds: 300,
});token = await client.create_session_token(expires_in_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:
| Status | code | When |
|---|---|---|
400 | auth_disabled | Auth is not configured on this box, so no token is needed |
401 | insufficient_scope | A session token was used to mint another token |
503 | mint_failed | The key store could not issue a token |
Readiness
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": ""
}| Field | Type | Meaning |
|---|---|---|
ready | boolean | llm && tts. The one field to branch on |
llm, tts | boolean | Each subsystem, separately |
voice, transcribe | boolean | Whether microphone input is usable |
voice_origin | string | Whose account this session speaks on |
voice_provider | string | The provider id it resolved to |
llm_state, tts_state | string | ready, starting or a failure state |
starting | boolean | Either subsystem is still warming up. Poll rather than fail |
voice_notice | string | Empty unless this caller's own key could not be used |
reason | string | A sentence naming what is down. Empty when ready |
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
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.
What the person said. Blank is a 400.
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.
Avatar for this turn. Falls back to this caller's saved avatar, then to the box's active one.
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 with | Where | Exact value |
|---|---|---|
| A request header | Accept | application/x-ndjson |
| A body field | stream | true |
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":""}type | When it arrives | Fields |
|---|---|---|
reply_chunk | Once per clause, as that clause is handed to the voice | text, tone |
tool | While a tool runs, not once it is over | name, label |
reply | Last, on a turn that produced words | The whole body documented above, plus type |
error | Last, on a turn that produced nothing | error, and interrupted on the live session path |
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
messageis a400, a turn with no voice to speak in is a400, and a model or voice engine that is not configured is a503. - A failure found after the first frame cannot be a status any more, because
200went out with the headers. It arrives as theerrorframe, 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.
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
Bypasses the model entirely: the text is spoken as written and nothing is committed to conversation history.
What to say. content is accepted as an alias. Blank is a
400.
Voice id for this utterance.
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"}'await session.talk("Welcome back.");await session.talk("Welcome back.")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.
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
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
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
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
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
type | Fields | What it does |
|---|---|---|
message | content, plus optional avatar, voice, correlationId | One conversational turn: the model answers and the avatar speaks it |
talk | content, plus optional voice, tone, correlationId | Speaks the text as written. No model, and nothing committed to history |
talkstream | content, startOfSpeech, endOfSpeech, plus optional voice, tone, correlationId | Feeds in a reply generated somewhere else. Text is buffered per correlationId and spoken when endOfSpeech arrives |
interrupt | none | Barge in on the current utterance. History is untouched |
auth | token | Hands the socket a fresh credential. A WebSocket cannot answer with a 401, so this is the only way to renew one without reopening |
heartbeat | none | Keep 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.
type | When it arrives | Fields |
|---|---|---|
session_ready | Once, as soon as the socket opens, before anything else and whether or not a media session exists yet | sessionId, avatar, tones |
user_message | When a message turn is accepted, echoing your own words back before the model runs | id, role, content, correlationId |
message_stream | Once per clause of the reply, as that clause is handed to the voice | id, role, content, contentIndex, endOfSpeech, tone, correlationId |
message | Last on a turn that produced words, carrying the whole reply. A talk turn sends it too, carrying the text you supplied | id, role, content, tone, avatar, correlationId |
avatar_speech_started | When the first audio of an utterance is ready | correlationId |
avatar_speech_ended | When that utterance has finished | correlationId |
interrupted | Acknowledges an interrupt, whether or not anything was speaking | none |
warning | Something worth knowing that did not stop the turn: no media session yet, or a voice account that is not the one you asked for | message |
auth_required | Once per lapse, when the socket's credential ages out. Answer it with an auth message | message |
auth_state | After every auth message, saying whether the credential now on the socket is live | live |
error | A turn refused, or the session ended. fatal says which | error, fatal |
session_expired | When the idle window or the maximum session length runs out. A close frame follows immediately, carrying the same code | reason (idle or max_length), message, seconds, code |
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
Page size, clamped to 50. An unparseable value is not an error;
the default applies.
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
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
}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
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.
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/*andOPTIONSrequires 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
correlationIdthreads a turn through the control WebSocket andavatar_speech_startedandavatar_speech_endedboth carry it. What is missing is server side capture of those timestamps and a route to read them back.