Visual identity: asset spec, readiness, and probes

The exact rules for identity assets, what makes a character image-ready, how probes report consistency, and batch inventory generation.

LoreOS image generation is identity-anchored: every selfie a character sends is grounded in an uploaded identity image plus text identity anchors, so the same character looks like itself over time. This page is the precise contract for that system — what you must upload, what “image-ready” actually checks, what an image probe returns, and how to batch-generate inventory.

For the concept overview, see Visual system. For the behavioral knobs that turn images on at all, see Character authoring.

Selfies are off until three things are true: (1) an identity image is uploaded and active, (2) profile.identity_anchors is set as a dict of visual keys (a list does not work for images — see the dict requirement), and (3) behavioral_thresholds image_proactivity / selfie_comfort are > 0 and daily_image_cap is > 0. Miss any one and the character will never send an image. GET /v1/characters/{slug}/visual-readiness checks all of this for you.

The identity asset (upload this first)

Before you can upload any other visual asset — or generate any image — the character needs an active identity asset. The identity image is what the engine uses to anchor every generated selfie and keep the face consistent across images.

$curl -sS https://api.loreos.app/v1/characters/$SLUG/visual-assets \
> -H "Authorization: Bearer $LOREOS_KEY" \
> -F "file=@face.png" \
> -F "category=identity" \
> -F "asset_key=identity-front" \
> -F "label=front-facing portrait"

This is a multipart/form-data upload, not JSON. The form fields:

FieldRequiredNotes
fileyesThe image (PNG / JPEG / WebP). A non-image or unreadable file is a 422 (image_rejected).
categoryyesOne of identity, wardrobe, accessory, prop, place, scene, style, keyframe. An unknown value is a 422 (invalid_category).
asset_keyyesA stable handle for this asset (e.g. identity-front). Must be non-empty.
labelyesA short human label. Must be non-empty.
visual_summarynoShort visual description (helps grounding).
visual_descriptionnoLonger description; defaults to visual_summary/label if omitted.

Identity is special

When you upload category=identity, LoreOS activates it immediately (status active, promotion approved_anchor) so the selfie pipeline can use it at once. Every other category is uploaded as a candidate (you promote it later — see Promoting assets).

Other categories are blocked until identity exists

Uploading any non-identity asset before an active identity exists returns:

1{
2 "detail": {
3 "code": "needs_identity",
4 "message": "this character has no active identity asset yet",
5 "fix": "Upload identity first: POST /v1/characters/{slug}/visual-assets (category=identity)."
6 }
7}

This is a 409 (a precondition is unmet), not a 422. Upload the identity image first, then everything else.

What makes a good identity image

The identity image is the face/look reference the engine sends with every generated selfie, so its quality sets the ceiling for how consistent and recognizable the character looks over time. It is worth getting right.

What the platform actually enforces (hard checks). Only these are validated — get them wrong and the upload or the selfie pipeline fails outright:

  • Format: the file must be a PNG, JPEG, or WebP image. Anything else is a 422 (image_rejected).
  • Size: up to ~8 MB per file. Larger is a 422 (image_rejected).
  • Fetchable over HTTPS: LoreOS stores the upload and the image must be reachable at an https:// URL — the generator fetches it as the reference. (You upload bytes; LoreOS hosts them, so this is automatic for uploaded assets.)
  • At least one active identity asset plus a non-empty profile.identity_anchors dict (next section). GET .../visual-readiness checks both.

What makes it good (best practice, not enforced). The platform does not check resolution, aspect ratio, face angle, or framing — there are no pixel thresholds in the readiness logic. But the generator anchors on what you give it, so these meaningfully improve the result:

  • One clear, front-facing portrait is the single most important asset. A head-and-shoulders shot where the face is large, sharp, and unobstructed gives the strongest face anchor. Prefer a portrait crop for the identity anchor — it is what facial consistency is measured against.
  • High enough resolution that the face is crisp. A roughly square-to-portrait image where the face fills a good part of the frame works well; a tiny or heavily compressed face gives the generator less to lock onto. (No minimum is enforced — “the face is clearly resolved” is the bar, not a specific pixel count.)
  • Well-lit, neutral, unobstructed face. Even lighting and a plain-ish background read best.
  • Portrait vs full-body: use a portrait as the identity anchor (face fidelity). If you also want reliable head-to-toe shots, add a full-body image too — but as an additional identity (or inventory) asset, not as the primary face anchor. You can also test a full-body result on demand with the full_body_identity probe.
  • How many is ideal: one strong front portrait is the minimum and is often enough. Two or three — e.g. a front portrait plus a slight three-quarter angle, or a full-body — can help the generator, and all active identity assets count toward readiness. There are diminishing returns; quality of the front shot matters more than quantity.
  • Does angle coverage help? A little extra angle coverage can help the generator generalize, but it is not required and is not a readiness check. A single excellent front portrait beats several mediocre angles.

