# minimoji avatar API

Integrate the minimoji avatar API with curl and Swift: authentication, JSON documents, GLB heads, PNG portraits, endpoints, rate limits and errors.

Canonical documentation: https://www.minimoji.com/developers/docs

### Document capabilities and protected edits

`GET /capabilities` is public and returns `{ "documentVersions": [1, 2], "bakeVersion": 9 }`
(and `expressions` and `pickModes`, the faces and the modes `POST /expressions/pick` takes, `gestures`, the words it answers, and `characters` and `characterKits`, the species below).
Before sending v2 to save, bake, generate, or pack, clients must verify v2 support and a bake version at least as new as their bundled catalog (currently 9). The web AI proxy also rejects a successful response that downgrades a v2 draft; v1 requests retain their legacy behavior. An unavailable or older service must leave the draft intact and report incompatibility. Omitted `v` means v1; unknown versions and v1 documents carrying v2 likeness fields or IDs are rejected. Saving never automatically migrates a document. V1 normalization and seeded document identities remain unchanged; bake 9 intentionally updates the drawing of existing v1 and v2 documents.

V2 adds eye width/height/spacing (0.9–1.1, default 1), independent `brow` width/thickness (0.9–1.1, default 1), height (-0.05–0.05, default 0), and optional palette/hex color. `brows` remains a style string. V2 preserves explicit facial hair, freckles on every skin, and bald/no-brow choices, while retaining fit restrictions. Nose normalizes to `{style,size:1,bias:0,warm:true}` with legal imported values preserved.

Generation accepts optional `documentVersion` (1 or 2; defaults to current document version, otherwise 1) and `lockedPaths: string[]` with a `current` document. Paths may be editable fields or categories such as `eyes`, `brow`, and `nose`. Hair/Wear remix clients initially send `['seed','head','skin','eyes','brows','brow','nose']`. Locks are enforced on validated normalized results. A fit conflict that would erase a lock discards the proposal and retains the current document. The response includes `changes: [{path,from,to}]` computed after enforcement; protected edits use a factual server-generated notes summary. The client stages proposals and explicitly accepts one.

Explicit local legacy export uses `exportLegacyAvatar(doc)` from core and returns `{doc, losses: [{path,from,to,reason}]}`. It approximates unsupported styles and reports dropped likeness fields and v1 rule consequences. No service write performs that conversion implicitly.

### The origin

Everything below is served from one origin, shown on your
[API keys](https://www.minimoji.com/developers/keys) page (production is
`https://striped-walrus-538.convex.site`). The objects the routes redirect
to live on `https://bakes.minimoji.com`; you never construct those URLs
yourself, you follow the redirect or store what a response gave you.

### Keys

