Editing a character
Change an avatar's clips and resting loop after creation: how to declare them, how each change settles, and the refusals worth telling apart.
Her clips and her resting loop are both editable after creation. The resting loop is re-directed in words; each library clip can either be rendered from a motion description or use the assetId of a pose-compatible video you uploaded. Her id, her voice and her live calls survive every change on this page.
Media changes are accepted before their background work finishes, and she keeps serving the previous take until its replacement is ready. Behavior-only edits do not render media. Keep the API key and declaration policy on your server; never forward arbitrary browser-authored motion or action descriptions as trusted character policy.
Her clips
Clip Library editing is enabled by default for active workspaces. Workspace owners and admins can edit eligible avatars in Studio; API calls require avatars:write. Enabling access does not generate clips or change an avatar's existing media. New motion descriptions queue generation only when you submit a declaration.
Send the library you want, in full: clips is a record keyed by clip id; each value contains only source. Playback rules live separately in idle, on and actions. The platform reconciles source content: an unchanged clip is kept, new or revised media is queued, and a record you leave out is retired. The stored source joins the runtime idle pool as primary. That id is reserved for source addressing, so do not duplicate it in the media record.
const { revision } = await rta.listClips(avatarId);
const { plan } = await rta.setClipLibrary(avatarId, {
expectedRevision: revision, // required compare-and-set
clips: {
sway: { source: { motionPrompt: "sways gently, settles back to rest" } },
nod: { source: { motionPrompt: "a small attentive nod, returns to rest", durationSeconds: 5 } },
},
idle: { clips: ["sway", "nod"], weights: { primary: 1, sway: 2, nod: 1 } },
on: {
userSpeechStarted: { clips: ["nod"] },
},
actions: {
agree: { description: "When agreeing with the user", clips: ["nod"] },
},
});
// plan → { kept: [...], queued: [...], retired: [...] }
await rta.waitForClips(avatarId);response = client.get(f"/avatars/{avatar_id}/clips")
response.raise_for_status()
library = response.json()
revision = library["revision"] # top-level; 0 while creation still owns the library
r = client.put(f"/avatars/{avatar_id}/clips", json={
"expectedRevision": revision,
"clips": {
"sway": {"source": {"motionPrompt": "sways gently, settles back to rest"}},
"nod": {"source": {"motionPrompt": "a small attentive nod, returns to rest", "durationSeconds": 5}},
},
"idle": {"clips": ["sway", "nod"], "weights": {"primary": 1, "sway": 2, "nod": 1}},
"on": {
"userSpeechStarted": {"clips": ["nod"]},
},
"actions": {
"agree": {"description": "When agreeing with the user", "clips": ["nod"]},
},
})
# httpx does not raise on 4xx. Refusals come back as {error, status, code} with no "plan":
# 403 clip_library_not_enabled, 409 revision_conflict (body "revision" = current), 422 clip_declaration_rejected
r.raise_for_status()
plan = r.json()["plan"] # the 202 body also carries the new "revision"Generated source.durationSeconds is an optional integer from 4–8 seconds, default 5. Uploaded sources contain only assetId; the GET envelope's defaultSourceAssetId names the source member of the idle pool. Selection membership, the idle weight, and action descriptions are metadata-only: changing them does not re-render unchanged sources. Motion prompts and action descriptions are screened before the declaration can change anything, including on metadata-only updates.
Every selection is a plain list of clip ids — nonempty, no duplicates within one list, every reference declared in clips — and candidates in one list draw uniformly for listening and actions. Idle uses one weighted idle pool:idle.weights gives each member, including primary, a finite weight from 0–100. Each omitted member defaults to 1; zero disables it. At least one member must remain positive. Unavailable members are removed and weights are renormalized. The legacy aggregate idle.weight is still supported, but cannot be combined with weights. Ten idle variations are valid: there are no idle, listening, or total-clip authoring caps beyond 32 per list. Request bytes remain bounded by transport, and worker admission is independent of declaration validity, not unlimited GPU capacity.
on.userSpeechStarted chooses a listening clip to play once, not a loop held throughout the user's speech — a triggered reaction repeats freely and never goes silent because it just played. actions is the requestable menu for the language model: each description explains when that action fits, its clips list selects the take, and declaring one never plays it by itself. The same clip can serve idle, listening and any action. Every idle member, including the source, avoids immediately repeating when another member is available. A sole available member loops completely. Speaker lip-sync continues on the current playback cursor during full-duplex speech; it does not select a separate talking state.
Omission is deletion. This is the whole desired library, not a patch — a clip missing from the record is retired, and omitted idle, on or actions rules are removed. expectedRevision is required: pass the revision you last read and a stale writer gets a 409 instead of silently erasing another change. Re-read and reconcile after a conflict; do not blindly overwrite the revision. You read it from the top level of GET /avatars/{id}/clips, beside data — it is 0 while the creation pipeline still owns the library, every accepted declaration bumps it, and the 202 body carries the new value.
For an uploaded take, upload or remotely register a video asset first, then use source: { assetId }. Uploaded clips are pose-validated against the avatar's rest-pose anchor before they can serve; the first and last frame must both return to that pose. The dashboard's Clips editor performs the same asset registration and full-library declaration without exposing a developer API key to the browser.
Her resting loop
The source loop is what motionPrompt described at creation. Without an authored idle group it forms a one-member idle pool. With authored idle clips, the source participates as an ordinary member of the same weighted pool. It has no separate rest draw or repeat privilege. Changing the source loop does not rewrite a library entry that references the previous source asset.
const { servingUrl } = await rta.setLoop(avatarId, {
motionPrompt: "tilts her head, a small amused smile, settles back to centre",
});
// servingUrl = the loop she is playing RIGHT NOW, for the whole render
await rta.waitForLoop(avatarId); // throws if the render gave upresp = client.put(f"/avatars/{avatar_id}/loop", json={
"motionPrompt": "tilts her head, a small amused smile, settles back to centre",
})
resp.raise_for_status() # 409 loop_pending, 422 loop_prompt_rejected / loop_not_generatable
accepted = resp.json()
# accepted["servingUrl"] = the loop she plays for the whole render (None if she had none yet)
# Poll GET /avatars/{id} until idleVideoStatus is "ready" (or "failed").Your clip library is untouched. Clips render against the portrait, not against the loop, so re-describing the loop re-queues nothing and does not move the library's revision. Describe a closed arc — it has to end where it began, or the loop snaps every time it wraps.
Keep it small. The loop is her resting state and a separate model animates her mouth, so every description is held to a nearly-still envelope: blinks, a soft closed-lip smile, a shift in weight, a few degrees of head drift. Ask for a laugh, a head turn, or an open mouth and the render comes back inside the envelope anyway — accepted, charged, and looking very much like the loop you already had. That is the one failure here with no error code attached to it, so aim inside the envelope rather than at the edge of it.
Waiting, and how each one ends
Both return on acceptance, not completion, so both need a wait — and the intuitive version of each is wrong:
waitForClipssettles when nothing is still rendering. Waiting for every clip to reachreadyhangs forever: a clip rejected by pose validation settlesfailed, which is terminal.waitForLoopsettles onidleVideoStatusand throws if the render gave up. A failed re-direct leaves herready— she is still serving the old loop — andidleVideoStatusis the only status field guaranteed to move;errormay or may not carry the reason. PollidleVideoStatus, neverstatusorerror: a loop watchingstatuswaits for a change that never comes.
Refusals worth telling apart
| Code | Means | Do |
|---|---|---|
403 clip_library_not_enabled | Clip Library access is disabled for this workspace | contact your workspace administrator or support; changing the body does not enable access |
409 revision_conflict | someone declared first | re-read, re-decide, re-declare |
409 loop_pending | a re-direct is already in flight | wait, then retry |
422 loop_not_generatable | no portrait to re-animate | terminal — do not retry |
422 clip_declaration_rejected | a motion prompt or action description was refused (clips route) | rewrite it |
422 loop_prompt_rejected | the loop description was refused (loop route) | rewrite it |
409 avatar_not_ready | she is not ready (either route) | poll status until ready, then retry; the loop route also refuses a creation that settled failed |
A 503 with retryable: true (clip_screen_unavailable, loop_workflow_unavailable, loop_workflow_queue_failed) is transient — back off and send the same body again. Each refusal above is JSON { error, status, code } — a malformed body (422) or an unknown avatar (404) carries no code — so switch on code, not on the status alone.
A rejected upload is not an error response at all: the declaration is accepted and that one clip settles failed with the verdict in poseCheck. The rest of the library is untouched.
What you cannot change
Her portrait. It is the rest pose every clip is rendered to start and end on — the shared frame that makes a state switch a splice instead of a jump — so replacing it would invalidate the whole library at once. Create a new avatar instead. Everything else about her is a sentence away.