Zeli AvatarDeveloper docs

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.

Two avatar lists, on two planes, with different shapes

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.

MethodPathPurposePlane
GET/auth/avatarsYour library, from durable storagePortal
GET/auth/avatars/publicAvatars other developers publishedPortal
GET/auth/avatars/tonesThe emotion vocabulary an avatar can be built withPortal
GET/auth/avatars/{id}/assetsOne avatar's images and clips as expiring URLsPortal
PUT/auth/avatars/{id}/visibilityPublish one of yours, or take it backPortal
DELETE/auth/avatars/{id}Erase one of yours. Works with no box runningPortal
GET/api/avatarsWhat this box has loaded, grouped by identityBox, any
GET/api/videosThe same set, per variant, with sizes and statusBox, any
GET/api/avatar/previewOne avatar's still frame, as image bytesBox, any
POST/api/avatarUpload an image or a video and prepare itBox, full
POST/api/avatar/deleteDelete a prepared avatar from the boxBox, full
POST/v1/photo_avatar.createBuild an avatar from one photographBox, full

Your library

GET/auth/avatars

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"
    }
  ]
}
FieldValuesNotes
source_kindphoto, image, video, unknownWhat it was made from
statepreparing, ready, failedThe last state a box observed
visibilityprivate, publicAlways present, never inferred
prepared_in_region, weights_volumestringWhere that observation was made
state is a past observation, not a present claim

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

GET/auth/avatars/public

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

GET/auth/avatars/tones

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

GET/auth/avatars/{id}/assets

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

PUT/auth/avatars/{id}/visibility
visibilitystringRequired

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.

StatuscodeWhen
404avatar_not_foundNot yours, or no such avatar. The same answer for both
409publication_consent_requiredPublishing without a recorded agreement
503publication_consent_unavailableThe ledger is not configured here
409, and deliberately not 403

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

DELETE/auth/avatars/{id}

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:

ArtefactWhat it is
registry_recordThe row your library lists from
public_listingThe row in the shared partition, if it was ever published
archived_mediaThe portrait and the generated clips
prepared_avatarThe 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.

Three avatar ids cannot be erased here

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

GET/api/avatars

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": {}
}
FieldMeaning
avatarsA flat list of every loaded variant id
groups[].previewThe 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[].stocktrue 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
preparingVariants still being prepared
buildsThe tone build journal, keyed by avatar
Why the build journal rides back on a list

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

GET/api/videos

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

GET/api/avatar/preview

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

POST/api/avatar

Multipart. Requires a full key.

filefileRequired

A PNG or JPG image, or an MP4, MOV or WEBM video. The part must be named file. Anything else is a 400.

tonesstringOptional

Comma separated tones to build.

consent_tokenstringOptional

Evidence that a human attested to this likeness. Required for a photo source; see the consent ledger below.

framingstringOptional

Crop and framing for the generated clips.

gesture_amplitudestringOptional

How much the avatar moves.

take__<tone>fileOptional

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.

ai_generatedstringOptional

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

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.

The box derives the id from the filename

Two uploads called portrait.png are the same avatar and the second overwrites the first. Name the file after the avatar you want.

Three fields are read only when clips are actually planned

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.

A video keeps the motion it was filmed with

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

POST/v1/photo_avatar.create

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.

filefileRequired

The photograph. An image, or a video declared with ai_generated=true. A video with no declaration is a 400 naming the declaration.

avatar_idstringRequired

The id to create. Unlike the upload route, this is explicit rather than derived from a filename.

tonesstringOptional

Comma separated tones to generate.

consent_tokenstringOptional

The attestation for this photograph.

framingstringOptional

Crop and framing.

gesture_amplitudestringOptional

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

POST/api/avatar/delete

A POST with the id in the body, not a DELETE with it in the path. Requires a full key.

avatarstringRequired

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.

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

POST/auth/consent
sha256stringRequired

The digest of the exact bytes the human looked at and agreed to.

source_kindstringOptionaldefault: image

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

GET/auth/avatars/publication-terms
{
  "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

POST/auth/avatars/{id}/publication-consent

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.

terms_versionstringOptional

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.

MethodPathWhat it does
POST/auth/portraitsGenerate one portrait from a persona description. 201
GET/auth/portraits/{id}Fetch it back to look at
POST/auth/portraits/{id}/approveA human says yes. Bound to the byte digest
POST/auth/portraits/{id}/regenerateAnother 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.
Zeli Avatar · real-time avatars over WebRTC · self-hostable · AU data residency · source