The write routes want `Authorization: Bearer <API key>`, minted at
[minimoji.com/developers/keys](https://www.minimoji.com/developers/keys).
Two kinds, the Stripe shape:

| kind | lives | may |
|---|---|---|
| **publishable** `mk_pub_…` | in an app | bake heads (`POST /avatars/bake`), cut editor pieces (`POST /packs/category`), generate a document from a selfie or a description (`POST /avatars/generate`) and ask which face a line of text reads as (`POST /expressions/pick`): idempotent where content-addressed, rate-limited |
| **secret** `mk_sec_…` | on your server | all of that, plus save avatars (`POST /avatars`) and upload pictures (`POST /renders/put`) |

The reads (`.json`, `.glb`, `.png`) take no key: they are URLs that go into
`<img>` tags. A key is shown once when it is created and can be revoked at
any time. Limits per key, per minute: 60 bakes, 20 cuts, 120 saves, 60
uploads, 10 generations (3 in a burst), 600 expression picks (100 in a
burst); and per key, per UTC day, 500 generations and 20,000 expression
picks. A `429` says how long to wait, in words and as `retryAfterMs`.
Generation and picks are free while they are new; a key can carry its own
daily quota of either, ask us. A publishable key is one key for a whole
app, so the pick limits are an app's, not a person's (a pick measured
0.003¢ on 2026-10-09, so a key spent to its day's limit costs us about
$0.60): an app that must limit each user calls through its own backend
with a secret key.

### Signing in from a terminal

A coding agent or a script can get keys without anyone copying one off a
web page: the OAuth device grant (RFC 8628), the shape `gh auth login`
uses. `POST /cli/login` with `{label}` (what the person approving will
read, e.g. `"Claude Code on my-laptop"`) answers `{deviceCode, userCode,
verificationUri, verificationUriComplete, interval, expiresIn}`. Show the
person `userCode` (`XXXX-XXXX`) and open `verificationUriComplete`
(minimoji.com/developers/device): they sign in, check the code and
approve. Meanwhile `POST /cli/login/poll` with `{deviceCode}` every
`interval` (5) seconds: a `400` whose `error` is `authorization_pending`
(keep polling), `slow_down` (too soon: add 5 s to the interval),
`access_denied` or `expired_token` (stop; a code lives `expiresIn`, 600
seconds, and is answered once), until a `200 {token, label}`. The
`mk_cli_…` token is shown this once and manages keys and nothing else: the
write routes above refuse it with a `401`. With `Authorization: Bearer
mk_cli_…`, `GET /cli/whoami` answers `{owner, email, name, session}`, `GET
/cli/keys` answers `{keys}` as the keys page lists them (never the key
itself), `POST /cli/keys` with `{kind, label?}` mints one and answers
`{key, prefix, kind}` with the key whole, this once, `POST
/cli/keys/revoke` with `{prefix}` (as listed) or `{id}` revokes one, and
`POST /cli/logout` signs the terminal out. A terminal shows on the keys
page with Revoke, and lapses after 90 days unused; any `/cli/*` call
after either is a `401`. Starting a login is limited to 10 a minute per
address, and the session to 60 calls a minute.

### Species

Besides people, the service saves and bakes the curated non-human cast:
D.A.S.H. (`agent`, ten themes `dashClassic`…`dashLavender`, and the dealt
`dashDealt`), the aliens (`alien`, `alienMint`, `alienGalaxy`, `alienJelly`,
`alienCadet`, `alienBrain`, `alienRoger`, and the dealt `alienDealt`), and
the animals (`cat`, `kitty`, `rabbit`, `monkey`, `dog`, the `penguin`, family `bird`, and the `ghost`, family `ghost`). Send a species as
`{"character": {"id": "agent"}}` wherever a route below takes `{doc}` or
`{character}`; never as a member of `doc` (that is a `400`, not the default
person). A selection is `{id, seed?, pick?}`:

- `id` is one of `GET /capabilities`' `characters`, each `{id, name, family, kit}`;
  any other id is a `400` that says so.
- `seed` (an integer 0…2147483647) deals a variant of a dealt id
  (`dashDealt`, `alienDealt`, `penguin`, `ghost`): the same seed is always the same head, so an
  app can give each install its own starter face with no editor. A bad seed
  is dropped.
- `pick` sets axes of the species' kit (`kit` is `alien`, `dash` or `null`;
  `characterKits` lists each kit's axes and choices), e.g.
  `{"id": "alienDealt", "seed": 7, "pick": {"head": "brain", "shell": "mint"}}`.
  An axis or choice the kit does not offer, and any other member, is dropped.

What is kept is what is stored and hashed: `GET /avatars/<key>.json` answers
`{"character": {…}}` for a species, which is how a reader tells the kinds
apart. A species' `.glb` carries `avatar: null` and the `character` in its
metadata and draws in every renderer; its `.png` is the species, framed by
everything it shows (ears, antennae, shell).

**Framing a head that reaches forward.** The perspective renderers (the
Kit's `HeadCamera.fitted` in `.bounds`, the web's `/render` route) size the
frame from the flat bounding box, which misses a part that is far out *and*
toward the camera: nearer the lens, it projects larger (the ghost's flared
hem left the frame at the bottom). A vertex at reach `a` (|x| or |y| from
the frame's centre) and depth `z` stays in a frame of half-angle tangent `t`
when the camera stands at `a / t + z` or further. Both renderers take the
largest of these over the rest vertices, never the effect plates (the
surprised "!", tears, flies: `isEffectPlate`, spelled in the Kit and in
`apps/web/lib/render-fit.mjs` as in `bake/lite.js`, a test holds the three
equal), and back off **only when that beats the flat box's distance**: the
Kit to 10% inside (room for a bob), `/render` to 4% (its own flat margin),
and `/render` for species only, so no cached person PNG reframes. The Kit
keeps only the frontier pairs no other vertex beats on both
(`HeadDocument.framingReach`). Every head but the ghost frames
byte-identically (the rabbit's ears are the regression case; on `/render`,
all 27 species on all 15 faces were checked). The web's React heads
use an orthographic stage and need none of it. Bodies, category packs
(`POST /packs/category`) and generation (`POST /avatars/generate`) are for
people: those routes refuse a `character` with a `400`.

### The routes

| route | in | out |
|---|---|---|
| `POST /avatars` | `{doc}` or `{character}` | `200 {avatarKey, json, glb, png}` — the document normalized and hashed (a species: the selection as kept, above); the same document is always the same key. The 256 px picture is drawn in the background right after, so a list row's first ask finds the CDN. Exactly one of the two, else a `400`. Secret key |
| `GET /avatars/<key>.json` | | `200` the normalized document, or `{character}` for a species, `Cache-Control: immutable` |
| `GET /avatars/<key>.glb?detail=.4&lite=0` | | `302` to the baked head, baking on the first request anywhere (seconds); the redirect is cacheable for a day |
| `GET /avatars/<key>.png?size=256&face=neutral` | | `302` to the picture once drawn; the first request draws it (a few seconds) and answers with the bytes. `size` is 64, 128, 256 or 512. `face` is optional, `neutral` by default, and may be any held-still emote: `neutral`, `happy`, `laugh`, `sad`, `angry`, `furious`, `surprised`, `worried`, `scared`, `dead`, `smug`, `joy`, `sleeping`, `crying`, `thinking`; anything else is a `400` that lists them |
| `POST /avatars/bake` | `{doc, detail?, lite?}` or `{character, detail?, lite?}` | `200 {url, bakeKey, bytes, cached}` — a document (or a species) and no key yet: an editor's path, or a fixed mascot an app never saves. Publishable or secret key |
| `POST /packs/category` | `{doc, current?, field, detail?}` | `200 {status, url, partsBase, …}` — one category's pieces for an editor composing locally. Publishable or secret key |
| `POST /renders/put?avatarKey=&size=&face=&bakeVersion=&species=` | PNG bytes | `200 {url, renderKey, bytes, cached}` — a picture you drew yourself: a `size`×`size` PNG under 2 MB for a saved avatar, showing `face` (optional, `neutral` by default); the first upload for a key wins. Required `bakeVersion` is the version that actually drew these bytes; missing/mismatched versions return `409` before storage. For a species, `species=1` says the bytes show the species; without it the upload is a `409` (a renderer that predates species draws a person). Secret key |
| `POST /avatars/generate` | `{image?, prompt?, current?, seed?}` | `200 {doc, notes, usage}` — a normalized document from a selfie (`image`: base64 JPEG or PNG, 4 MB decoded at most; downscale to a 1024 px long edge first), a description (`prompt`, up to 500 characters), or both (the photo leads, the words adjust). With `current`, an edit by description: what the words ask for changes, the seed and everything else survive; a vague request ("make it prettier") is read as a style direction and lands as two or three picks that go together. `notes` is a short plain-text summary: when editing, what changed and why, then anything that could not be honoured; otherwise what could not be honoured, or empty. `usage` is `{model, rung, attempts, input, cached, output, cost}`: the model the returned document came from as the provider named it (`none` when a no-op edit such as "keep it as is" needed no model), which rung of the generation it came from, how many calls answered, the tokens across all of them and the dollars OpenRouter billed for them. The generation is a cascade: `rung` 0 is a decision model (words in about 0.2 s, a selfie in about 0.5 s), 1 the style-direction table and the decision model again, 2 a text expander and the decision model again, 3 and up the chat ladder (3 its first model, 4 its second). When no decision rung applies to a request (the rungs switched off on the deployment, a photo sent with a `current` document, or a photo on a deployment with only the text decision model set) the chat ladder counts from 0. An answer from rungs 0–2 has `notes` written by code, not by a model, up to three sentences: on an edit `Changed hair.color, hat.style.` (or `No fields changed.`), then anything a document rule took away, then `Could not place: fedora, scarf.` for what the inventory has no piece for; an answer from the chat ladder has the model's own sentence as before. Nothing is saved: hand `doc` to the editor or to `POST /avatars`. Under a second from the decision rungs, a few seconds from the ladder. Publishable or secret key |
| `POST /expressions/pick` | `{text, options?, mode?, gesture?}` | `200 {expression, candidate, confidence, reason, probabilities, mode, usage, gesture?, gestureWord?, gestureProbabilities?}` — the held-still face a line of text reads as: a status, a transcript, a chat line. `text` is trimmed, must not be empty and is at most 1000 characters (Unicode code points; cut a longer transcript to its end, people end on how they feel). `options` narrows the answer to faces you can show: one or more of `neutral`, `happy`, `laugh`, `sad`, `angry`, `furious`, `surprised`, `worried`, `scared`, `dead`, `smug`, `joy`, `sleeping`, `crying` (the list `GET /capabilities` returns as `expressions`; `thinking` is a state a host drives, not a mood, and is not offered); absent, all of them. `mode` is how eager a pick is: `cautious`, `balanced` or `eager` ("How eager a pick is", below); absent, `balanced`. `expression` is the pick, or `null`: keep whatever the head wears. `reason` says which: `picked`; `unclear` (the text shows no feeling: a link, a plan, a plain "at the gym"); `unsure` (there is a `candidate`, not sure enough for the mode); `refused` (the model provider would not read the text; `candidate` is `null` and `probabilities` is `{}`). `candidate` is the most probable face, picked or not (`null` when no face got any weight), and `confidence` its probability. `probabilities` are the model's over exactly the faces asked plus `none`, so a stricter host applies its own rule. `mode` is the rule that was applied; a deployment that predates the modes ignores the field, applies `cautious` and answers without `mode`, which is how to tell. `neutral` means relaxed and calm, not "no mood": that is `null`. With a narrowed list, a text whose mood is not offered lands on a neighbour (worried on `sad`), so offer `neutral` and expect `none` less often. `usage` is `{model, cost, input, output, ms}`: the model as the provider named it, the dollars billed, the tokens, and the model call's milliseconds. One decision-model call, about 0.2 s; the service gives up after 3 s (`504`). Nothing is cached, so debounce: ask on a typing pause, not on a keystroke. Measured only on authored English texts so far; other languages are read as written and unmeasured, and sarcasm is read literally ("wow thanks for the invite" reads as `happy`). **The gesture** (9.4): `gesture: true` asks a second `choice` question in the same call, the head gesture a friend would make reading the text aloud, over a nine-word vocabulary (`nod`, `double-take`, `tilt`, `bob`, `laugh`, `shake`, `lean`, `glance`, `none`; `GET /capabilities` lists the eight as `gestures`). The answer then carries `gestureWord` (the top word when it is at or over 0.5 and not `none`, else `null`), `gesture` (the track to cue for the **picked** face, as a track id the Kit maps with `HeadGesture(rawValue:)`: `happy` + `double-take` is `tiny-double-take`, `laugh` + `laugh` is `big-double-laugh`; `null` with no pick, no word, or no track fitting that word on that face) and `gestureProbabilities` over the nine words. Without the flag the body is unchanged. The threshold is a first guess measured once on Bunch's held-out authored rows (95% of words read accepted, 68% of rows wanting one covered, none on rows wanting none); it costs no second model call. Publishable or secret key |

Every route answers `OPTIONS` and sends `Access-Control-Allow-Origin: *`.
Query values: `detail` is a fraction of full geometry in (0, 1] (absent: .4,
the mobile budget); `lite` is `0|1|true|false` and drops the effect plates.

### Explicit head shake (MinimojiKit 9.16.0)

JavaScript `life.perform('head-shake')` and Swift `life.perform(.headShake)`
add a deliberately authored “no”: two tapering side-to-side turns over the
current expression, returning to rest in 1.85 seconds. The existing cue
lifecycle, gaze ownership and Reduce Motion apply. The cue is excluded from
automatic mood selection; the original 45 performances remain three per mood.

The shared default mapping from `shake` to a track now selects `head-shake`
for neutral, sad, worried and thinking. Existing mood-specific shake
overrides and every other word retain their mappings. The service uses that
mapping from `@minimoji/engine/performances`; no request-schema or model-prompt
change is needed. Deploying the backend makes it start returning the new ID.
Ship compatible SDK/web consumers first. Older Swift clients decode an unknown
ID as nil; older web engines return null from `perform`, so they keep the
expression and omit the gesture. The cue is available in the web engine and
[MinimojiKit 9.16.0](https://www.minimoji.com/developers/kit/RELEASE_NOTES.md).
The production service mapping was deployed on 2026-10-16 after the compatible
web client and Kit release were verified.

### How eager a pick is

`mode` on `POST /expressions/pick` is a name, not a number: the numbers
behind each name are tuned to one model and re-derived when it changes, and
the name keeps its meaning. It decides only whether `expression` is the
`candidate` or `null`; `candidate`, `confidence` and `probabilities` are the
same in every mode, and `none` in the lead is `unclear` in all three.
`GET /capabilities` lists the names as `pickModes`.

| mode | picks when | for a host that |
|---|---|---|
| `cautious` | the candidate is at least 0.45 | would rather miss a mood than show a wrong face |
| `balanced` (the default) | `cautious` picks, or the text clearly has a mood but spreads it over more than one face: `none` at most 0.10 and the candidate at least 0.30 | wants the face to follow the words, with few false picks |
| `eager` | `none` is not the most probable answer | wants a face on almost everything |

**The default changed with the modes** (on a deployment, from the deploy
that brings them: `pickModes` in `GET /capabilities` and `mode` in the
answer say it has). A request without `mode` used to get what `cautious`
gives and now gets `balanced`, so a caller that sends nothing (the Kit
before 8.3.0) sees a few more texts come back `picked`:
"i got so much homework" (dead 0.38, worried 0.34, `none` 0.06) was `unsure`
and is now `dead`. Send `"mode": "cautious"` for the old answers.

What each mode did on three runs of 304 texts on 2026-10-10 (authored
texts, measured through the dev route; none is a real status, so none of
this is real-traffic accuracy):

| | all 304 texts: picks that fit | texts with a mood picked | no-mood texts picked | the 106 held-out texts: picks that fit | texts with a mood picked | no-mood texts picked |
|---|---|---|---|---|---|---|
| `cautious` | 98% | 91% | 4% (7/162) | 97% | 87% | 8% (4/51) |
| `balanced` | 98% | 93% | 4% (7/162) | 97% | 89% | 8% (4/51) |
| `eager` | 97% | 94% | 6% (10/162) | 96% | 91% | 8% (4/51) |

"Fit" counts any face the text's label accepts. `balanced` is a narrow fix,
not a general lift: over the 912 calls it picked 11 times where `cautious`
did not, on four texts, each time a face the label accepts and never on a
no-mood text. It does not reach a complaint stated as a fact: "inbox is at
400" and "50 pages to read by tomorrow" answer `none` in every mode, because
a plain statement of what is happening is what `none` means. Half of the
held-out workload texts that should get a face get one under `cautious` and
`balanced`, 64% under `eager`.

### Errors

Always `{error}` with words, never a bare status: `400` for a value the
grammar refuses (for `/avatars/generate`: neither image nor prompt, an
oversized or non-JPEG/PNG image, a photo with no face and no prompt, or a
photo the model declined, with the category), `401` for a missing or
unknown key (the message names the keys page), `403` for a key of the wrong
kind (it names the kind wanted), `404` for a key nobody saved, `429` for a
rate limit or a spent day's quota (`{error, retryAfterMs}`: the wait in
words and in milliseconds), `500` with the message when a bake or the model
call throws, `503` when generation or the expression picker is switched
off on the deployment, `502` when the picker's model provider failed (the
message names its status and nothing else) and `504` when it did not answer
in time. For `/expressions/pick` a `400` is a `text` that is missing, empty or
over 1000 characters, an `options` that is empty or names a face outside
the list (the message lists them), or a `mode` that is not one of the three
(the message lists them); a text the provider will not read is not
an error but a `200` with `reason: "refused"`. `402` is reserved for a key
past a paid plan's quota; no route answers it today.

Photos sent to `/avatars/generate` are not stored by Minimoji: decoded,
sent to our model provider under its API terms, and dropped — never written
to storage, scheduled or logged. Send photos of the user themself, no
minors, and no third parties without their consent; show your own camera
usage description and a one-line disclosure before capture.

The text sent to `/expressions/pick` is treated the same way: sent to our
model provider under its API terms and dropped — never written to storage,
scheduled or logged, and neither is the face picked for it. The route's one
log line carries the model, the time, the cost, the reason, the text's
length and the mode. No error message quotes the text: the provider's own error words
are not passed on, since they could.

### What moves a URL

```
avatarKey   = sha256hex(canonical(normalize(doc)))         64 hex
            | sha256hex(canonical({character: selection}))  a species, as kept
bakeKey     = <avatarKey>-v<version>-d<round(detail*100)>[-lite]
renderKey   = <avatarKey>-v<version>-s<size>[-f<face>]      face only when not neutral
```

The document *after normalization* is the identity: field order and
omitted defaults do not matter, and an old spelling the normalizer rewrites
lands on the same key. A different value — a new hairstyle, another eye
colour — is a new key. `version` is the engine's bake version: when the
service changes how an existing head is drawn it bumps the version and
every `.glb` and `.png` URL moves at once, which is why you store the
**avatar key** (or the contract URL, which always redirects to the current
version) rather than a CDN URL. `detail`, `lite`, `size` and `face` are the
other dimensions a URL has; a neutral picture's key has no face in it, so
every render key minted before faces still names the same picture.
A species shares the grammar: a normalized person never has a `character`
member, so the two kinds can never land on one key, and a bake-version bump
moves a species' URLs with everyone else's.

### Two examples

```bash
S=https://striped-walrus-538.convex.site
DOC='{"v":1,"seed":4821,"head":"round","skin":"brown","hair":{"style":"afro","color":"ink"}}'

# save, then fetch the head: the same document always lands on the same object
KEY=$(curl -s -X POST $S/avatars -H "Authorization: Bearer $MINIMOJI_SECRET_KEY" \
  -H 'content-type: application/json' -d "{\"doc\":$DOC}" | jq -r .avatarKey)
curl -sIL "$S/avatars/$KEY.glb?detail=0.4" | grep -iE '^(HTTP|location)'
#   HTTP/2 302 … location: https://bakes.minimoji.com/bakes/<KEY>-v2-d40.glb
#   HTTP/2 200                                 (1.9 MB, model/gltf-binary)

# its picture, for a list row or an invite page: no key at all
curl -sIL "$S/avatars/$KEY.png?size=256" | grep -iE '^(HTTP|location|content-type)'
#   the first time: HTTP/2 302 … location: https://www.minimoji.com/api/render/<KEY>?size=256
#                   HTTP/2 200   content-type: image/png        (drawn now, a few seconds)
#   from then on:   HTTP/2 302 … location: https://bakes.minimoji.com/renders/<KEY>-v2-s256.png
#                   HTTP/2 200   content-type: image/png
```

The Swift side of the same two calls is the quick start in
[MinimojiKit README](https://www.minimoji.com/developers/kit/README.md): `AvatarService(base:key:)`,
`head(for:)`, `save(_:)`, `renderURL(for:size:)`. A host's agent starts from
the package's [`SKILL.md`](https://www.minimoji.com/developers/skill.md)
(`packages/swift/MinimojiKit/SKILL.md` here): the integration rules and
snippets in one file, every snippet copied from the README or the demo.

### The Swift avatar editor

MinimojiKit includes the native editor, AI generation, selfie capture,
on-device voice input, and preview lifecycle in the **MinimojiEditorUI**
Swift Package product. Add the package (partners, by request: its
[README](https://www.minimoji.com/developers/kit/README.md) says how)
and link `MinimojiEditorUI` and `MinimojiService`. The editor requires iOS 18;
the package can be linked by apps targeting iOS 17, with the editor gated
behind an availability check.

```swift
import SwiftUI
import MinimojiEditorUI
import MinimojiService

@available(iOS 18.0, *)
struct AvatarEditor: View {
    let service: AvatarService
    @State private var doc = MinimojiEditorView.starter()

    var body: some View {
        MinimojiEditorView(service: service, doc: $doc, imports: .kit)
    }
}

// Create the service with your deployment origin and a publishable key.
// AvatarEditor(service: AvatarService(base: origin, key: "mk_pub_…"))
```

Every accepted edit updates `doc`. Your app decides when and how to persist
it; saving with a secret key belongs on your backend. The editor never
saves automatically. You can also bind `head` to retain the latest baked
preview for a confirmation screen.

`offersSpecies: true` adds the kind row (Person, D.A.S.H., Cat, Dog, then
the rest of `service.characters()`). A species is a second kind of draft in
the same `EditorHistory`: `doc` becomes `{"character": {…}}` (never a field
on a human document), `head` its service bake, and the panel offers only the
catalogue's ids and kit axes, the same allowlist `normalizeCharacter`
enforces. `service.save(doc)` routes it to `POST /avatars {character}`. The
web `/playground` has the same row, reusing `CharacterPicker`, and its "bake
for native" sends `{character}` through `/api/bake`.

#### AI edit: an inline composer; a selfie is always a new face

The avatar card carries two controls: an **orange camera** at the bottom
left and **AI edit** at the bottom right. Neither opens a sheet.

**AI edit** turns the editor into a composer in place: the parts panel
slides away, the stage grows with the head at full brightness, a row of
three suggestion chips sits under it ("Try · silver hair · heart glasses ·
a boxed beard" — `EditorSuggestions` derives them from the document, never
offering what the head already wears), and a field docks on the keyboard
with the camera as its leading button and an ↑ that is grey until there is
text. A chip or ↑ sends the words with `current` and the seed lock. While
the edit is in flight the head wears the hologram and the ↑ is a spinner;
when the answer's head is built (on the device when the service has a
local builder, otherwise once `/avatars/bake` answers), the stage
**whirls** into it — `Whirl`, the Swift port of `swap.js`, two
turns in 0.6 s with the new face out of the last half turn and the spring
after — and **Undo | Keep** sit under the stage. The sentence stays in the
field for a follow-up. Keep commits one undo transaction; Undo whirls back
and keeps the composer open; ✕ on the stage, shown while no answer is
pending, closes the composer with no change.
Holding AI edit still dictates and sends on release.

**The camera** makes the stage the viewfinder (`EditorSelfieStage`: front
camera, mirrored, a dashed face oval, Library, the shutter, flip, ✕). The
shutter is the send: the shot (1024 px long edge, JPEG 0.85) goes straight
to `POST /avatars/generate` **with no `current` and no `lockedPaths`** —
`EditorComposer.request` drops the document whenever a photo is attached,
so the service takes the one-call decisions rung instead of the edit
ladder. While the server reads the face the stage runs the **shuffle**
(`ShuffleWhirl`): the whirl's ease-in winds the old head up, it cruises at
the whirl's top speed with three other heads passing behind it (the host's
`shuffleHeads`, or three the device builds while the camera is up — or,
from the library, during the read, joining as each lands), and the
answer lands the last turn on the new face with the boing. "Reading your
face" with the thumbnail sits at the bottom over the dimmed shot. The result
lands in the same frame with a **Before** chip (press and hold to peek), a
"From your selfie" pill and **Retake | Keep**. A `400` (no face) returns the
stage to the camera with the server's reason as the hint line. Without a
camera (the simulator, a host without `NSCameraUsageDescription`, or the
default `.kit` — the stage camera needs `.kit(camera: true)`) the button
opens the photo library and a pick is the send.

While the camera, the read or a new face owns the stage, the Kit sets the
host's `proposalOpen` binding; the demo shows only the title ("New face")
then and brings Cancel/Save back after Keep, Undo, Retake or ✕. Reduce
Motion replaces the whirl and the shuffle with a crossfade and stops the
scan line. The review sheet (`EditorProposalReview`, with Revise, paging
and Accept) remains only for the parts panel's **Remix this category ·
3 looks**, the one place the Kit asks for three candidates at once.

Replacement is a separate entry: **Settings → Replace avatar…** offers
**From a selfie** (the same stage camera, opened on arrival) or **Describe a
new avatar** (the composer, with the keyboard up; its first request omits
the starting document). Pass `creatingAvatar: true`, `creationSource`, and
`onReplacement` for the host callback after Keep.

Floating controls use native Liquid Glass on iOS 26 and ultra-thin material
on earlier supported releases. Slow manual updates preserve the current head
with the same cyan hologram and bottom-left terminal status. Errors retain
Retry away from the recovery controls.

#### Permissions and camera

The host app supplies these nonempty `Info.plist` descriptions to enable voice:

```xml
<key>NSMicrophoneUsageDescription</key>
<string>Use your voice to describe changes to your avatar.</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>Turn your speech into avatar prompts on your device.</string>
```

Voice uses on-device speech recognition and is unavailable on unsupported
devices or locales. Audio is not uploaded by the Kit; the resulting text is
sent to the avatar generation service. Typing remains available when voice
cannot run. Capture stops when leaving the editor or backgrounding the app.

The default `.kit` uses the photo library. To offer the stage camera, use
`.kit(camera: true)` and provide `NSCameraUsageDescription`; camera hardware
must also be available. Otherwise the camera button opens the library.
Photos are downscaled to a 1024 px long edge and encoded as JPEG before
upload. The model provider processes them; Minimoji does not store them.

#### Existing apps and bundled avatars

Use `imports: .none` (the default) for manual editing only, or
`imports: .callback { source in ... }` for a host-owned import flow. Existing
`onImport` and `onImportSelfie` initializers remain supported. Hosts switching
to `.kit` can remove their import picker, prompt sheet, generation state,
and manual editor reset after generation. Keep host persistence.

For a bundled starting avatar, pass `bundledHead` and a lazy
`bundledSourcesProvider`. The Kit registers that head before previewing,
loads its part packs, warms nearby choices, and discards the starting packs
after AI generation. Update `documentID` along with `doc` when your app
selects a different starting avatar. The Kit owns the internal editor session;
callers do not need to change the view's `.id` after a generated edit.

See the [Kit README](https://www.minimoji.com/developers/kit/README.md)
for theme customization, package products, and complete API examples.

### The head at runtime

A baked head is not a picture: the runtime that plays it takes a handful
of states, the same ones on the web (`@minimoji/react`'s `MinimojiHead`)
and on iOS (MinimojiKit's `MinimojiMetalHeadView` / `MinimojiHeadView`).
Everything else — blinks, glances, idle drift, the one-shot performance a
new mood picks — the head does on its own.

| state | values | what it is |
|---|---|---|
| `expression` | `neutral happy laugh sad angry furious surprised worried scared dead smug joy sleeping crying thinking` | the mood: a face, with its own marks (steam, sparkles, a `!`, tears, Zs). `thinking` is one brow up, the mouth pulled aside and the eyes up and aside |
| `look` | `{x, y}` in −1…1, or none | where the eyes go, then the head; unset, the head glances about on its own |
| `talking` | on / off, or seconds | a jittery jaw over any face; feed `mouth` (open / width / round) from audio for real lip-sync |
| `talkStyle` | `mouth` (default) · `arcs` · `lines` | the mouth animates without accents by default; explicit styles add marks beside it: thin, see-through 3D **sound arcs** rippling out of both corners of the mouth, `(( o ))`, lighting one to three rings with the voice's loudness, drawn over hair, turning with the head, held through a phrase and gone within about 0.4 s of silence; `lines` for manga chatter dashes off the mouth's corners, `mouth` for the jaw alone. A host tints them to its own UI, with no re-bake, through `ColorOverrides.talk` / `talkOpacity` in MinimojiKit. Below 48 pt the rings are thicker and two at most. A host with real audio passes `voiceLevel` (0…1): the head views' `voiceLevel:` prop (MinimojiKit 7.1) or `MinimojiHead`'s `voiceLevel` when the level is already UI state, kept fresh every frame; a level from an SDK that doesn't publish changes (LiveKit, `AVAudioEngine`) goes through a performance snapshot's `voiceLevel` or `FaceLife.setVoiceLevel`, sampled every frame; otherwise the loudness is read off whatever moves the mouth |
| `listening` | `{tempo?, mouth?, intensity?}`, or none | **a state, not a face**: the head nods on the beat of `tempo` (bpm, default 96), three notes rise off the crown, and the mood underneath is untouched — a sad head nods sadly. `mouth` is `closed` (default), `hum`, `sing` or `lipSync`; `intensity` 0…1 scales the nod and the notes, so a list row bops less than a hero. A `drop` event adds a bigger nod without leaving the state; the single and joined notes retain their independent float cadence |
| `thinking` | on / off | **the typing state** ("a friend is typing"): the `thinking` face laid over the mood plus a loop — a gentle tilt, the eyes shifting up and aside every 1.5–3 s — with the mouth held shut. Any talking takes the mouth at once and fades the tilt out in about 0.3 s, so a host can hand off without flipping both on the same frame |
| `presence` | `online` (default), `asleep` (`z?`), `party` (`tempo?`) | **per head, set by the host**. `online` is the idle, with a subtle breath on every platform. `asleep` is offline, not the snoring `sleeping` face: eyes and mouth shut, a slow breath, a partway droop; `z: true` lies the head down with the Zs, which only a head baked with full eyes and not `lite` carries (elsewhere it just lies down). `party` is a small bounce and squash on the beat of `tempo` (bpm, default 120) or a host's `beat`; `listening` wins the beat when both are set. Leaving `asleep` for anything — `thinking`, `talking`, `online`, `party` — plays a built-in ~0.6 s wake-up (a blink opens the eyes, the head lifts, the brows jolt) |
| `performance` | a source sampled every frame, or none | **a host plays the head** (an instrument, a game): each rendered frame, right before the update, it answers `{active, look, mouth, beat, generation, blink, drop}` from its own clock and touches. `beat` is the host's absolute musical beat and replaces the nod's clock for that frame (none: the nod free-runs on `tempo`); a new `generation` (seek, restart) eases the nod back in; `blink` is an event sequence, one blink per change, never replayed; `drop` is another, one listening drop (a bigger nod and a note burst) per change, and only while listening; a held `mouth` is fed every frame so its 0.3 s TTL never closes a sustained note, and `null` releases at once. While set it owns gaze, mouth, beat, blinks and drops; identity and mood stay with the other states. `listening.notes: false` keeps the nod without the floating notes or the wider frame |
| a **sung** `mouth` | `{open, width, round, energy?, bliss?}` | `energy` above 0 (the default 0 is plain lip-sync and moves nothing else) makes the mouth *sung*: it opens bigger, wider as much as taller and growing down from the upper lip; the teeth and tongue keep their size; and the brows lift, the eyes widen and the head stretches a little with it. `bliss` above 0.5 shuts the eyes into the happy arc behind a blink. You rarely set these by hand: the **singing voice** (`SingingVoice` in MinimojiKit, `createSingingVoice` in the engine) turns "this note, held this long" into such a mouth every frame — the note lands with a pop, glides between notes, **trills** after 0.8 s (jaw, lips and energy shake together, so the eyes and brows flutter), takes an optional `vowel` from a tight round "oo" (0) to a wide "ah" (1) so a slide across a pad morphs the lips, and closes the eyes after 2 s on one note. Frame-rate independent |
| a **spoken** `mouth` | `{open, width, round}` from a speech voice | a recorded voice (a voice status, a voice note) plays the mouth through the same `performance` snapshot: the **speech voice** (`SpeechVoice` + `Viseme` in MinimojiKit 8.1, `createSpeechVoice` + `VISEMES` in the engine) takes, every frame, the viseme the host's timed stream says is current (`rest`, `open` ah, `round` oh/oo, `wide` ee/ih, `shut` m/b/p, or none) and the loudness at that instant (0…1, off an envelope stored with the clip), and eases a plain lip-sync mouth out of them: loudness opens the jaw under the shape's cap, the lips glide to the shape, `shut` closes fast. The host owns the clock and the stream; `rest` through silences keeps the head's own talk jaw off; none, once quiet, releases. The same loudness goes to `voiceLevel` for the arcs. The animator gives any continuous mouth the **speaking look**: one plate per shape (the wide `ee` plate included), teeth and tongue tooth-sized and hugging their lip (teeth behind round lips, tongue behind wide ones), a chin dip per syllable, a blink at a phrase's end, the gaze on the viewer. **The mood stays on** (emotional talking): the face's talk-only keys reshape the plate (`talkSX`/`talkSY`/`talkY`/`talkX`/`talkRoll`, easing in with the jaw; a sung mouth is untouched), bias the teeth and tongue (`talkTeeth`: a snarl bares them, a sad lip covers them), and set the direction of the **stress accent** a syllable that hits (the jaw crossing a quarter-open) lands on the brows, eyes and head (`talkBrow`; sized by how far the jaw gets; 250 ms cooldown; behind the performances switch). In a gap the mouth is the expression's own |

`talking` and a fed `mouth` always beat a listening or thinking mouth; a `look` you set
always beats the head's own glances. Headphones for a listening head are
*worn, not saved*: derive `identity ⊕ {extra: headphones}` for the moment
(MinimojiKit's `ListeningWear.headphones(on:catalog:)` does it under the
avatar rules and says in `applied` whether they went on — a beanie wearer
keeps the beanie) and never write it into the saved document. The status
chip ("listening · Bad Guy · Billie Eilish") is yours to draw; the head
renders no text.

```swift
MinimojiMetalHeadView(document: head, expression: "sad",
                      listening: Listening(tempo: 120, intensity: 0.6))
```

```tsx
<MinimojiHead avatar={doc} expression="sad" listening={{ tempo: 120, intensity: .6 }} />
```

`thinking` and `presence` are parameters on MinimojiKit's views; on the web
they are on the engine's runtime (`life.think(on)`, `life.setPresence(p)`,
or `applyState({thinking, presence})`) and not yet `MinimojiHead` props:

```swift
MinimojiMetalHeadView(document: friend, talking: isSpeaking,
                      thinking: friendIsTyping, presence: isOnline ? .online : .asleep(),
                      frameRate: .reduced)   // the non-speaking heads of a group header
```

Played by an instrument (the source is one stable object or function; it
reads your audio clock, it is not React or SwiftUI state):

```swift
MinimojiMetalHeadView(document: head, emotes: false,
                      listening: running ? Listening(tempo: bpm, notes: false) : nil,
                      performance: jam, isActive: onScreen)   // jam: AvatarPerformanceInput
```

```tsx
<MinimojiHead avatar={doc} emotes={false} listening={running ? { tempo: bpm, notes: false } : null}
              performance={t => ({ look, mouth, beat: transport.beat(), generation, blink })} />
```

The `mouth` in both comes from a singing voice, sampled once per frame
with how open the note is (0…1, none when no note is down), how long it has
been held, and optionally the vowel:

```swift
var voice = SingingVoice()                      // keep it; it eases between frames
let mouth = voice.sample(dt: dt, target: touch == nil ? nil : rowOpen, held: heldSeconds, vowel: touchX)
```

```ts
const voice = createSingingVoice();             // from @minimoji/engine
const mouth = voice.sample(dt, touch ? rowOpen : null, heldSeconds, touchX);
```

Reset `held` when the NOTE changes, not when only the vowel slides. Try it:
`packages/engine/dev/perform.html` on the web, the harness's **Keys** tab
(`-keys`) on iOS.

A recorded voice uses the same snapshot with a **speech voice** instead:
once per frame, the viseme its stream says is current and the loudness read
off the clip's envelope at the player's time, both the host's:

```swift
var voice = SpeechVoice()                       // keep it; it eases between frames
let mouth = voice.sample(dt: dt, viseme: clip.viseme(at: player.currentTime), level: clip.level(at: player.currentTime))
```

```ts
const voice = createSpeechVoice();              // from @minimoji/engine
const mouth = voice.sample(dt, clip.visemeAt(audio.currentTime), clip.levelAt(audio.currentTime));
```

Sending it between apps: put `{listening: {tempo?, detail?}}` on a
member's presence, absent when not listening, and map it to the state
above on the receiving side (the `detail` is for the chip).

### Bodies on the web (preview)

The heads above can stand on a body: the Mini walks, runs, ducks,
crouch-walks, jumps, sits, lies down, stands up and gets hit, with every
emote laid over what the body is doing. It is what
[minimoji.com/body](https://www.minimoji.com/body) runs.

**Web availability.** The web body remains in-house: it lives in
the JavaScript engine, `@minimoji/engine`, a **private workspace package**
of the minimoji repository. It is not published to npm and there is no
separate SDK release. Two entries:

- `@minimoji/engine/body-playground`: a finished stage (the figure, a
  floor, the camera) you mount into an element and drive with method
  calls. Start here.
- `@minimoji/engine/body`: the same behaviour without a renderer (the
  action controller, the emote coordinator, the performer that joins them),
  for a host with its own scene and rig.

Native body playback is available in the optional `MinimojiBody` product since MinimojiKit **v9.19.0** (see the native section below).

**Assets.** The body is KayKit's CC0 adventurer rig and its animation
packs. Serve `kaykit/Rogue.glb` and the five `kaykit/Rig_Medium_*.glb`
files (General, MovementBasic, MovementAdvanced, Simulation, Extras) from
one directory and pass its URL as `assetBase`. They load once per page
and are shared by every stage.

#### Mount, drive, dispose

```js
import { mountBodyPlayground } from '@minimoji/engine/body-playground';

const stage = await mountBodyPlayground(el, {
  avatar,                                   // an avatar document
  assetBase,                                // serves kaykit/Rogue.glb and kaykit/Rig_Medium_*.glb
  reduceMotion: matchMedia('(prefers-reduced-motion: reduce)').matches,
  onState: s => console.log(s.action, s.step, s.mood),   // fires when what it shows changes
});

stage.act('walk');          // a semantic action: the figure walks in place, the floor runs under it
stage.emote('joy');         // face and body together; the walk keeps going
stage.act('jump');          // a one-shot: lands back in the walk
stage.setSpeed(.5);         // everything at half speed: body, face, floor
stage.setView('side');

// when the element goes away (a framework's unmount, or the page itself):
// stops the frame loop, removes the canvas, releases the WebGL context
addEventListener('pagehide', () => stage.dispose(), { once: true });
```

`mountBodyPlayground` resolves once the rig and the head are built, and
rejects (having removed its canvas and released its WebGL context) when
WebGL, the assets or the avatar fail. The handle:

| method | what it does |
|---|---|
| `act(id)` | ask for an action (below). False when nothing changes: the same action again, a jump in mid-air, an action this rig cannot do |
| `emote(mood)` | an explicit emote: a new face performance and body gesture, even for the mood already showing (a replay) |
| `clearMood()` | back to the neutral face and posture; no performance |
| `replay()` | the current action from its start: a loop restarts, a held sit or lie gets up and goes down again, a landing jumps again |
| `reset()` | neutral idle at once: no transition, no mood, no pending request |
| `pause()` · `resume()` · `setSpeed(s)` | freeze or scale the one clock (`1`, `.5`, `.25` or any positive number) |
| `setView(v)` | `front`, `threeQuarter`, `side`, `back`; dragging the canvas orbits |
| `setAvatar(doc)` | swap the Mini in place; the mood and the asked-for action carry over. Throws, keeping the current Mini, if the document will not build |
| `setReducedMotion(on)` | switch the reduced-motion policy (below) |
| `setMoodMotion(on)` | enable or disable expressive walking/running; enabled by default and retained across avatar swaps and reset |
| `playSequence()` · `stopSequence()` | a scripted tour through every action; any `act` or `emote` stops it |
| `available()` | the action ids this rig can do |
| `state()` | `{action, step, pending, ground, mood, stance, elevation, speed, paused, view, reduce, moodMotion, sequence, label}`: `ground` is the standing or resting action a one-shot returns to |
| `dispose()` | stops the frame loop, removes the canvas and listeners, releases the WebGL context. Safe to call twice |

#### Actions

Actions are semantic: you never name an animation clip.

| id | kind | behaviour |
|---|---|---|
| `idle` `walk` `run` `duck` `crouchWalk` | standing | cross-fade straight into each other. Walking and running are in place; the floor runs under the feet at the gait's speed |
| `jump` | one-shot | takeoff, about 0.75 of clearance, a grounded landing, then back to the standing action that was asked for |
| `sit` `lie` | rest | enter, hold until something else is asked for, get up, then do it |
| `hit` | one-shot | a flinch (two alternating takes) and a short surprised, angry or scared face; then the earlier mood comes back (below) |
| `stand` | alias | `idle`: gets up from a rest, stops a walk or run; nothing happens when already idle |

**The latest request wins.** Requests are not queued. A jump, and a
rest's way down and up, play out and then go to whatever was asked for
last; a hit is cut short by any newer request. Mashing buttons always
settles on the final press and never leaves a frozen pose. From a rest or
a lying mood, every action gets up first; a jump or hit asked for from
`sit` returns to `sit`.

#### Emotes, stances and hits

`emote(mood)` takes any of the head's expressions. The face and the body
play one event: the same variant, the same clock, one end. The body adds
a held posture (a slump, open arms, a hand at the chin) and a gesture
while standing, walking or running. A crouch, a jump, a rest or a lying
pose keeps its silhouette, so there the mood shows in the face.

- **`dead` and `sleeping` lie down.** The figure falls (or lies down) into
  a held pose and stays there. The same mood again replays the face
  without a second fall. A **later action wins**: the figure gets up and
  does it, keeping the face. A **different emote** gets it up too.
  `reset()` is neutral idle at once.
- **A hit restores the mood under it**, face only, once the flinch ends,
  unless any mood was chosen meanwhile (even the one the flash showed);
  that choice stands. Consecutive hits come back to the mood from before
  the first.
- **Variation.** Each explicit emote draws once, at the start, a mirror
  (the other hand), a size and a body tempo, and the held posture keeps
  the same draw. Draws are bounded (tighter for poses where a hand meets
  the face or body) and never let the body outlast the face. Pass
  `random` to `mountBodyPlayground` (a seeded generator) to make a run
  reproducible. Pausing never redraws.

#### Time, ownership and reduced motion

- **One clock.** `pause` and `setSpeed` slow the body, the face, the
  floor and the tour together.
- **Who moves what.** The action owns the hips, legs and contacts; the
  emote overlay only turns the spine, chest and arms; the face runtime
  owns the head. The stage owns the root: the figure stays put, the floor
  scrolls, and a jump lifts the root by its arc. Ground contact is
  corrected from the pose every frame: a squat's soles are lowered toward
  the floor, a floor sit or a lying body is lowered onto it (its lowest
  point, never lifted), and a lying head is tipped up to clear it (see
  the limits below). A host running `makeGroundContact` itself passes
  `contact.apply(actions.state(), seconds)`.
- **Reduced motion.** Pass `reduceMotion` (and call `setReducedMotion` when
  the preference changes). Rests and lying moods cut straight to the held
  pose and back, with no fall. A jump is a small hop, emotes play no body
  gesture and a quieter posture, and the camera cuts between views. The
  tour never starts on its own; minimoji.com/body leaves it to a click.

#### Without the stage

**Expressive movement.** The web stage enables gait-aware mood motion by
default (`moodMotion: false` disables it at mount). Sad, crying, joy/happy,
angry/furious and scared have walk/run styles; other moods use the existing
overlay. The styles add only spine, chest and arm rotations. Joy adds torso
motion only, preserving the existing hand gesture. Neither movement speed,
clip rate, legs, root nor jump arc changes. An explicit cue accents the
torso; a silent mood change does not. Reduced motion immediately removes
the added swing modulation, bob, shoulder drive, shiver and cue accent,
keeping a smaller static posture. The website's **Expressive movement**
button compares the enabled and previous overlays.

The existing playable `dev/body-sandbox.html` uses the same production
styles through its own input/scene adapter. The native Body
product below consumes the same generated styles and real-JS parity oracle.

`@minimoji/engine/body` runs the same lifecycle on any rig. Hand
`createBodyActions(player)` a clip player, `{ play(name, { fade, once,
rate, at }), has(name), duration(name), update(dt), stop() }`, over a
rig that carries the KayKit clip names. The controller starts in `idle`;
`initial` may name another standing action (anything else falls back to
idle). Join it to the emote coordinator and drive all of it, and the
face, from one clock:

```js
import { createBodyActions, createEmoteCoordinator, makeBodyApplier, createBodyPerformer } from '@minimoji/engine/body';
import { createGlossFace, hangHair } from '@minimoji/engine';

const face = createGlossFace(built, { emotes: false });   // the coordinator cues the face itself
const actions = createBodyActions(player);
const emotes = createEmoteCoordinator({ face, body: makeBodyApplier(bones, figure), random });
const body = createBodyPerformer({ actions, emotes, random });

body.act('sit');
body.emote('sleeping');      // an explicit emote: a performance; dead and sleeping lie down
body.setMood('happy');       // a held mood, silently: no new performance, no variation drawn;
                             // a cue already playing keeps playing to its end
body.cancel();               // stop the cue playing (the body gesture fades); the mood stays

let faceClock = 0;
function frame(dt) {
  const seconds = body.update(dt);   // steps the player (your mixer) by the scaled time: do not step it elsewhere
  faceClock += seconds;
  const s = face.update(faceClock, seconds), head = built.head;
  head.position.set(s.x, head.userData.restY + s.y, 0); head.rotation.set(s.pitch, s.yaw, s.rot);
  hangHair(built);
  figure.position.y = floorY + actions.elevation();   // the jump's arc is the host's to apply
  floorOffset += actions.groundSpeed() * seconds;     // scroll the floor by it, or move the figure: never both
}

// on the host's unmount: stop scheduling frames first, then the cue, then the body
function unmount() {
  cancelAnimationFrame(raf);   // your loop: nothing calls frame() again
  body.cancel();               // ends the face's cue the coordinator started
  body.dispose();              // stops the controller and the player's clips (later act() calls do nothing)
  // then release your own scene: remove the figure, dispose geometries, materials, the renderer
}
```

To opt a custom host into mood motion, import `createMoodMotion`,
`measureProfiles` and `makeMoodMotionBody` from `@minimoji/engine/body`.
Measure the minibody once before overlays, then create one evaluator per
figure with `{profiles: measureProfiles(mb), intensity: 1}`. Wrap the
existing body applier with `makeMoodMotionBody({applier, mb, motion,
state: () => ({...emotes.state(), action: actions.state().action,
step: actions.state().step})})` and pass that
sink to the coordinator. Pass the same `motion` to `createBodyPerformer`
so reset clears its weights. The wrapper combines both poses before one
applier call; it preserves any extra host channels.

Intensity is finite and clamped to 0–1; zero preserves the previous
output exactly. A custom player without `clipPhase` or gait profiles
keeps the previous overlay. Profiles are plain mean-centered radian
arrays, reusable between figures using the same rig. Their measurement
uses a separate mixer and restores the sampled bones; it does not stop a
live clip or consume emote randomness.

`mb.clipPhase(name)` reads the actual normalized phase after that frame's
mixer update. Among an original clip and its replay twin it chooses the
highest effective weight; exact ties prefer the current action, then the
original. A paused contributing action keeps its phase. Missing, stopped
or zero-weight clips return `null`; a gait without a finite phase or a
profile contributes no style delta. No additional oscillator runs.
The evaluator takes the coordinator's scaled absolute time, mood/event,
action/step and reduced-motion state, and returns six figure-frame
rotation deltas in radians. Shared `MOTION_STYLES` and `MOTION_ENVELOPE`
exports are the declarative source for platform ports.

`built` is a `buildGloss` head mounted on the rig, `bones` the rig's
humanoid bones by name (`spine`, `chest`, `leftUpperArm`…) and `figure`
its root. `body.reset()` is neutral idle at once; `body.state()` adds the
`mood` (and the `restingMood` a hit will return to) to the controller's
state.

#### Known limits

- Mood reads mostly in the face while running, crouching, jumping or
  resting: the body keeps the action's silhouette there.
- Duck uses a dedicated planted held crouch authored from the landing pose.
  Actual jump landings retain their original clip. The accepted 192-case
  transition sweep stays within the 0.015 contact target in normal and reduced
  motion; this does not establish physical-device performance or broader visual acceptance.
- Long hair is rigid: on a lying figure it does not drape on the floor.
- The contact correction is bounded: a stride's flight is left alone, and
  a sole may still sit slightly above or below the floor mid-step.
- A standing figure keeps a dead or sleeping face after an action gets it
  up, by design; choose another emote to change it.

### Native fixed face surfaces

The native fixed-surface correction preserves the bake's existing `meta.faces`
animation membership. All visible non-body parts still have render nodes;
parts outside that membership use the existing `headFixed` flag and retain
their authored transforms. Explicit fixed flags remain fixed. Fragment
composition carries membership from surviving base parts and replacement
fragment parts instead of promoting every visible mesh into animation.

This keeps the monkey's cream face patch attached during gaze and expressions
in Metal head/body and RealityKit. The approved eye materials, geometry and
serialized bake contract are unchanged. Existing supported bakes need only
the corrected runtime; no rebake is needed for this fix. Available in MinimojiKit **v9.23.0** after PR163. TestFlight 1.0(15) predates this correction.

### D.A.S.H. screen effects

Available in MinimojiKit **v9.22.0** and the website after PR162.
Curved, screen-local decorations use the existing face clock: dead uses
interlace/static, scared uses short glitch accents, furious adds restrained
brightness disturbances, and thinking adds a moving scan. Neutral restores
the original display; other moods keep their existing emotion palette.
The effects leave the shell, glyph geometry and surrounding scene unchanged.

`life.screenEffects()` in JavaScript and `FaceLife.screenEffects` in Swift
return `[elapsed, dead, scared, furious, thinking, enabled]`, or null/nil for
organic heads. Elapsed is relative to the latest face change or explicit cue,
derived from the existing face clock. Pause freezes it; repeated cues reset
its phase, and native head replacement preserves it. No extra timer or RNG
source is used. `setScreenEffectsEnabled(false)` hides the layer immediately.
Reduced-motion hosts call this setter without changing the selected face or
palette. The React component observes the OS preference for this screen
layer; it does not change existing organic beat, blink or drop behavior.

New bakes carry optional `screenFx: {index, scale}` metadata on 20 inset curved
strips. Metadata-free bakes keep their existing display. New bakes remain
collapsed on older runtimes, so applications need both the updated runtime
and a regenerated D.A.S.H. bake to display effects. Metal head/body and
RealityKit consume the same bounded opacity state; no page-wide filter is
required. TestFlight 1.0(15) does not include these later screen updates.

### D.A.S.H. emotion colors

Available in MinimojiKit **v9.20.0** and the website after PR160. TestFlight 1.0(15) predates this update.

Screen LED glyphs now communicate emotion with a semantic palette: anger/fury
red, sadness/crying blue, happiness/joy/laughter warm yellow, thinking purple,
worried peach, surprise pale gold, scared lavender, smug pink, sleeping dim
cool blue and dead subdued gray. Shells and accessories retain their theme;
neutral and idle restore the exact authored LED and glass colors. The glass
keeps its themed gain and remains dark enough for readable glyphs.

The existing eight-field `screenMood()` / `FaceLife.screenMood` contract is
unchanged. Additive `screenPalette()` / `FaceLife.screenPalette` supplies four
values: premultiplied linear RGB and weight. A zero weight means authored
colors. It shares the existing face transition and thinking clock; organic
heads return null/nil. The Swift `screenTint(rest:lit:mood:palette:)` helper
accepts this optional palette; omitting it preserves legacy gain-only math.
`FaceLife.screenMaterial` applies the tint from an immutable `MaterialPayload`
after instance overrides, preserving opacity and physical material settings.
It retains the glTF emissive factor and `KHR_materials_emissive_strength`
separately, reproduces the JS LED/glow intensity, and preserves zero emission.
The output emission is flattened for native rendering. Metal and RealityKit both consume it,
including shell-fixed glass and the Metal body-attached head.

This intentionally supersedes the earlier requirement to retain a theme's
LED hue during every non-neutral mood. No geometry, baked roles, body motion,
automatic performance selection, or independent effect clock is added.

### Native bodies in Swift (MinimojiKit v9.19.0+)

The optional `MinimojiBody` product renders the same capsule body and
motion pack through Metal. Add that product beside `MinimojiKit`; it does not
require the service or `MinimojiEngine`. The package links on iOS 17/macOS 15;
`MinimojiBodyView` requires iOS 18/macOS 15. The public v9.19.0 tag
contains this product. Physical iPhone performance validation remains pending. Head-only products do not depend on the body asset bundle.

```swift
import SwiftUI
import MinimojiKit
import MinimojiBody

@available(iOS 18, macOS 15, *)
struct FullMini: View {
  @StateObject private var mini: BodyController
  init(document: HeadDocument) throws {
    let controller = try BodyController(document: document)
    _mini = StateObject(wrappedValue: controller)
  }
  var body: some View {
    VStack {
      MinimojiBodyView(controller: mini).frame(width: 280, height: 340)
      Button("Walk and smile") { mini.act(.walk); mini.emote(.happy) }
      Button("Jump") { mini.act(.jump) }
      Button("Quiet mood") { mini.setMood(.sad, stance: false) }
      Button("Pause") { mini.pause() }
      Button("Resume at half speed") { mini.setSpeed(0.5); mini.resume() }
      Button("Reduce motion") { mini.setReducedMotion(true) }
    }
  }
}
```

Create the controller once. Commands are events: `emote(.happy)` always
replays; `setMood(.happy)` changes the resting mood without starting or
cancelling a cue. `BodyCue` carries an explicit generation for state-driven
hosts. Use `availableActions` / `unavailableActions` for a custom pack,
`replay()`, `cancel()` and `reset()` for playback, and `state` for phase,
pending action, mood, event, ground speed and jump elevation. A hit restores
the resting mood only if no newer mood request has taken ownership. An explicit
action releases a dead/sleeping stance while leaving that face selected.

`BodyController` owns the body and face clock. Automatic mode clamps a long
frame gap to 0.1 seconds for both, freezes while the view is inactive, and
discards elapsed wall time when it resumes. Pause stops display scheduling.
A manual scene creates `BodyController(document: ..., clock: .manual)`, calls
`step(seconds:)` exactly once per frame, and draws with `BodyRenderer.encode`.
Manual mode uses the supplied delta without clamping. A mounted manual view
redraws on those steps and never advances itself. Do not additionally call
`MetalHead.tick`, `FaceLife.update` or `rig.update` on a controller-owned Mini.
The controller applies jump height once. Set `position` to the host’s floor
position and `yaw` to its facing; never add `state.elevation` to `position`.
Root travel is opt-in: the host can integrate `state.groundSpeed` itself.

Mood-aware walking/running is enabled by default. The style tables are generated
from the production JS source; the native evaluator reads the actual blended
clip phase and never changes hips, legs, travel, cadence or jump height. Use
`setMoodMotion(false)` for the baseline overlay or
`setMoodMotionIntensity(0...1)` for a bounded amount; settings survive reset and
avatar replacement. Nonfinite intensity updates are ignored. Reduced motion
removes the extra rhythm/accent immediately and keeps a smaller static posture.
Custom clip drivers without phase/profile support retain the baseline overlay.

Use `replace(document:)` for an avatar swap: the resting mood and only an
owning stance carry over, and the obsolete cue is cancelled. Set
`setActive(false)` for a hidden manual scene. Removing the SwiftUI view
releases its display delegate; call `dispose()` when the controller itself
is no longer needed. `onEventEnd` reports completed/cancelled body events.
The head’s existing cue callback remains available on `head.life`.

A host can use `head.performExternally(.headShake)` on the same body-owned
clock. Canceling the body reaction preserves a newer, distinct head gesture.
Before directly replaying the body reaction's **same** head gesture, cancel
that body reaction first: the face API exposes track identity, not individual
replay generations, so it cannot distinguish those two owners.

The default body camera fits baked head extents and reserves the canonical jump
height using a fixed standing envelope, so ears stay visible without changing
skull attachment size or zooming with each step. Explicit `encode` view and
projection matrices remain host-owned. Paused drawable resizing requests a
frame without advancing animation; hidden/inactive views submit no frames.

The actual Minimoji demo has **Home · Sandbox · Me** tabs. Sandbox
loads its saved avatar and colors independently of Me and refreshes after
Customize or Use avatar. Its cast picker never edits the saved profile.
Tab changes preserve playback controls while hidden/backgrounded body
views stop drawing and ticking; returning preserves an explicit pause.

Sandbox also exposes the canonical curated character collection through a
separate picker. `apps/minimoji-ios/gen-characters.mjs` serializes the production
`CharacterSelection` recipes through `src/bake/character.js`, with source, recipe
and asset hashes in `characters.generated.json`. Run that generator before
building the app; `--check` rebakes every entry for freshness. These are app-only
resources, outside the Kit and its head-only/Body products. Species metadata
carries `character` and `avatar: null`, never a fabricated human document.
The existing saved human profile and Customize flow remain independent.
Only the chosen character is decoded, on a serial queue with cancellation
checks and a one-entry cache; the same FaceLife and BodyController animate it.

The bundled schema-1 JSON/binary pack contains 21 clips, 23 joints and 5,442
vertices. Runtime body resources total 609,410 bytes including the retained
KayKit source notices; this excludes linked code and head resources. Assets
are tracked inside the package. Unsupported schemas, missing required bones
or idle, invalid references/weights, malformed offsets and nonfinite data
throw `BodyPackError`; the view reports rendering failures instead of silently
showing a bind pose. `BodyPack.shared` shares immutable data while each
controller owns its skeleton, overlays, clock and callbacks.

The same standing, rest, hit and jump semantics as the web are supported.
Native rendering uses one camera/depth pass for head and body. Long hair
remains rigid while lying; there is no cloth/hair physics, arbitrary-rig
import, Mixamo library or full-body RealityKit/AR support. Native device
performance and broader human mood-readability remain unmeasured; the release
and internal TestFlight availability do not imply those checks passed.

