# AutoRig — Full API Reference for AI Agents
100% local auto-rigging for humanoid 3D models. This document is the canonical reference for AI agents (Claude, Cursor, Cline, Windsurf, etc.) calling the AutoRig service. Copy-paste the relevant sections into your prompt or use them as a system message.
**Base URL (production, SSD):** `https://rig.neuralworkshk.com`
**Base URL (staging, NVMe ~1.63× faster):** `https://rig2.neuralworkshk.com`
**Base URL (local dev):** `http://127.0.0.1:8421`
**MCP endpoint:** `/mcp/sse` (13 tools — includes `autorig_build_character_pack` for iOS fighting games; see MCP section)
**OpenAPI spec:** `/openapi.json`
**Swagger UI:** `/docs`
### Choosing a server
Both run **identical v2.7.0 code**. Pick based on workload:
- **rig.neuralworkshk.com** — prod SSD. Has all your historical jobs/rigs.
- **rig2.neuralworkshk.com** — NVMe. ~38% faster end-to-end rig, ~4× faster disk reads. Best for new heavy/parallel workloads.
- **Data is NOT synced** between them. A `job_id` from rig.* won’t exist on rig2.* and vice versa. Pick one server per character/pipeline.
- Both expose the same REST + MCP endpoints. AI agents can register both as parallel MCP servers and round-robin.
---
## Concepts
**Job ID (jid)**: every uploaded model gets a UUID. All subsequent calls reference this `jid`. Jobs auto-delete after 24 hours.
**Status states**: `preview` → `ready` → `rigging` → `done` (or `error` / `cancelled` at any point).
**Coordinate convention**: glTF 2.0 standard. `+Y` up, `-Z` forward, `+X` right. After P0 #2 (2026-05-20), exported GLBs have canonical hips: identity rotation, only Y translation, no baked rotation. Mesh forward = `-Z` in any viewer.
---
## Authentication
Open by default. If `AUTORIG_API_KEY` env var is set on the server, every `/api/*` route (except the public allow-list) requires header `X-API-Key: `.
**Public routes** (no auth):
- `GET /api/health`
- `GET /api/version`
- `GET /api/anim-list`
**MCP endpoint**: currently always open. Tunnel through a Cloudflare Worker or nginx basic-auth for additional protection.
Check current auth state:
```bash
curl https://rig.neuralworkshk.com/api/health
# {"status":"ok","version":"2.0","auth":"open"} # or "auth":"required"
```
---
## Workflows
### Workflow A — Minimum rig (5 calls, polling)
```bash
BASE=https://rig.neuralworkshk.com
# 1. Upload — returns job_id + light mesh stats
JID=$(curl -sk -F file=@character.glb $BASE/api/upload | jq -r .job_id)
# 2. Wait for preview ready
while [ "$(curl -sk $BASE/api/status/$JID | jq -r .status)" != "ready" ]; do sleep 2; done
# 3. Start rigging (default = standard quality, full fingers)
curl -sk -X POST -H "Content-Type: application/json" -d '{}' $BASE/api/rig/$JID
# 4. Wait for done
while [ "$(curl -sk $BASE/api/status/$JID | jq -r .status)" != "done" ]; do sleep 2; done
# 5. Download
curl -sk $BASE/api/result/$JID.glb -o rigged.glb
```
### Workflow B — With webhook (no polling)
```bash
JID=$(curl -sk -F file=@char.glb "$BASE/api/upload?callback_url=https://my.app/rig-done" | jq -r .job_id)
curl -sk -X POST -d '{}' $BASE/api/rig/$JID
# Your server receives POST at https://my.app/rig-done when complete:
# {"job_id":"...","status":"done","result_url":"/api/result/.../glb","timestamp":...}
```
### Workflow C — Retarget animations + export sequence
```bash
# Pick animations by tag
ANIMS=$(curl -sk "$BASE/api/anim-list?tag=combat&tag=punch")
# Retarget specific ones (just metadata, no heavy quaternion payload)
curl -sk -X POST -H "Content-Type: application/json" \
-d '{"names":["A_Hook_Punch","Cross_Punch"]}' \
"$BASE/api/anim/$JID?fields=name,duration,frame_count"
# Get merge timing meta (NO GLB, just clip [start, end])
curl -sk -X POST -H "Content-Type: application/json" \
-d '{"clips":[
{"name":"Idle","crossfade":0.3},
{"name":"A_Hook_Punch","crossfade":0.3},
{"name":"Walking","crossfade":0.3}
]}' \
$BASE/api/anim-merge-meta/$JID
# {"clips":[{"name":"Idle","start":0.0,"end":3.0,"duration":3.0}, ...], "total_duration":..., "fps":30}
# Export merged GLB (X-Clip-Timings header has the same meta)
curl -sk -X POST -H "Content-Type: application/json" \
-d '{"clips":[...same...]}' \
-D /dev/stderr -o merged.glb $BASE/api/anim-merge-export/$JID 2>&1 | grep -i clip-timings
```
### Workflow D — Export to all formats
```bash
curl -sk $BASE/api/result/$JID.glb -o rigged.glb # glTF binary
curl -sk $BASE/api/result/$JID.fbx -o rigged.fbx # ASCII FBX 7.4 (+ baseColor sidecar PNG in response dir)
curl -sk $BASE/api/usdz/$JID -o rigged.usdz # iOS RealityKit / AR Quick Look
```
---
## Endpoint Reference
### System
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/health` | `{status, version, auth}` (public) |
| GET | `/api/version` | `{version: "v2.7.0"}` (public) |
| GET | `/api/jobs` | List active jobs (truncated) |
| GET | `/api/status/{jid}` | Current job state |
`/api/status/{jid}` response:
```json
{
"status": "rigging", // preview | ready | rigging | done | error | cancelled
"progress": 45, // optional, 0..100
"filename": "char.glb",
"ext": ".glb",
"created": 1716000000.0,
"error": null
}
```
### Upload
`POST /api/upload` — multipart `file` field. Optional `?callback_url=https://...`.
Response:
```json
{
"job_id": "abc-1234-...",
"filename": "char.glb",
"format": "glb",
"upload_bytes": 248744,
"polygons": 5670,
"vertices": 5595,
"has_existing_rig": false,
"warnings": []
}
```
`polygons`/`vertices`/`has_existing_rig` are best-effort (GLB/OBJ/STL accurate; FBX/DAE skipped to keep response fast).
Supported uploads: `.fbx .obj .gltf .glb .dae .stl .3ds .x3d .ply .zip`. Max 200 MB.
### Joints (T-pose joint detection)
`GET /api/joints/{jid}` — default returns legacy `[{id, label, x, y, z}, ...]`.
`GET /api/joints/{jid}?details=true`:
```json
{
"joints": [
{"id":"head", "label":"Head Top", "x":0.0, "y":0.0, "z":0.845, "confidence":0.92},
...
],
"overall_quality": "good", // good | marginal | poor
"warnings": []
}
```
Quality buckets (based on `min` and `avg` of per-joint confidence):
- `good`: min ≥ 0.7 AND avg ≥ 0.85
- `marginal`: min ≥ 0.4 AND avg ≥ 0.6
- `poor`: otherwise
Confidence = avg(inside_mesh_proximity, lr_symmetry, anatomical_z_ordering). Coarse heuristic — catches gross failures, not subtle misplacements.
### Rig
`POST /api/rig/{jid}` — strongly typed Pydantic body:
```json
{
"mode": "standard", // "standard" | "template"
"quality": "standard", // "fast" | "standard" | "high"
"finger_mode": "full", // "none" | "simple" | "full"
"poly_mode": "desktop", // "mobile" | "desktop" | "full" | "original"
"rig_version": "v2", // "v1" | "v2"
"skin_method": "heat", // "heat" | "distance"
"joints": null // optional override array; null = use auto-detected
}
```
Returns `{"status":"rigging"}`. Poll `/api/status/{jid}` or use webhook.
Optional `?callback_url=https://...` overrides upload-time webhook.
### Re-rig
`POST /api/rerig/{jid}` — modify joint positions, re-skin without re-uploading:
```json
{
"bone_edits": {"head": {"hx":0.0, "hy":0.0, "hz":0.86}},
"quality": "standard",
"rig_version": "v2",
"skin_method": "heat"
}
```
### Animations
| Endpoint | Description |
|----------|-------------|
| `GET /api/anim-list` | Plain string list (back-compat) |
| `GET /api/anim-list?tag=combat&tag=punch` | AND filter, returns `[{name,tags}, ...]` |
| `GET /api/anim-list?details=true` | Always-enriched form |
| `POST /api/anim/{jid}` | Retarget on demand |
| `POST /api/anim-merge/{jid}` | Merge w/ crossfade — returns JSON |
| `POST /api/anim-merge-meta/{jid}` | Clip timing meta only (NO GLB) |
| `POST /api/anim-merge-export/{jid}` | Merge + export GLB (includes `X-Clip-Timings` header) |
| `POST /api/anim-pack-glb/{jid}` | GLB with N independently named glTF animations (no merge — for Three.js / Unity / Unreal / Godot / NeuralForge runtime clip selection) |
Anim tags vocabulary:
- **Movement**: locomotion, walk, run, jog, jump, crouch, crawl, climb, swim, turn, strafe
- **Posture**: idle, sit, lie, sleep, pose
- **Combat**: combat, punch, kick, block, dodge, hit_react, death, boxing, capoeira
- **Weapon**: weapon, sword, rifle, pistol, bow, shoot, reload, aim
- **Dance**: dance
- **Sport**: sport, stretch
- **Gesture**: gesture, wave, nod, point, clap, salute, bow, talk, laugh, cry, victory
- **Other**: zombie, magic
`POST /api/anim/{jid}` body:
```json
{"names": ["Walking", "A_Hook_Punch"]}
```
Response (default — full):
```json
{
"animations": [
{
"name": "Walking",
"duration": 1.2,
"frame_count": 36,
"bone_rotations": {"hips": [[0,0,0,1], ...], "spine": [...], ...}
}
]
}
```
With `?fields=name,duration,frame_count` — skip the heavy `bone_rotations` payload:
```json
{"animations": [{"name":"Walking", "duration":1.2, "frame_count":36}]}
```
Max 50 names per call.
`POST /api/anim-merge-meta/{jid}` body and response:
```json
// Request
{"clips":[{"name":"Idle","crossfade":0.3},{"name":"Walking","crossfade":0.2}]}
// Response
{
"clips":[
{"name":"Idle","start":0.0,"end":3.0,"duration":3.0},
{"name":"Walking","start":2.7,"end":3.9,"duration":1.2}
],
"total_duration": 3.9,
"fps": 30
}
```
`POST /api/anim-merge-export/{jid}` returns GLB binary + `X-Clip-Timings` header (same JSON as above).
`POST /api/anim-pack-glb/{jid}` returns a single GLB containing the rigged mesh + skeleton + **N independently named** glTF Animation entries (no merging, no crossfade — clips stand alone for engine runtime selection):
```json
// Request
{
"names": ["Bouncing_Boxing_Idle","Walking","A_Hook_Punch","Cross_Punch","Jump","Hit_React","Death"],
"rename": {
"Bouncing_Boxing_Idle": "Idle",
"A_Hook_Punch": "LightPunch",
"Cross_Punch": "HeavyPunch",
"Hit_React": "Hit",
"Death": "Die"
}
}
// Response: model/gltf-binary + X-Anim-Clips header
// X-Anim-Clips: [{"name":"Idle","duration":2.23}, ...]
```
Three.js validation:
```js
const gltf = await new GLTFLoader().loadAsync(url);
console.log(gltf.animations.map(a => a.name));
// ["Idle","Walking","LightPunch","HeavyPunch","Jump","Hit","Die"]
mixer.clipAction(gltf.animations.find(a => a.name === "LightPunch")).play();
```
This is the **glTF equivalent** of `/api/character-pack/{jid}` (which writes named UsdSkel.Animations in USDZ). Use this for engines that consume glTF natively (Three.js, Babylon.js, Godot, Unity / Unreal glTF importers, NeuralForge); use the USDZ pack for iOS RealityKit.
### Exports
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/result/{jid}.glb` | Rigged GLB (canonical orientation, T-pose, no animations) |
| GET | `/api/result/{jid}.glb?edited=1` | GLB with texture edits + attachments baked in |
| GET | `/api/result/{jid}.fbx` | ASCII FBX 7.4 + sidecar baseColor PNG |
| GET | `/api/usdz/{jid}` | USDZ for iOS / RealityKit / AR Quick Look |
| POST | `/api/anim-pack-glb/{jid}` | GLB with N independently named glTF animations (Three.js / Babylon / Godot / Unity / Unreal) |
| POST | `/api/character-pack/{jid}` | ZIP: USDZ (skeleton + skin + materials + N named UsdSkel.Animations) + JSON sidecar for iOS fighting games |
#### Character pack body
Produces a **single-file iOS RealityKit fighting-game character** per call.
Output ZIP is spec-compliant — validates against the iOS team's section-5
validator script.
**Request body**:
```json
{
"char_id": "aether_k",
"action_map": {
"idle": "Bouncing_Boxing_Idle",
"walk_forward": "Walking",
"jump": "Jump",
"block": "Block_Idle_Pose",
"hit": "Hit_React",
"victory": "Post_Putting_Emotional_Victory_Gesture",
"light_punch": "A_Hook_Punch",
"heavy_punch": "Cross_Punch",
"light_kick": "Capoeira_Front_Kick",
"heavy_kick": "A_Roundhouse_Kick_To_The_Side_Of_An_Opponent"
},
"metadata": {
"displayName": "Aether-K",
"tagline": "Balanced Rushdown",
"primaryColor": [0.95, 0.30, 0.20]
}
}
```
- `action_map` keys: snake_case **game actions** (NOT Mixamo anim names). Values:
any Mixamo anim name from `/api/anim-list`.
- `char_id`: lowercase snake_case (used as folder name + JSON `id` field). Default = `jid[:8]`.
- `metadata`: optional overrides for the JSON sidecar. Anything not provided
uses sensible defaults (see "JSON sidecar fields" below).
**Required action_map keys** (10 — JSON validator enforces these all present):
```
idle, walk_forward, jump, block, hit, victory,
light_punch, heavy_punch, light_kick, heavy_kick
```
**Optional action_map keys** (4):
```
knockdown, get_up, projectile, uppercut
```
**Missing actions auto-fallback** to similar anims so the validator passes.
Fallback chains (e.g.):
- `block` missing → falls back to `idle` (visual freeze)
- `light_kick` missing → falls back to `heavy_kick` → `light_punch` → `idle`
- `walk_forward` missing → `walk_back` → `run` → `idle`
The JSON's `_actionsFallback` debug field lists which actions were satisfied
via fallback so iOS can detect and (optionally) compensate.
#### Response
`application/zip`, content-disposition `attachment; filename="_pack.zip"`.
Typical size: ~5-8 MB.
ZIP layout (matches iOS team's spec section 2):
```
characters//.usdz ← UsdSkel skeleton + skinned mesh + materials
+ N named UsdSkel.Animation prims
(canonical PascalCase names: Idle, Walk,
LightPunch, HeavyPunch, LightKick,
HeavyKick, Block, Hit, Victory, Jump,
Knockdown, GetUp, Projectile, Uppercut)
characters//.json ← spec section 4 JSON sidecar:
id, displayName, tagline, primaryColor,
secondaryColor, weightClass, stats,
animations{action: {clip, loop, speed}},
hitboxes (defaults for 4 base attacks),
hurtbox, plus debug fields
(_actionsIncluded, _actionsFallback,
_animClipsInUsdz)
```
#### JSON sidecar fields
| Field | Default | Notes |
|-------|---------|-------|
| `id` | `` | Matches folder name. Validator checks. |
| `displayName` | `` | Shown in character-select UI |
| `tagline` | "Auto-rigged character" | Override via metadata |
| `heightMeters` | 1.8 | Mesh height (validator informational only) |
| `primaryColor` | `[0.50, 0.50, 0.95]` | Linear RGB 0-1 for HP bar / UI |
| `secondaryColor` | `[0.95, 0.95, 0.95]` | Accent color |
| `weightClass` | `"medium"` | `"light"` / `"medium"` / `"heavy"` |
| `stats.maxHP` | 100 | HP |
| `stats.walkSpeed` | 2.5 | m/s |
| `stats.jumpHeight` | 3.2 | m peak |
| `stats.knockbackResistance` | 1.0 | 0.5-1.5 multiplier |
| `animations..clip` | canonical name | UsdSkel.Animation prim name (case-sensitive) |
| `animations..loop` | derived | true for idle/walk/block/victory; false otherwise |
| `animations..speed` | 1.0 | 1.2 for jump+light_punch, 1.1 for light_kick |
| `hitboxes.light_punch` | spec defaults | offsetMeters, sizeMeters, activeFrames, damage, hitstunFrames |
| `hitboxes.heavy_punch` | spec defaults | (same) |
| `hitboxes.light_kick` | spec defaults | (same) |
| `hitboxes.heavy_kick` | spec defaults | (same) |
| `hurtbox` | offset=[0,1.0,0], size=[0.7,2.0,0.6] | Hittable volume |
You can override any nested field via `metadata` (deep merge — only paths you
provide are replaced, rest stays default):
```json
{
"metadata": {
"stats": {"maxHP": 120, "walkSpeed": 3.0},
"hitboxes": {"light_punch": {"damage": 7}}
}
}
```
#### iOS RealityKit usage
```swift
let url = Bundle.main.url(
forResource: characterID,
withExtension: "usdz",
subdirectory: "Models3D/characters/\(characterID)"
)!
let entity = try await Entity.load(contentsOf: url)
let anims = entity.availableAnimations // ["Idle", "Walk", "LightPunch", ...]
// Look up the canonical clip name from JSON, NOT the action name
let meta = try JSONDecoder().decode(CharacterMeta.self,
from: Data(contentsOf: jsonURL))
let idleClipName = meta.animations["idle"]!.clip // "Idle"
let idleClip = anims.first { $0.name == idleClipName }!
entity.playAnimation(idleClip, transitionDuration: 0.15, startsPaused: false)
```
#### Validation
Run the spec's section-5 validator after building (we ship a compatible
implementation at `tools/validate_character_pack.py`):
```bash
unzip aether_k_pack.zip -d out/
python3 tools/validate_character_pack.py out/
# → PASS aether_k: USDZ has 1 mesh, N anims (Idle, Walk, ...), 4 hitboxes
```
#### Pitfalls / known limitations
1. **No root motion** — character animates in place; game engine drives
translation (matches fighting-game convention).
2. **`preview.png` not generated** — spec marks it optional; we don't render it.
Provide your own and add to the ZIP if needed.
3. **Anims must already retarget** — if you call this immediately after rigging
and an anim isn't in the cache, the endpoint retargets on-demand
(~5-10 ms per anim with C accel). For 10 anims expect ~8-12s end-to-end.
4. **`metadata` deep merge** — `hitboxes`, `stats`, `animations` blocks are
merged path-by-path; you don't need to repeat the whole structure to
override a single value.
5. **Spec section 3a coordinate system** is satisfied because P0 #2 fix bakes
canonical hips into the rigged GLB. Mesh forward = `-Z`, up = `+Y`.
### Texture Editor
| Method | Path | Description |
|--------|------|-------------|
| GET / POST | `/api/texture-edit/{jid}` | Per-material HSB + tint |
| POST | `/api/texture-replace/{jid}/{material_key}` | Upload new image (.png/.jpg/.webp, 10MB) |
| GET | `/api/texture-replace/{jid}/{material_key}/image` | Serve current replacement |
| DELETE | `/api/texture-replace/{jid}/{material_key}` | Restore original |
### Attachments (weapons / props on bones)
6 predefined sockets:
| Socket | Bone | Use |
|--------|------|-----|
| `hand_R` | hand.R | Weapon, tool |
| `hand_L` | hand.L | Shield, secondary |
| `back` | chest | Backpack, large weapon |
| `hip_R` | thigh.R | Holster |
| `hip_L` | thigh.L | Holster |
| `head` | head | Hat, helmet |
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/attachment/sockets` | List of predefined sockets |
| GET | `/api/attachment/{jid}` | Current attachments |
| POST | `/api/attachment/{jid}` | Upload item (multipart `file` + form `socket`) — .glb/.gltf/.fbx/.stl/.obj/.dae, 50MB |
| PUT | `/api/attachment/{jid}/{idx}` | Adjust offset_pos / offset_rot_euler / scale |
| DELETE | `/api/attachment/{jid}/{idx}` | Remove |
| GET | `/api/attachment/{jid}/{idx}/item` | Serve attachment file |
Attachments bake into the exported GLB via `?edited=1`.
### AI Chat
| Endpoint | Description |
|----------|-------------|
| GET `/api/ai/providers` | List configured providers |
| POST `/api/ai/chat` | Send chat turn |
Configure providers via env vars: `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `XAI_API_KEY`, `GEMINI_API_KEY`, or run Ollama locally (auto-detected at `http://localhost:11434`).
---
## MCP (Model Context Protocol) Integration
Mounted at `/mcp/sse`. Add to your AI client config:
**Claude Code** (`~/.claude.json` or `claude_desktop_config.json`):
```json
{
"mcpServers": {
"autorig": {
"url": "https://rig.neuralworkshk.com/mcp/sse"
}
}
}
```
**Cursor** (`Settings → MCP`):
```json
{
"mcpServers": {
"autorig": {
"url": "https://rig.neuralworkshk.com/mcp/sse"
}
}
}
```
### 13 MCP tools
| Tool | Maps to |
|------|---------|
| `autorig_health` | GET /api/health |
| `autorig_upload` | POST /api/upload (multipart) — server reads from `file_path` argument |
| `autorig_get_status` | GET /api/status/{jid} |
| `autorig_get_joints` | GET /api/joints/{jid}?details=true |
| `autorig_rig` | POST /api/rig/{jid} |
| `autorig_wait_for_done` | Polls status until terminal state (convenience) |
| `autorig_list_anims` | GET /api/anim-list?tag=...&details=true |
| `autorig_retarget` | POST /api/anim/{jid}?fields=duration,frame_count |
| `autorig_merge_meta` | POST /api/anim-merge-meta/{jid} |
| `autorig_export_glb` | GET /api/result/{jid}.glb |
| `autorig_export_fbx` | GET /api/result/{jid}.fbx |
| `autorig_export_usdz` | GET /api/usdz/{jid} |
| `autorig_build_character_pack` | POST /api/character-pack/{jid} (saves ZIP to disk) |
| `autorig_export_glb_with_clips` | POST /api/anim-pack-glb/{jid} (saves GLB with N named clips to disk) |
Note: `autorig_upload` accepts `file_path` argument. Server reads from local disk — works when AI client runs on the same host as the AutoRig server. For remote use, upload via REST POST or fetch from an HTTP URL.
---
## Python SDK example (minimal)
```python
import requests, time
BASE = "https://rig.neuralworkshk.com"
# Upload
with open("char.glb", "rb") as f:
r = requests.post(f"{BASE}/api/upload", files={"file": f}).json()
jid = r["job_id"]
print(f"Job {jid}: {r['polygons']} polys, {r['vertices']} verts")
# Wait for preview ready
while requests.get(f"{BASE}/api/status/{jid}").json()["status"] != "ready":
time.sleep(2)
# Check joint confidence
joints = requests.get(f"{BASE}/api/joints/{jid}?details=true").json()
print(f"Quality: {joints['overall_quality']}")
if joints['overall_quality'] == 'poor':
raise SystemExit("Joint detection failed — model may not be humanoid")
# Rig
requests.post(f"{BASE}/api/rig/{jid}", json={"quality": "standard"})
# Wait for done
while requests.get(f"{BASE}/api/status/{jid}").json()["status"] != "done":
time.sleep(2)
# Find combat anims, retarget metadata only
combat_anims = requests.get(f"{BASE}/api/anim-list?tag=combat&tag=punch").json()
names = [a["name"] for a in combat_anims[:3]]
requests.post(f"{BASE}/api/anim/{jid}?fields=duration,frame_count",
json={"names": names})
# Export USDZ for iOS
usdz = requests.get(f"{BASE}/api/usdz/{jid}").content
with open("rigged.usdz", "wb") as f:
f.write(usdz)
```
### Character pack (iOS fighting game) — full end-to-end example
```python
import requests
BASE = "https://rig.neuralworkshk.com"
# Assume jid is a rigged job in status="done"
jid = "..."
# Pick your animations (game action → Mixamo anim name)
action_map = {
"idle": "Bouncing_Boxing_Idle",
"walk_forward": "Walking",
"jump": "Jump",
"block": "Block_Idle_Pose", # may not exist; will fall back to idle
"hit": "Hit_React",
"victory": "Post_Putting_Emotional_Victory_Gesture",
"light_punch": "A_Hook_Punch",
"heavy_punch": "Cross_Punch",
"light_kick": "Capoeira_Front_Kick",
"heavy_kick": "A_Roundhouse_Kick_To_The_Side_Of_An_Opponent",
}
r = requests.post(
f"{BASE}/api/character-pack/{jid}",
json={
"char_id": "aether_k",
"action_map": action_map,
"metadata": {
"displayName": "Aether-K",
"tagline": "Balanced Rushdown",
"primaryColor": [0.95, 0.30, 0.20],
"stats": {"maxHP": 110, "walkSpeed": 2.8},
},
},
)
r.raise_for_status()
with open("aether_k_pack.zip", "wb") as f:
f.write(r.content)
print(f"Saved {len(r.content) / 1e6:.1f} MB pack to aether_k_pack.zip")
# Inspect what made it in:
import zipfile, json
with zipfile.ZipFile("aether_k_pack.zip") as z:
meta = json.loads(z.read("characters/aether_k/aether_k.json"))
print("Included:", meta["_actionsIncluded"])
print("Fallbacks:", meta.get("_actionsFallback", {}))
print("USDZ clips:", meta["_animClipsInUsdz"])
```
Drop the resulting ZIP into your iOS app's `Models3D/` folder. The validator at
`tools/validate_character_pack.py` confirms section 5 compliance.
---
## Error codes
| HTTP | Meaning | Action |
|------|---------|--------|
| 400 | Bad request — missing field, invalid value, oversized file | Check body schema in `/openapi.json` |
| 401 | Missing or invalid `X-API-Key` (only if auth enabled) | Set `X-API-Key` header |
| 404 | Job not found (may have expired after 24h) | Re-upload |
| 422 | Pydantic validation failed | Fix body shape — see error.detail |
| 500 | Internal error | Check `/api/health`; report with `jid` and request |
| 503 | Subsystem unavailable (e.g. AI chat without httpx) | Server config issue |
---
## Common pitfalls
1. **Default `User-Agent: python-urllib/*` may be blocked by Cloudflare.** Set a custom UA: `headers={"User-Agent": "MyApp/1.0"}`.
2. **Re-rigged jobs from before 2026-05-20 have non-canonical hips rotation.** Re-rig once to get identity hips. Anything rigged AFTER is glTF-canonical (mesh faces -Z).
3. **`POST /api/anim/{jid}` default response is heavy** (~100KB per animation). Always pass `?fields=duration,frame_count` if you don't need rotations.
4. **Job auto-deletes after 24h.** Save output GLB locally if you need it later.
5. **Webhook URL must be reachable from VPS** (public Internet). `localhost` / `192.168.*` / `10.*` etc. are rejected by SSRF guard.
6. **`mocap/cache_glb/`** must exist on the server for fast animation retarget. Without it, fallback to FBX path (slower but works).
7. **MCP `/mcp/sse` via Cloudflare may need nginx SSE-friendly headers** (`proxy_buffering off; proxy_http_version 1.1; proxy_set_header Connection "";`). Local dev works without config.
---
## Performance characteristics
- Upload: ~1 second per 10 MB
- Preview generation: ~3-5 seconds
- Rig (standard quality, heat skinning): ~30-60 seconds for typical 5000-poly model
- Animation retarget: ~5 ms per anim with C extension (was 100 ms in pure Python)
- USDZ conversion: ~5 seconds
- FBX export: ~3 seconds for 35-bone skeleton
Total upload-to-rigged for a typical character: **~40 seconds**.
---
## Version history
- **v2.7.0** (2026-05-20): Canonical hips orientation, USDZ working, FBX export, anim tags, joint confidence, MCP wrapper, webhook callback, paint-on-model, texture extract, IK + onion skinning
- **v2.0** (2026-05-18): Texture editor, weapon attachments, API auth + Unity/Python SDK, AI chat L1
- **v1.0** (2026-05-17): Stable baseline, GLB animation cache, C extension retarget (19.82× speedup), USDZ stubs
---
## Contact
- GitHub: (private repo)
- Issues: file via your AI agent's session
Last updated: 2026-05-20