Bad examples to avoid (each weakens the anchor or causes inconsistent selfies):

  • a cropped or partially cut-off face (forehead or chin missing);
  • a heavy filter / beauty-smoothing / stylization that hides real features;
  • side-profile-only with no front shot (the generator has no frontal reference to anchor);
  • a tiny, low-resolution, or motion-blurred face;
  • sunglasses, masks, hands, or hair fully covering the face;
  • a busy group photo where it is ambiguous which person is the character.

Pair the image with structured anchors in profile.identity_anchors (see the next section) — the image anchors the face, and the identity_anchors dict anchors hair / style / recurring visual constants. For images this field must be an object, not a list.

Visual readiness and the exact pass or fail rules

GET /v1/characters/{slug}/visual-readiness answers one question: can this character generate good, consistent selfies? It runs four deterministic checks and rolls them into a status.

$curl -sS https://api.loreos.app/v1/characters/$SLUG/visual-readiness \
> -H "Authorization: Bearer $LOREOS_KEY"
Check idok when…missing / weak / disabled when…
identity_asset≥ 1 active identity asset existsmissing — no active identity image (selfies have no face reference)
identity_anchorsprofile.identity_anchors is set as a non-empty object (dict) of visual keysmissing — no usable visual anchor; selfie generation fails even with an identity image. A list does not satisfy this check (see the warning below)
inventory_coverage≥ 1 active inventory asset (wardrobe / accessory / prop / place / scene / style / keyframe)weak — no inventory assets (images work, but variety/consistency is thinner)
image_generationbehavioral_thresholds.image_proactivity or selfie_comfort is > 0disabled — image generation is off (the default)

The aggregate status is:

  • needs_attention if any check is missing (i.e. identity_asset or identity_anchors),
  • ready_with_warnings if any check is weak/disabled (e.g. no inventory yet, or images still off),
  • ready only when all four are ok.
1{
2 "status": "ready_with_warnings",
3 "checks": [
4 { "id": "identity_asset", "status": "ok", "summary": "1 active identity asset(s)." },
5 { "id": "identity_anchors", "status": "ok", "summary": "Visual identity anchors set." },
6 { "id": "inventory_coverage", "status": "weak", "summary": "No inventory assets.", "fix": "Add wardrobe/accessory/prop/place for consistent images." },
7 { "id": "image_generation", "status": "disabled", "summary": "Image generation off (default).", "fix": "Set behavioral_thresholds.image_proactivity / selfie_comfort." }
8 ],
9 "active_version_id": "...",
10 "scope_note": "identity_anchors + image_generation reflect the ACTIVE character version ..."
11}

identity_anchors is version-scoped; the identity image is not. Read the scope_note: identity_anchors and image_generation reflect the active character version, while identity_asset and inventory_coverage are character-global (they persist across versions). A PATCH /v1/characters that sends a new profile replaces the profile wholesale — if you omit profile.identity_anchors in that PATCH, you silently drop the text anchor from the new active version, and selfie generation will start failing even though the identity image is still there. Always include identity_anchors in any profile you PATCH.

For images, identity_anchors must be a dict, not a list. This is the most surprising part of the visual system. The character authoring contract types profile.identity_anchors as list[str] | dict, so both shapes are valid through POST /v1/characters — sending the dict form is not a 422. The distinction is at runtime: a list of text anchors satisfies authoring readiness (it doubles as voice/persona anchors), but the image pipeline reads identity_anchors as a structured object with visual keys, and the visual-readiness identity_anchors check, the selfie-bootstrap step, and the image-continuity source all require a non-empty dict. A list-shaped identity_anchors passes authoring readiness but makes visual-readiness report identity_anchors: missing and causes selfie generation to fail with “non-bootstrap selfie requires a self visual reference.” A dict satisfies authoring readiness too, so if your character sends images, just author identity_anchors as a dict — set it directly on the profile you POST/PATCH.

identity_anchors is the visual consistency seed the selfie pipeline reads. The engine pulls visual_summary, hair, style_constants (a list), and avoid_visual_changes (a list) out of it to anchor every generated image; additional descriptive keys are carried through. Set it on profile as an object:

