API reference / LiveKit
LiveKit
Four routes put a Zeli Avatar into a LiveKit room you own and take it out again. Your agent keeps speaking through livekit-agents; the avatar joins as its own participant and turns that speech into a lip synced face. For the walkthrough, start with the LiveKit guide.
| Method | Path | Purpose | Auth |
|---|---|---|---|
| POST | /livekit/join | Join a room and publish the avatar into it | full |
| POST | /livekit/leave | Leave a room and free the box | full |
| GET | /livekit/status | Is the box free, busy or draining | full |
| GET | /livekit/readiness | Can this box join LiveKit rooms at all | full |
Every route takes a full API key (zsk_live_...) in X-Api-Key. Session tokens
(zsk_temp_...) are refused with 401 insufficient_scope: a join spends a GPU
and carries a room token, so it is made from your server, never a browser.
These routes reach a running avatar box, like every /api/* route.
Join a room
Joins your LiveKit room with the token you minted, publishes the avatar's video and audio, and starts listening for your agent's speech. Answers once the avatar is in the room, usually in about 20 seconds. One box serves one room at a time, so a join while the box is busy is refused rather than allowed to cut somebody else's call.
Your LiveKit server URL, for example wss://your-project.livekit.cloud.
A room token for the avatar's identity, signed with your LiveKit API secret:
kind agent, roomJoin for this room, and the
attribute lk.publish_on_behalf set to your agent's identity.
Used exactly as given. The box never holds your secret.
The room to join.
The identity your token joins as. Default zeli-avatar. The box
checks the token against it and refuses a mismatch, because a face your
agent is not talking to would sit in the room silent.
Your agent's participant identity: whose audio and tone messages to accept. Empty means the first agent participant in the room.
The avatar to show. Falls back to your saved avatar, then the box's active one.
How long you expect the call to run. A box that will shut down sooner answers
503 box_draining now instead of dropping the call later. Answered
400 livekit_bad_request when it is not a JSON number (a string or
a boolean), is not greater than zero, is more than the box's
max_minutes ceiling (at most 120 by default), or is more than
the max_minutes you sent (the session would end before the
call).
This session's maximum length. Lowered to the box's ceiling (120 minutes by default).
This session's idle timeout. Lowered to the box's ceiling (20 minutes by default).
Optional ids for this meeting's usage record, for example
{"meeting_id": "your-call-id"}. Kept on the record. The
meeting is always billed to the account that owns the API key.
{
"ok": true,
"identity": "zeli-avatar",
"room": "support-room-1",
"avatar": "your-avatar-id",
"silence": "your-avatar-id__silence",
"idle_stand_in": false,
"limits": {
"max_minutes": 60.0,
"idle_minutes": 5.0,
"expected_minutes": 45.0,
"clamped": []
},
"session_id": "0192f3a4b5c6d9f1e2a3b4c5d"
}identity is the identity the box actually joined as, read back from your
token. limits are the limits this session runs under, and clamped names any
you asked for that were lowered. session_id is the id of this meeting's usage
record: keep it with your call.
| Status | Code | Meaning |
|---|---|---|
| 400 | livekit_bad_request | A required field is missing, a limit is not a number greater than zero, or expected_minutes is over the box's ceiling or over your max_minutes |
| 409 | livekit_session_in_use | The box is already serving a session |
| 409 | livekit_join_aborted | A leave for this room arrived while the join was in flight |
| 502 | livekit_join_failed | The box could not connect to the room with that URL and token |
| 502 | livekit_identity_mismatch | The token joins as a different identity than identity |
| 503 | box_draining | The box will shut down before the call would end |
| 503 | livekit_unavailable | This box cannot join LiveKit rooms |
Leave a room
Leaves the room and frees the box. Idempotent: leaving twice answers
left: false the second time rather than an error.
The room to leave. Always send it. Named, the leave only ends a session in
that room, and cancels a join for it that is still in flight. Another room
answers 409 livekit_not_in_room and nothing is touched.
{ "ok": true, "left": true, "room": "support-room-1", "session_id": "0192f3a4b5c6d9f1e2a3b4c5d" }Only a key from the account that joined the room, or the box operator, may end
it. Anybody else gets 403 livekit_not_owner, and the room stays up. See
"Who may end a room" below for the other routes this covers.
Who may end a room
While a LiveKit room is live on the box, only a key from the account that joined it, or the box operator, may end it. That holds on every route that would end it:
| Route | What it would do | Anybody else gets |
|---|---|---|
POST /livekit/leave | Leave the room | 403 livekit_not_owner |
POST /disconnect | End the live session | 403 livekit_not_owner |
POST /connect | Take the box over for a browser session, ending the room | 403 livekit_not_owner |
POST /api/avatar/delete | Stop the stream before deleting an avatar | 403 livekit_not_owner |
Who may drive a room. The same rule covers the routes that would act in the room without ending it:
| Route | What it would do | Anybody else gets |
|---|---|---|
POST /api/chat | Speak a reply into the room | 403 livekit_not_owner, nothing is said |
GET /api/voice/ws | Turn a microphone into turns in the room, cut its speech, or end it through a persona end call | Close code 4003 with error_code: "livekit_not_owner" on the error frame |
A microphone opened on a browser session and still open when a room another account joined goes live cannot drive that room either: its next turn closes it the same way.
The room stays up on every refusal. If the box cannot look your key up at that
moment it answers 503 livekit_owner_unverified instead and ends nothing
(on the microphone socket: close code 4006 with that error_code): retry in a
moment. A browser session with no LiveKit room is not affected.
Box status
Whether this box can take a call now, and for how long. Always 200: the state
is the answer.
This route answers for the one box it reaches. Each box also writes its state,
room, build and warm avatars into the box registry every 15 seconds, and portal
admins read every box from that registry with
GET /auth/admin/fleet, which is served by the always on service
and asks no box anything. It answers
{generated_at, boxes: [{box_id, public_url, region, state, room, build, heartbeat_at, heartbeat_age_seconds, fresh, capacity, avatars_warm, drained, leased}]},
newest heartbeat first, where fresh means a heartbeat within the
last 45 seconds and build is
{known, short_commit}. It is admin only and takes a
portal session; it is what the admin dashboard's Fleet panel and the GPU
page's build line read. The box keeps this route, /livekit/readiness
and /api/stats for the questions only a running box can answer.
Also report whether this avatar is already loaded (a warm face joins faster).
{
"ok": true,
"state": "free",
"minutes_remaining": null,
"shutdown_minutes_remaining": null,
"drain_threshold_minutes": 55.0,
"idle_shutdown": false,
"drain": false,
"limits": { "max_minutes_ceiling": 120.0, "idle_minutes_ceiling": 20.0 },
"room": null,
"session_id": null,
"avatar": "your-avatar-id",
"avatar_warmed": true,
"box_id": "i-0abc123def4567890",
"build": "14b454e"
}state is free, busy or draining. room and session_id are shown only
to the account that joined the room.
LiveKit readiness
Whether this box can join LiveKit rooms at all, and on which releases. Always
200.
{
"ok": true,
"installed": true,
"problem": null,
"versions": {
"livekit": "1.1.8",
"livekit-agents": "1.5.13",
"livekit-api": "1.1.0",
"livekit-protocol": "1.1.15"
},
"identity": "zeli-avatar",
"topic": "lk.audio_stream"
}installed: false comes with a problem saying what is missing. identity is
the default identity a join expects, and topic the data stream topic the box
listens on for your agent's audio.