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:
| 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?}:
idis one ofGET /capabilities'characters, each{id, name, family, kit}; any other id is a400that 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.picksets axes of the species' kit (kitisalien,dashornull;characterKitslists 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.
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
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.
| 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.
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:
| 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.
deadandsleepinglie 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
randomtomountBodyPlayground(a seeded generator) to make a run reproducible. Pausing never redraws.
Time, ownership and reduced motion
- One clock.
pauseandsetSpeedslow 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
makeGroundContactitself passescontact.apply(actions.state(), seconds). - Reduced motion. Pass
reduceMotion(and callsetReducedMotionwhen 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.