1{
2 "profile": {
3 "identity_anchors": {
4 "visual_summary": "late-20s woman, warm but tired-eyed, often in a flour-dusted apron",
5 "hair": "shoulder-length dark hair, usually tied back",
6 "eyes": "dark brown",
7 "style_constants": ["round wire glasses", "simple earth-tone clothes", "a thin silver ring"],
8 "avoid_visual_changes": ["no drastic hair color changes", "no heavy makeup"]
9 }
10 }
11}

Without a usable dict here the pipeline rejects a non-bootstrap selfie with “non-bootstrap selfie requires a self visual reference” — even with an identity image present.

A broader, grouped view of all assets (identity plus every inventory category, with counts by status) is at GET /v1/characters/{slug}/visual-inventory. The character-wide launch readiness (persona, voice, budget, delivery, and the visual checks) is at GET /v1/characters/{slug}/readiness.

Image probes — test the visual setup on demand

You don’t have to chat until the character decides to send a selfie. An image probe force-generates one image of a chosen kind, runs it through the same engine the runtime uses (it only bypasses the per-character cap/cooldown), and delivers the result onto the session event log as an image.ready event — exactly like a real selfie. The probe report is then a deterministic read of what actually happened: the image URL, which reference assets were sent, and the engine’s consistency verdict.

Each probe is one real, metered image generation — it counts against budget and the per-user/app credit cap, just like a runtime selfie. Probes also require a persistent developer key (demo sandbox keys are rejected with 403).

Run a probe

A probe runs inside one of the character’s existing sandbox sessions (the image.ready event lands on that session’s log), so pass a session_id you already have open.

$curl -sS https://api.loreos.app/v1/characters/$SLUG/image-probe \
> -H "Authorization: Bearer $LOREOS_KEY" \
> -H "Content-Type: application/json" \
> -d '{ "probe_type": "identity_selfie", "session_id": "..." }'

The seven probe types:

probe_typeWhat it asks the engine to draw
identity_selfiea clear front-facing selfie showing the face
full_body_identitya full-body photo, head to toe
wardrobe_variationthe character in a different everyday outfit
accessory_carry_forwardthe character keeping the same signature accessory
same_scene_followupanother photo in the same place as the previous image
prop_usagethe character holding/using an everyday personal item
forbidden_visual_checkan ordinary selfie, used to confirm no forbidden visual traits appear

The POST responds as soon as generation is enqueued (it is async). Preconditions are checked synchronously: a missing/unowned session_id is 404; an exited session is 409 (session_exited); no active identity asset is 409 (needs_identity); an inability to seed the self visual reference (e.g. the identity image isn’t fetchable over HTTPS, or profile.identity_anchors is unset) is 409 (needs_identity with a fix pointing at visual-readiness); over budget is 402 (budget_exceeded).

1{
2 "data": {
3 "probe_id": "…",
4 "session_id": "…",
5 "probe_type": "identity_selfie",
6 "image_kind": "selfie",
7 "request_status": "…",
8 "decision": "send_image",
9 "deliverable": true
10 },
11 "next_actions": [
12 { "command": "GET /v1/characters/{slug}/image-probe/{probe_id}", "description": "Poll the probe result (image URL + assets sent + consistency)" },
13 { "command": "GET /v1/sessions/{session_id}/events?since=0", "description": "Watch the session log for the image.ready event" }
14 ]
15}

Poll the probe result

GET /v1/characters/{slug}/image-probe/{probe_id} is the result. It is a redacted projection over the underlying image request — no raw prompts or generation payloads. The full schema:

FieldTypeMeaning
probe_idstringThe probe’s id.
session_idstringThe session the image.ready event was delivered to.
probe_typestringThe probe type you requested.
image_kindstringThe forced image kind (selfie / scene / object).
request_statusstringRaw pipeline status (draft, queued_with_ryan, succeeded, failed, …).
statestringDeveloper-friendly lifecycle: queued / generating / ready / failed / blocked / unknown.
image_urlstring | nullThe servable HTTPS URL, or null if no servable image was produced.
servablebooltrue only when a fetchable HTTPS image exists.
summarystringPlain-language verdict (see the failure modes below).
assets_sentstring[]The reference asset ids the pipeline was given for this image.
consistencyobject | nullThe continuity verdict (see below); null if not evaluated.
failure_reasonstring | nullWhy it failed, if it failed.
promotion_eligiblebooltrue only when request_status == "succeeded" and the consistency verdict passed.
continuity_dependentbooltrue for accessory_carry_forward / same_scene_followup — their verdict is only meaningful when a prior image exists to follow.
created_at / completed_atstring | nullISO timestamps.

The consistency object, when present:

1{
2 "consistency": {
3 "status": "passed",
4 "reason": "...",
5 "continuity_relation": "...",
6 "parent_bitmap_available": true
7 }
8}

consistency.status is one of passed | manual_required | failed.

