# 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.

- Canonical: https://realtimeavatar.ai/docs/editing
- Updated: 2026-09-08

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

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.

**TypeScript**

```ts
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);
```

**Python**

```python
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.

**TypeScript**

```ts
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 up
```

**Python**

```python
resp = 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:

- `waitForClips` settles when nothing is still *rendering*. Waiting for every clip to reach `ready` hangs forever: a clip rejected by pose validation settles `failed`, which is terminal.
- `waitForLoop` settles on `idleVideoStatus` and throws if the render gave up. A failed re-direct leaves her `ready` — she is still serving the old loop — and `idleVideoStatus` is the only status field guaranteed to move; `error` may or may not carry the reason. Poll `idleVideoStatus`, never `status` or `error`: a loop watching `status` waits for a change that never comes.

## Refusals worth telling apart

| Code | Means | Do |
| --- | --- | --- |
| `403 clip_library_not_enabled` | a per-tenant rollout gate | ask us; nothing about the body helps |
| `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.

---

Realtime Avatar — realtime AI avatar API & SDK. Docs: https://realtimeavatar.ai/docs · Agent guide: https://realtimeavatar.ai/llms.txt
