API reference / Avatars
Avatars
An avatar is a face this service can render, prepared once and then streamed many times. Creating one is a paid, minutes long build, so the create routes answer immediately and the outcome arrives through a separate read. Reading your library works whether or not a GPU is running, because the library and the machine are deliberately not the same thing.
GET /auth/avatars is your library, from the
always on portal service, and it is what a person should see.
GET /api/avatars is what is loaded on this box right
now, grouped for a picker, and it exists only while a box runs. They
do not share a credential and they do not share a shape. Use the first for a
library view and the second only when you are driving a live box.
| Method | Path | Purpose | Plane |
|---|---|---|---|
| GET | /auth/avatars | Your library, from durable storage | Portal |
| GET | /auth/avatars/public | Avatars other developers published | Portal |
| GET | /auth/avatars/tones | The emotion vocabulary an avatar can be built with | Portal |
| GET | /auth/avatars/{id}/assets | One avatar's images and clips as expiring URLs | Portal |
| PUT | /auth/avatars/{id}/visibility | Publish one of yours, or take it back | Portal |
| DELETE | /auth/avatars/{id} | Erase one of yours. Works with no box running | Portal |
| GET | /api/avatars | What this box has loaded, grouped by identity | Box, any |
| GET | /api/videos | The same set, per variant, with sizes and status | Box, any |
| GET | /api/avatar/preview | One avatar's still frame, as image bytes | Box, any |
| POST | /api/avatar | Upload an image or a video and prepare it | Box, full |
| POST | /api/avatar/delete | Delete a prepared avatar from the box | Box, full |
| POST | /v1/photo_avatar.create | Build an avatar from one photograph | Box, full |
Your library
Takes no parameters. The acting identity is the storage partition: no path segment, header or body field names an owner, which is what makes another developer's library unreachable rather than merely filtered.
{
"avatars": [
{
"avatar_id": "8f1d...",
"name": "Acme host",
"source_kind": "photo",
"state": "ready",
"created_at": 1769900000,
"updated_at": 1769903600,
"prepared_in_region": "ap-southeast-2",
"weights_volume": "vol-0abc...",
"detail": "",
"visibility": "private"
}
]
}| Field | Values | Notes |
|---|---|---|
source_kind | photo, image, video, unknown | What it was made from |
state | preparing, ready, failed | The last state a box observed |
visibility | private, public | Always present, never inferred |
prepared_in_region, weights_volume | string | Where that observation was made |
ready means a box finished preparing this avatar and said so. It
does not mean the avatar is streamable right now: the volume may since have
been rebuilt, or may be in another region, and most of the time there is no box
at all. Fuse this with a live box reading, or say "prepared, box offline",
which is the true sentence.
There are no URLs on the record. Images and clips come from the asset route below, because they are expiring signatures and do not belong in a cached list.
Published avatars
Avatars other developers deliberately shared. This is a separate partition, not the route above with a looser filter: a private avatar was never in the answer rather than being removed from it. Same item shape, plus one field:
{ "avatars": [{ "avatar_id": "8f1d...", "visibility": "public", "mine": false }] }mine marks your own rows in the shared set. No email addresses are on the
wire. A shared avatar is a shared face, not a shared address book.
Still session gated: this is a developer portal, not an open gallery, and "the people who sign in here" is exactly what the person publishing is told.
The emotion vocabulary
The tones an avatar can be built with. Derived rather than stored, and served from the always on plane so a create form is never empty just because no GPU happens to be running.
{ "tones": ["neutral", "confident", "warm", "curious"] }One avatar's media
Images and clips as short lived presigned URLs, with no box involved: this reads storage and signs locally, so it starts nothing and spends nothing.
This is the only portal route whose path carries an id the caller chose, so
ownership is an explicit gate rather than a property of the partition. A refusal
is a 404.
{
"avatar_id": "8f1d...",
"assets": [
{
"kind": "image",
"filename": "portrait.png",
"size_bytes": 482913,
"updated_at": 1769900000,
"url": "https://..."
}
],
"expires_in": 900
}kind is image or video. expires_in is stated rather than implied, so a
client can refresh before a URL dies and a stale signature does not render as
breakage.
Publish or withdraw
public or private.
Returns { "avatar": {} }, the updated record in the shape above.
Publishing requires a separate agreement and withdrawing never does. Publishing a face is a decision about other people seeing it, so it takes its own recorded attestation; taking it back is unconditional and works even when the consent ledger is unreachable, because nothing should stand between a person and un publishing their own face.
| Status | code | When |
|---|---|---|
404 | avatar_not_found | Not yours, or no such avatar. The same answer for both |
409 | publication_consent_required | Publishing without a recorded agreement |
503 | publication_consent_unavailable | The ledger is not configured here |
A 403 from behind this host is rewritten by the CDN into the sign
in page, so it would reach your client as HTML. This route answers
409 so the refusal stays readable as JSON. See the
overview.
Erase an avatar
Served from the always on plane, which is the entire point: a face has to be removable at any hour, not only during the couple of hours a GPU happens to be up. Nothing here starts, wakes or even asks a box.
Answers 202, never 200, because part of the erasure has not happened yet.
The body names what is gone and what is still owed:
{
"avatar_id": "8f1d...",
"status": "pending",
"erased": ["registry_record", "public_listing", "archived_media"],
"pending": ["prepared_avatar"]
}status is pending or complete. The artefact names describe what a person
loses, not which service holds it:
| Artefact | What it is |
|---|---|
registry_record | The row your library lists from |
public_listing | The row in the shared partition, if it was ever published |
archived_media | The portrait and the generated clips |
prepared_avatar | The prepared face on the GPU box's volume. The one artefact no always on service can reach, so it is recorded as an order the box drains at its next boot |
Ask again after a box has run and the same id answers complete. A 404 means
not yours, or no such avatar.
tones, public and publication-terms are
exact sub resource paths under /auth/avatars/. An avatar whose id
is one of those three literals answers 404 on this route and has
to be deleted from the box instead. Ids minted today are random, so this only
affects avatars whose id came from an uploaded filename.
What a box has loaded
Grouped by identity, so a picker shows one entry per person and the tone variants resolve per reply.
{
"avatars": ["01-presenter-male", "01-presenter-male__warm"],
"groups": [
{
"id": "01-presenter-male",
"variants": ["01-presenter-male", "01-presenter-male__warm"],
"tones": ["neutral", "warm"],
"preview": "01-presenter-male",
"stock": true
}
],
"preparing": [],
"active": "01-presenter-male",
"active_group": "01-presenter-male",
"builds": {}
}| Field | Meaning |
|---|---|
avatars | A flat list of every loaded variant id |
groups[].preview | The variant that actually has a still on disk, as an id and not a URL, because fetching it needs the key header and so cannot go in an <img src> |
groups[].stock | true when nobody uploaded this face. A box is shared and cannot ask who owns a face it found on the volume, so "nobody uploaded it" is the strongest claim it can make |
preparing | Variants still being prepared |
builds | The tone build journal, keyed by avatar |
An upload answers in milliseconds and a build takes minutes, so the upload response structurally cannot carry the outcome. A client is already polling this route while an avatar prepares, so the answer arrives on a request it was making anyway. See Avatar videos.
The per variant view
The same set, one row per variant, for a management panel rather than a picker:
{
"videos": [
{
"id": "01-presenter-male__warm",
"status": "ready",
"kind": "video",
"bytes": 8421904,
"identity": "01-presenter-male",
"emotion": "warm",
"active": true,
"deletable": true,
"registry_note": null
}
],
"active": "01-presenter-male"
}deletable is false for bundled avatars, which have no uploaded source.
bytes is null for the same reason. registry_note is null on every
ordinary avatar; it carries a plain sentence only when the avatar is ready on
this box but a developer library cap kept it out of the portal library, so the
one channel a background build has left can still say why.
A still frame
Takes ?avatar=<id> and returns image bytes, not JSON. Authenticated like
every other box route, which is why a browser fetches it into a blob rather than
pointing an <img src> at it: the alternative is a live credential in a query
string, and that lands in access logs, referrer headers and history.
A 404 has two meanings worth telling apart by their message: an unknown avatar,
or one that exists but has not been prepared yet. The second is worth retrying.
Create from an image or a video
Multipart. Requires a full key.
A PNG or JPG image, or an MP4, MOV or WEBM video. The part must be named
file. Anything else is a 400.
Comma separated tones to build.
Evidence that a human attested to this likeness. Required for a photo source; see the consent ledger below.
Crop and framing for the generated clips.
How much the avatar moves.
Footage for ONE tone, one part per take: take__happy for the
first, take__happy__1 for the next. The file must be a
.webm, .mp4, .mov or
.mkv: a take is looked up by extension and anything else is
stored and never found. tones should name exactly the tones
sent here, because a tone with no footage behind it is not built.
The uploader's declaration that a video source was made by
AI and shows no real person. Send true (the box reads a closed
list: true, 1, yes, y,
on), or omit the field entirely. Absent, empty and unreadable
all mean no.
curl -X POST "https://avatar.zelibot.xyz/api/avatar" \
-H "X-Api-Key: zsk_live_..." \
-F "file=@acme-host.png" \
-F "tones=neutral,confident" \
-F "consent_token=ct_..."const created = await admin.management.createAvatar({
file: portrait,
filename: "acme-host.png",
tones: ["neutral", "confident"],
consentToken: "ct_...",
});created = await client.create_avatar("acme-host.png", name="Acme host")Response 200, immediately:
{
"avatar": "acme-host",
"status": "preparing",
"tones": ["neutral", "confident"],
"note": "",
"active": "01-presenter-male"
}status is preparing or ready. The returned tones are the ones actually
coming, which may be fewer than you asked for, and note says why. Poll
GET /api/avatar/clips for the outcome.
Two uploads called portrait.png are the same avatar and the second
overwrites the first. Name the file after the avatar you want.
consent_token, framing and
gesture_amplitude are read only when tone builds are actually
planned. With photo avatar creation disabled on the box, or on a video upload
that carries neither takes nor ai_generated, all three are
accepted from the client and never used, and the response's note
mentions none of them. Check GET /api/avatar/clips rather than
assuming they applied.
Emotion clips are not generated from footage. A video upload gets its tones one
of two ways, and neither of them is the box inventing motion for a real
person's face: send a take__<tone> part per tone, or
declare the video is AI generated with
ai_generated=true, which is the uploader's statement and is not
verified. Without either, the response's tones comes back as the
base idle face alone and note says so.
Create from one photograph
The generated portrait pipeline. Off unless the box enables it; when it is off
every call is a 501 with code feature_disabled. Requires a full key.
Unlike POST /api/avatar, this one is synchronous on the box and holds the
connection until every clip is rendered, which can take minutes.
The photograph. An image, or a video declared with
ai_generated=true. A video with no declaration is a
400 naming the declaration.
The id to create. Unlike the upload route, this is explicit rather than derived from a filename.
Comma separated tones to generate.
The attestation for this photograph.
Crop and framing.
Motion amount.
This is the second of the three routes that wraps its payload in data:
{
"data": {
"avatar_id": "acme-host",
"clips": [
{
"tone": "neutral",
"template": "idle",
"path": "/var/avatars/acme-host/neutral.mp4",
"duration_s": 4.0,
"fps": 25,
"loop_frame": 100,
"avatar_id": "acme-host",
"queued": true
}
]
}
}avatar_id on each clip is the id that clip is served under: the bare id for
neutral, and <avatar_id>__<tone> otherwise. queued says whether preparation
was queued for it.
Delete from a box
A POST with the id in the body, not a DELETE with it in the path. Requires a
full key.
The avatar id.
{ "deleted": "acme-host", "active": "01-presenter-male", "videos": [] }Only uploaded avatars can be deleted, never bundled ones: a 400 says so.
The registry delete is itself the ownership proof, so there is no second check
after it.
To erase an avatar when no box is running, and to erase the stored media and the
consent record with it, use DELETE /auth/avatars/{id} above. That is the route
to prefer.
Consent, and the publication ledger
A legal weight audit trail with no vendor equivalent. The owner is never in the body and cannot be: it is the resolved portal identity.
Attest to an uploaded likeness
The digest of the exact bytes the human looked at and agreed to.
What was attested to.
Response 201. The record carries no owner, because echoing an email into a
response body is how one ends up in a log or a browser history entry:
{
"consent": {
"consent_id": "cs_01H...",
"sha256": "9f86d0...",
"attested_at": 1769900000,
"source_kind": "image"
}
}Bound to the digest, so an attestation cannot be reused for different bytes.
Read the publication wording
{
"terms": {
"version": "avatar-publication#v1",
"text": "...",
"sha256": "4c1a..."
}
}Served so the exact paragraph a stored attestation names can be fetched and
hashed by somebody who does not hold this repository. That is what turns "they
agreed to avatar-publication#v1" from a label into a checkable claim.
Agree to publish one face
A separate act from the upload attestation: that one is about use and names
no audience. A POST rather than a PUT, because each call records a new
agreement about a new moment rather than overwriting one row per user.
The wording version being agreed to.
Response 201:
{
"publication_consent": {
"attestation_id": "pa_01H...",
"avatar_id": "8f1d...",
"terms_version": "avatar-publication#v1",
"terms_sha256": "4c1a...",
"attested_at": 1769900000,
"withdrawn_at": null
}
}Synthetic portraits
Four routes for the generate, look, approve loop that runs before any paid clip generation. A full avatar is many seconds of video per emotion, so the face is seen and accepted first.
| Method | Path | What it does |
|---|---|---|
| POST | /auth/portraits | Generate one portrait from a persona description. 201 |
| GET | /auth/portraits/{id} | Fetch it back to look at |
| POST | /auth/portraits/{id}/approve | A human says yes. Bound to the byte digest |
| POST | /auth/portraits/{id}/regenerate | Another attempt, as a new id. 201 |
Three of the four are a POST, including the plain generate, because a GET is
reachable by a link, a prefetch or an image tag, and generating costs money.
Regenerating mints a new id rather than putting new bytes behind the old one, so
an approval can never come to mean a face nobody looked at.
Not served in this group
GET /avatars/{id}. There is no single record read. The library list is the read, and the only id addressed avatar route returns media rather than the avatar.- Rename. There is no update path on the record other than visibility. A display name is fixed at creation.
- Search, tags, render style filters and pagination on either avatar list. Both return the complete set.