servable: true does not mean success. An image can be generated and viewable (servable: true, image_url set) while the request was rejected — e.g. a safety block — so request_status is failed, promotion_eligible is false, and it is not auto-delivered as a successful result. Always read summary + request_status together, not just image_url. The three cases:

  • generated and verdict passed → promotable into inventory;
  • generated but the request didn’t fully succeed → viewable, not promotable, not delivered;
  • no servable image → see failure_reason.

For continuity-dependent probes (accessory_carry_forward, same_scene_followup), run an identity_selfie first so there is a parent image to follow — otherwise the verdict reports a new episode with nothing to compare against. GET /v1/characters/{slug}/image-probe lists recent probes (most recent first) and echoes the valid probe_types.

Batch-generate inventory

Once identity is in place you can generate a character’s reusable inventory (outfits, props, scenes) from its identity, instead of uploading every asset by hand. POST /v1/characters/{slug}/inventory/generate runs the same engine once per requested image.

$curl -sS https://api.loreos.app/v1/characters/$SLUG/inventory/generate \
> -H "Authorization: Bearer $LOREOS_KEY" \
> -H "Content-Type: application/json" \
> -d '{
> "categories": ["wardrobe", "accessory", "prop"],
> "session_id": "...",
> "count_per_category": 2,
> "style_brief": "casual weekend, soft natural light",
> "hold_for_review": false
> }'

The request body:

FieldTypeNotes
categoriesstring[] (required)Subset of wardrobe, accessory, prop, place, scene. (Note: identity and keyframe are not generatable here — identity is developer-provided; keyframe is the default generated category.) An unknown category is a 422 (invalid_category).
session_idstring (required)An open sandbox session for this character (same role as in a probe).
count_per_categoryint 1..4 (default 1)Images per category.
style_briefstring, optionalFree-text style guidance threaded into the brief.
forbiddenstring[], optionalThings to avoid in the generated images.
hold_for_reviewbool (default false)See activation behavior below.

The ≤ 8 per batch cap

categories.length × count_per_category must be ≤ 8 — each image is a real, metered generation, so the batch is capped for cost. Exceed it and you get:

1{
2 "detail": {
3 "code": "batch_too_large",
4 "message": "12 images requested; max 8 per batch",
5 "fix": "Reduce categories × count_per_category to ≤ 8."
6 }
7}

Cost is categories × count_per_category metered generations. Each image in the batch is one real, metered image generation (same unit a runtime selfie or a probe costs). So ["wardrobe","accessory","prop"] with count_per_category: 2 is 3 categories × 2 = 6 metered generations — and is fine (≤ 8). 3 × 3 = 9 exceeds the cap and is rejected. Budget the batch accordingly; the whole batch is also gated by the same 402 budget_exceeded preflight as a normal turn.

Active by default, or held for review

  • hold_for_review: false (default) — generated assets go straight to active: they are immediately usable by the runtime and show up active in visual-inventory typed by category. Best when it’s effectively just you using the character.
  • hold_for_review: true — generated assets are kept as candidates; you approve the ones you want via PATCH /v1/characters/{slug}/visual-assets/{asset_id} before they’re used. Best when you want a human to curate.

The same identity-and-anchors preconditions as probes apply (needs_identity 409 if missing), and the same budget gate (402).

The generate response (what was queued)

POST .../inventory/generate responds as soon as the batch is enqueued — it does not wait for the images. The data reports how many were requested, how many actually queued, and a per-item batch entry:

1{
2 "schema_version": "v0",
3 "data": {
4 "requested": 6,
5 "queued": 6,
6 "hold_for_review": false,
7 "batch": [
8 { "category": "wardrobe", "request_id": "…", "request_status": "draft", "image_kind": "selfie", "deliverable": true },
9 { "category": "wardrobe", "request_id": "…", "request_status": "draft", "image_kind": "selfie", "deliverable": true },
10 { "category": "accessory", "request_id": "…", "request_status": "draft", "image_kind": "object", "deliverable": true },
11 { "category": "accessory", "request_id": "…", "request_status": "draft", "image_kind": "object", "deliverable": true },
12 { "category": "prop", "request_id": "…", "request_status": "draft", "image_kind": "object", "deliverable": true },
13 { "category": "prop", "request_id": "…", "request_status": "draft", "image_kind": "object", "deliverable": true }
14 ]
15 },
16 "next_actions": [
17 { "command": "GET /v1/characters/{slug}/inventory/generations", "description": "Poll the generation batch status" },
18 { "command": "GET /v1/characters/{slug}/visual-inventory", "description": "See the new typed inventory assets as they complete" }
19 ]
20}

