AVSTAR — Public API v1
HTTP API for using published AVSTAR models and attaching portfolio imagery from other services. This documentation is public; no account is required to read it.
Base URL (production): https://avstar.mother.tech
Base URL (local dev): http://localhost:3030
Base URL (Teenage, Tailscale required): https://teenage.tail382540.ts.net:8473
Versioning: All endpoints are mounted under /api/v1/. Breaking changes go to /api/v2/; v1 stays stable.
Stability: Field additions are non-breaking. Field removals or renames are breaking.
Authentication
Only models that staff have made available appear in the API. Drafts, queued jobs and failed generations are excluded. Requests for an unpublished model return 404, including portrait and sheet requests.
The API has separate read and upload permissions:
The web catalogue, published model pages, traits, references and lineage are also public: visitors can browse without signing in. Bearer keys govern programmatic JSON access and uploads; they do not make the published catalogue private. Drafts and staff operations remain restricted.
Keys are issued by the AVSTAR operator. Each key has a list of scopes; v1 read access requires avstars:read.
Issuing a key — preferred path is the admin UI at /admin/access (issue, revoke, audit, last-used tracking). CLI fallback for ops:
pnpm api:keys:create <consumer-name>
# → prints the plaintext key ONCE. Save it. The DB only stores the hash.
Listing / revoking via CLI:
pnpm api:keys:list
pnpm api:keys:revoke <id>
Lost keys must be revoked and re-issued — they cannot be recovered.
Errors
Every error response has the shape:
{
"error": {
"code": "unauthorized",
"message": "missing or malformed Authorization header",
"request_id": "0a6479e7-862a-467e-bceb-81601eee3eb0"
}
}
Every response (success or error) echoes its request_id in the X-Request-ID header.
Pagination
List endpoints use cursor pagination, newest first.
limit— default 25, max 100.cursor— opaque base64url string returned inpage.next_cursorof the previous page. Whennext_cursorisnull, you've reached the end.
Cursors are stable across new inserts at the head of the list.
Portfolio images
Portfolio images are finished work featuring a model. They appear in the model gallery, and remain separate from the identity references in media and views. Uploading does not change DNA, lineage or the Use model prompt.
Admins can issue Read models + upload portfolio images keys under /admin/access. An upload key can add images to any available model; it cannot change identity references, publish drafts, or remove images.
POST /api/v1/avstars/:id/portfolio
Send multipart/form-data, one image per request. Required scope: avstars:media:write.
The original image is retained. A smaller WebP preview is generated. Images appear immediately on available models; unpublished models return 404. Staff can attach images to drafts using the app.
curl -X POST "$AVSTAR_URL/api/v1/avstars/$MODEL_ID/portfolio" \
-H "Authorization: Bearer $AVSTAR_API_KEY" \
-H "Idempotency-Key: shoot-2026-image-001" \
-F 'file=@editorial.jpg' \
-F 'caption=Autumn editorial' \
-F 'credit=Studio name' \
-F 'project_url=https://example.com/editorial'
Use a unique Idempotency-Key per image (1–128 letters, digits, periods, hyphens, underscores or colons). Retrying the same key for the same model returns the existing image instead of adding a duplicate, even for concurrent requests. Do not reuse a key for different work.
Response: 201 when added, 200 for a repeated upload key.
{
"id": "media-uuid",
"url": "/api/blob/original-uuid",
"thumbnail_url": "/api/blob/preview-uuid",
"caption": "Autumn editorial",
"credit": "Studio name",
"project_url": "https://example.com/editorial",
"sort_order": 0
}
For production uploads, use this direct-upload protocol so the file does not pass through Vercel's 4.5 MB request-body limit. It supports the full 10 MB per-image allowance:
Treat returned media URLs as opaque and resolve relative URLs against the API origin. Follow redirects: Cloudflare delivery uses temporary signed URLs, while the API URLs remain stable. Published image and preview URLs require no API key.
GET /api/v1/avstars/:id/portfolio
Requires avstars:read. Returns { "data": [...] } in gallery order. The model-detail endpoint also includes a portfolio array with the same objects; its existing media and views fields continue to describe identity references.
Edit metadata, reorder and remove portfolio images through Model → ⋯ → Add images in the staff UI.
Endpoints
GET /api/v1/avstars
Paginated list of AVSTAR summaries.
Query params
| Name |
|---|
Response 200
{
"data": [
{
"id": "69db4d28-b753-4e2f-8aef-34d65fd4938a",
"display_name": "Giorgi Nino",
"signature": "The way their fingers unfurl…",
"generation": 1,
"source": "imprint",
"hero_url": "/api/v1/avstars/69db4d28-…/portrait?role=hero",
"created_at": "2026-04-23T03:38:53.000Z"
}
],
"page": {
"next_cursor": "eyJjcmVhdGVkX2F0Ijoi…",
"limit": 25
}
}
hero_url is nullable for compatibility. Publishing a new model requires a completed editorial portrait.
GET /api/v1/avstars/:id
Full public dossier for one avstar.
Path params
| Name | Type | Notes |
|---|---|---|
id | uuid | Returns 400 if not a uuid, 404 if not found |
Response 200
{
"id": "69db4d28-…",
"display_name": "Giorgi Nino",
"signature": "…",
"generation": 1,
"source": "imprint",
"hero_url": "/api/v1/avstars/69db4d28-…/portrait?role=hero",
"created_at": "2026-04-23T03:38:53.000Z",
"bio": "The genetic argument between two disciplines made flesh…",
"origin": "Tbilisi, Georgia",
"age": 23,
"traits": {
"face_shape": { "dominant": "square", "recessive": "angular", "rarity": "common" },
"skin_tone": { "dominant": "golden", "recessive": "alabaster","rarity": "common" },
"eye_color": { "dominant": "grey", "recessive": "green", "rarity": "common" },
"eye_shape": { "dominant": "almond", "recessive": "round", "rarity": "common" },
"brow": { "dominant": "thick", "recessive": "straight", "rarity": "common" },
"nose": { "dominant": "straight","recessive": "button", "rarity": "common" },
"lip_shape": { "dominant": "bow", "recessive": "pouted", "rarity": "common" },
"jaw": { "dominant": "square", "recessive": "defined", "rarity": "common" },
"hair_texture": { "dominant": "wavy", "recessive": "straight", "rarity": "common" },
"hair_color": { "dominant": "dark", "recessive": "auburn", "rarity": "common" },
"distinguishing_mark": { "dominant": "freckles", "recessive": "none", "rarity": "common" },
"aura": { "dominant": "watchful","recessive": "regal", "rarity": "common" }
},
"parents": [
{ "id": "783f32c8-…", "display_name": "Giorgi Beridze" },
{ "id": "292231c5-…", "display_name": "Nino Beridze" }
],
"media": [
{
"id": "media-uuid-front",
"role": "hero",
"view": "front",
"url": "/api/v1/avstars/69db4d28-…/portrait?role=hero&view=front",
"image_seed": 1234567
},
{
"id": "media-uuid-profile-left",
"role": "hero",
"view": "profile_left",
"url": "/api/v1/avstars/69db4d28-…/portrait?role=hero&view=profile_left",
"image_seed": 9876543
}
// … one row per generated sheet view
],
"views": {
"front": "/api/v1/avstars/69db4d28-…/portrait?role=hero&view=front",
"profile_left": "/api/v1/avstars/69db4d28-…/portrait?role=hero&view=profile_left",
"profile_right": "/api/v1/avstars/69db4d28-…/portrait?role=hero&view=profile_right",
"back_portrait": "/api/v1/avstars/69db4d28-…/portrait?role=hero&view=back_portrait",
"body_front": "/api/v1/avstars/69db4d28-…/portrait?role=hero&view=body_front",
"body_back": "/api/v1/avstars/69db4d28-…/portrait?role=hero&view=body_back"
},
"signed_portrait_url": "https://avstar-media.mother.tech/media/<uuid>?variant=original&expires=…&signature=…",
"media_updated_at": "2026-05-06T22:14:09.000Z"
}
Field notes:
GET /api/v1/avstars/:id/sheet
Returns a single composite PNG: a 3×2 grid of all six character-sheet views joined into one image. Designed for handing one image to an external AI model as a multi-view reference.
Layout:
┌──────────┬──────────────┬───────────────┐
│ PASSPORT │ PROFILE LEFT │ PROFILE RIGHT │
├──────────┼──────────────┼───────────────┤
│ BACK │ BODY FRONT │ BODY BACK │
└──────────┴──────────────┴───────────────┘
Tiles are 800×1067 each, separated by 16px gutters → final canvas is 2432×2150 PNG.
Missing views render as black tiles with an AWAITING — VIEW caption. The X-Sheet-Views response header lists which views are real (comma-separated).
Path params
| Name | Type | Notes |
|---|---|---|
id | uuid | 400 if not a uuid; 404 if the avstar has no sheet views at all |
Response 200
- Body: PNG bytes
Content-Type: image/pngCache-Control: public, max-age=300, s-maxage=600(sheet contents may change as new views are generated; shorter TTL than per-view portraits)X-Sheet-Views: front,profile_left,...— present views
GET /api/v1/avstars/:id/portrait
Returns the original image for the requested role, or an optimized display variant. Published models remain public; drafts return 404. Storage stays private. Cloudflare mode responds with a 307 redirect to a short-lived signed media URL, which clients must follow.
Path params
| Name | Type | Notes |
|---|---|---|
id | uuid | 400 if not a uuid |
Query params
Optional variant: original (default), thumb (320px), card (640px), gallery (1280px), or large (2560px). Display variants preserve aspect ratio and negotiate AVIF/WebP/JPEG. Originals keep their actual format.
Response 307 → 200
- Body after following the redirect: image bytes
Content-Type: the delivered image MIME type- Redirects use
Cache-Control: private, no-store; avoid storing signed URLs as permanent identifiers. - Supabase fallback serves the bytes directly with a five-minute cache lifetime.
Response 404
- Avstar exists but no media of that role
- Avstar doesn't exist
Character Sheet
Every AVSTAR can have up to six standardized views, all stored as role='hero' rows in face_media and differentiated by the view field. Together they form a Character Sheet — an editorial reference set you can use to compose richer character UIs without asking for ad-hoc angles.
Sheets are generated lazily and admin-gated — most avstars start with just front and only get the full set when an operator triggers generation. You should always check views[<view>] for presence before using it. Two convenient shapes:
// Sparse map shape — pick only what you need:
if (avstar.views?.body_front) {
download(avstar.views.body_front)
}
// Or iterate media[] with an explicit filter:
const fullBody = avstar.media.filter((m) => m.view === 'body_front')
Adding new views to the taxonomy in the future is non-breaking (additive). Removing or renaming a view goes to /api/v2/.
DTOs (TypeScript / Zod)
These are the published types. Add them by hand to your client until a published SDK package exists.
type Source = 'cast' | 'imprint' | 'architect'
type MediaRole = 'hero' | 'thumbnail' | 'reference'
type MediaView =
| 'front'
| 'front_passport'
| 'profile_left'
| 'profile_right'
| 'back_portrait'
| 'body_front'
| 'body_back'
type Rarity = 'common' | 'uncommon' | 'rare' | 'mewt'
interface TraitSlot {
dominant: string
recessive: string
rarity: Rarity
}
interface TraitMapV1 {
face_shape: TraitSlot
skin_tone: TraitSlot
eye_color: TraitSlot
eye_shape: TraitSlot
brow: TraitSlot
nose: TraitSlot
lip_shape: TraitSlot
jaw: TraitSlot
hair_texture: TraitSlot
hair_color: TraitSlot
distinguishing_mark: TraitSlot
aura: TraitSlot
}
interface ParentRefDto {
id: string
display_name: string | null
}
interface MediaDto {
id: string
role: MediaRole
view: MediaView | null // null only for legacy rows; new writes always set view
url: string
image_seed: number | null
}
// Sparse map keyed by view name → proxy URL.
type ViewsMap = Partial<Record<MediaView, string>>
interface AvstarSummaryDto {
id: string
display_name: string | null
signature: string | null
generation: number
source: Source
hero_url: string | null
created_at: string // ISO 8601
}
interface AvstarDetailDto extends AvstarSummaryDto {
bio: string | null
origin: string | null
age: number | null
traits: TraitMapV1 | null
parents: ParentRefDto[]
media: MediaDto[]
views: ViewsMap | null // sparse — only generated views appear
signed_portrait_url: string | null
}
interface PaginatedList<T> {
data: T[]
page: { next_cursor: string | null; limit: number }
}
interface ApiError {
error: {
code: 'unauthorized' | 'forbidden' | 'invalid_request' | 'not_found' | 'internal_error'
message: string
request_id: string
}
}
Sample integration: Degen synthetic accounts
The canonical first consumer is Degen (/Users/matty/Code/mother/degen), which uses this API to create synthetic auth.users whose identity mirrors an AVSTAR.
// 1. Browse + pick
const { data, page } = await fetch(`${BASE}/api/v1/avstars?limit=50`, {
headers: { Authorization: `Bearer ${process.env.AVSTARS_API_KEY}` },
}).then((r) => r.json())
const chosen = data[0]
// 2. Fetch full dossier
const avstar: AvstarDetailDto = await fetch(`${BASE}/api/v1/avstars/${chosen.id}`, {
headers: { Authorization: `Bearer ${process.env.AVSTARS_API_KEY}` },
}).then((r) => r.json())
// 3. Map into Degen's user_profiles row
const profile = {
display_name: avstar.display_name,
username: slugify(avstar.display_name ?? '') + '_' + avstar.id.slice(0, 6),
bio: avstar.bio,
// (avatar_storage_path filled in step 4)
}
// 4. Download portrait once via signed URL → upload into Degen's avatars bucket
const bytes = await fetch(avstar.signed_portrait_url!).then((r) => r.arrayBuffer())
// supabase.storage.from('avatars').upload(`${profile.username}/hero.png`, bytes, …)
// 5. Run skills with traits + signature as workflow inputs
const skillInputs = {
display_name: avstar.display_name,
bio: avstar.bio,
signature: avstar.signature,
traits: avstar.traits,
origin: avstar.origin,
age: avstar.age,
}
Versioning policy
- Backwards-compatible (no notice): new fields, new optional query params, new endpoints, additional error codes.
- Breaking (new version under
/api/v2/): field removal, field rename, type change, semantics change, removed endpoint, removed error code. - v1 remains live for at least 6 months after v2 ships.