Docs

The avatar API

One origin serves every avatar: a document in, a versioned 3D head and a 2D picture out. Plain HTTPS and JSON — no SDK required. Start from a terminal with the CLI, or with curl; the Swift and React packages are for partners, by request. This page is the contract: the origin, the keys, the routes, what moves a URL, the errors.

Read as MarkdownLLM reference index

Quick start

From a terminal, anyone can start: npx minimoji login signs you in, npx minimoji generate "red curls, round glasses" makes and saves a head, and npx minimoji preview <avatarKey> opens it live. npx minimoji --help lists the rest, and the CLI and agent skills are public at hypersocialinc/minimoji-sdk.

Two ways in. A server holds a secret key and saves documents; an app holds a publishable key and plays heads. Get either at API keys.

From a server, with a secret key

curl -X POST https://striped-walrus-538.convex.site/avatars \
  -H "Authorization: Bearer mk_sec_…" \
  -H "content-type: application/json" \
  -d '{"doc":{"seed":4821,"head":"round","skin":"brown",
            "hair":{"style":"afro","color":"ink"}}}'
# → {"avatarKey":"5dd7…", "glb":"…/avatars/5dd7….glb", "png":"…/avatars/5dd7….png"}

<img src="https://striped-walrus-538.convex.site/avatars/<avatarKey>.png?size=128">

In a SwiftUI app, with a publishable key (Swift package for partners, by request)

// MinimojiKit (iOS 18): partners, by request
import MinimojiKit
import MinimojiMetal
import MinimojiService

let service = AvatarService(base: URL(string: "https://striped-walrus-538.convex.site")!, key: "mk_pub_…")
let head = try await service.head(for: ["seed": 4821, "head": "round", "skin": "brown"])
MinimojiMetalHeadView(document: head, expression: "happy").frame(width: 96, height: 96)
// the same head listening to music: a nod on the beat over any mood
MinimojiMetalHeadView(document: head, expression: "sad", listening: Listening(tempo: 120))
AsyncImage(url: service.renderURL(for: avatarKey, size: 256))

Not a person? Send a species in place of the document, {"character":{"id":"agent"}} for D.A.S.H., and it saves, bakes and draws the same way (Species). In Swift, service.head(for: CharacterSelection(id: "agent")).

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 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. Two kinds, the Stripe shape:

kindlivesmay
publishable mk_pub_…in an appbake 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 serverall 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

routeinout
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>.json200 the normalized document, or {character} for a species, Cache-Control: immutable
GET /avatars/<key>.glb?detail=.4&lite=0302 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=neutral302 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 bytes200 {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. 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.

modepicks whenfor a host that
cautiousthe candidate is at least 0.45would 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.30wants the face to follow the words, with few false picks
eagernone is not the most probable answerwants 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 fittexts with a mood pickedno-mood texts pickedthe 106 held-out texts: picks that fittexts with a mood pickedno-mood texts picked
cautious98%91%4% (7/162)97%87%8% (4/51)
balanced98%93%4% (7/162)97%89%8% (4/51)
eager97%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

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: AvatarService(base:key:), head(for:), save(_:), renderURL(for:size:). A host's agent starts from the package's 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 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.

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:

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

statevalueswhat it is
expressionneutral happy laugh sad angry furious surprised worried scared dead smug joy sleeping crying thinkingthe 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 nonewhere the eyes go, then the head; unset, the head glances about on its own
talkingon / off, or secondsa jittery jaw over any face; feed mouth (open / width / round) from audio for real lip-sync
talkStylemouth (default) · arcs · linesthe 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 nonea 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
thinkingon / offthe 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
presenceonline (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)
performancea source sampled every frame, or nonea 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 voicea 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.

MinimojiMetalHeadView(document: head, expression: "sad",
                      listening: Listening(tempo: 120, intensity: 0.6))
<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:

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):

MinimojiMetalHeadView(document: head, emotes: false,
                      listening: running ? Listening(tempo: bpm, notes: false) : nil,
                      performance: jam, isActive: onScreen)   // jam: AvatarPerformanceInput
<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:

var voice = SingingVoice()                      // keep it; it eases between frames
let mouth = voice.sample(dt: dt, target: touch == nil ? nil : rowOpen, held: heldSeconds, vowel: touchX)
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:

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))
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 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

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:

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

idkindbehaviour
idle walk run duck crouchWalkstandingcross-fade straight into each other. Walking and running are in place; the floor runs under the feet at the gait's speed
jumpone-shottakeoff, about 0.75 of clearance, a grounded landing, then back to the standing action that was asked for
sit lierestenter, hold until something else is asked for, get up, then do it
hitone-shota flinch (two alternating takes) and a short surprised, angry or scared face; then the earlier mood comes back (below)
standaliasidle: 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:

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.

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.