requested is categories × count_per_category; queued counts the entries that got a request_id (an entry that failed to enqueue has request_id: null — see partial failure below).

Poll the generation status

The images generate asynchronously. Poll GET /v1/characters/{slug}/inventory/generations for per-image status (and GET /v1/characters/{slug}/visual-inventory to watch the active assets land). Each generation is reported with the same redacted report shape as an image probe, plus the typed category. The developer-facing lifecycle field is state (you do not need to know the raw pipeline status):

1{
2 "schema_version": "v0",
3 "data": {
4 "categories": ["accessory", "place", "prop", "scene", "wardrobe"],
5 "generations": [
6 {
7 "probe_id": "…",
8 "category": "wardrobe",
9 "image_kind": "selfie",
10 "request_status": "succeeded",
11 "state": "ready",
12 "image_url": "https://…/wardrobe-01.png",
13 "servable": true,
14 "summary": "Image generated and eligible to promote into inventory.",
15 "assets_sent": ["…"],
16 "consistency": { "status": "passed", "reason": "…", "continuity_relation": "…", "parent_bitmap_available": true },
17 "failure_reason": null,
18 "promotion_eligible": true,
19 "created_at": "2026-06-05T09:20:00Z",
20 "completed_at": "2026-06-05T09:20:48Z"
21 }
22 ]
23 }
24}

state is the lifecycle to drive your UI off: queuedgeneratingready (or failed / blocked). servable: true with image_url set means a fetchable image exists; promotion_eligible is true only when the request succeeded and the consistency verdict passed.

What a partial failure looks like

A batch is per-image: some images can succeed while others fail, and you still get a 200. Two distinct shapes:

  • Failed to enqueue — surfaced inline in the generate response batch, with no request_id:

    1{ "category": "scene", "request_id": null, "error": "…" }

    (queued will be less than requested when this happens.)

  • Enqueued but the generation did not fully succeed — surfaced in inventory/generations, the same way a failed probe is: state: "failed" (or blocked), servable: false, image_url: null, promotion_eligible: false, and a failure_reason string. Note the confusing-but-correct case where servable: true and request_status is not succeeded: an image was produced and is viewable, but the request was rejected (e.g. a safety block) — so it is not promotable and not delivered as a successful result. Read summary + request_status together, not just image_url.

The successful items in the same batch are unaffected — they land active (or as candidates), independently of the failed ones.

Candidate vs active outcome, and approve/reject

The outcome of a succeeded image depends on hold_for_review:

  • hold_for_review: false (default) → the asset is active immediately (usable by the runtime, shows up active in visual-inventory typed by category). Nothing more to do.
  • hold_for_review: true → the asset is a candidate; curate it before it is used. Read the candidates in inventory/generations / visual-inventory, then:
    • approvePATCH /v1/characters/{slug}/visual-assets/{asset_id} with {"promotion_status": "approved_keyframe"} (or approved_anchor) to promote it into active inventory;
    • rejectPATCH .../visual-assets/{asset_id} with {"asset_status": "rejected"}, or DELETE .../visual-assets/{asset_id} to remove it.

(The asset_id for promotion is the visual asset that the generated image produced — read it from visual-inventory; the probe_id in the generation report is the image request id, not the asset id.)

Promoting and managing assets

PATCH /v1/characters/{slug}/visual-assets/{asset_id} updates an asset’s promotion_status / qa_status — this is how you promote a candidate (an uploaded non-identity asset, a held inventory result, or a passing probe image) into active inventory, and how a passing probe result becomes a reusable keyframe. DELETE /v1/characters/{slug}/visual-assets/{asset_id} removes an asset. Asset statuses are candidate / active / retired / rejected; promotion statuses include candidate / approved_anchor / approved_keyframe.

End-to-end order

1

Upload an identity image

POST .../visual-assets with category=identity. It activates immediately and unlocks every other asset and all image generation.

2

Set profile.identity_anchors as a dict

POST/PATCH /v1/characters with profile.identity_anchors authored as an object (visual_summary / hair / style_constants / avoid_visual_changes …). Required for selfie generation, must be a dict (not a list), and is version-scoped.

3

Enable images

Set behavioral_thresholds.image_proactivity / selfie_comfort > 0 and daily_image_cap > 0.

4

Check visual readiness

GET .../visual-readiness should reach ready (or ready_with_warnings if you haven’t added inventory yet).

5

Probe it

POST .../image-probe with identity_selfie, then poll the result. Confirm image_url is set and consistency.status is passed.

6

(Optional) generate inventory

POST .../inventory/generate for outfits/props/scenes — active by default, or hold_for_review to curate.