> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.loreos.app/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.loreos.app/_mcp/server.

# Visual identity: asset spec and readiness

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, and how to inspect runtime-generated images.

For the concept overview, see [Visual system](./visual-system). For the behavioral knobs
that turn images on at all, see
[Character authoring](./character-authoring#behavioral-thresholds-enabling-selfies-and-proactive-messages).

> **Warning**
>
> **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](#visual-readiness-and-the-exact-pass-or-fail-rules)),
> 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.

```bash
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:

| Field                | Required | Notes                                                                                                                                        |
| -------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `file`               | yes      | The image (PNG / JPEG / WebP). A non-image or unreadable file is a `422` (`image_rejected`).                                                 |
| `category`           | yes      | One of `identity`, `wardrobe`, `accessory`, `prop`, `place`, `scene`, `style`, `keyframe`. An unknown value is a `422` (`invalid_category`). |
| `asset_key`          | yes      | A stable handle for this asset (e.g. `identity-front`). Must be non-empty.                                                                   |
| `label`              | yes      | A short human label. Must be non-empty.                                                                                                      |
| `visual_summary`     | no       | Short visual description (helps grounding).                                                                                                  |
| `visual_description` | no       | Longer 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](#promoting-and-managing-assets)).

### Other categories are blocked until identity exists

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

```json
{
  "detail": {
    "code": "needs_identity",
    "message": "this character has no active identity asset yet",
    "fix": "Upload identity first: POST /v1/characters/{slug}/visual-assets (category=identity)."
  }
}
```

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.

```bash
curl -sS https://api.loreos.app/v1/characters/$SLUG/visual-readiness \
  -H "Authorization: Bearer $LOREOS_KEY"
```

| Check id             | `ok` when…                                                                                  | `missing` / `weak` / `disabled` when…                                                                                                                        |
| -------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `identity_asset`     | ≥ 1 **active** `identity` asset exists                                                      | `missing` — no active identity image (selfies have no face reference)                                                                                        |
| `identity_anchors`   | `profile.identity_anchors` is set **as a non-empty object (dict)** of visual keys           | `missing` — 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_generation`   | `behavioral_thresholds.image_proactivity` **or** `selfie_comfort` is `> 0`                  | `disabled` — 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`.

```json
{
  "status": "ready_with_warnings",
  "checks": [
    { "id": "identity_asset", "status": "ok", "summary": "1 active identity asset(s)." },
    { "id": "identity_anchors", "status": "ok", "summary": "Visual identity anchors set." },
    { "id": "inventory_coverage", "status": "weak", "summary": "No inventory assets.", "fix": "Add wardrobe/accessory/prop/place for consistent images." },
    { "id": "image_generation", "status": "disabled", "summary": "Image generation off (default).", "fix": "Set behavioral_thresholds.image_proactivity / selfie_comfort." }
  ],
  "active_version_id": "...",
  "scope_note": "identity_anchors + image_generation reflect the ACTIVE character version ..."
}
```

> **Warning**
>
> **`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.

> **Warning**
>
> **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:

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

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

## Generate a new image through character chat

There is no public "generate now" endpoint. New images are an actor-native character behavior:
create a session, send a normal message that naturally asks for an image, and keep consuming the
session event log.

```bash
curl -X POST "https://api.loreos.app/v1/sessions/$SESSION_ID/messages" \
  -H "Authorization: Bearer $LOREOS_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: selfie-request-001" \
  -d '{
    "text": "Show me a natural selfie of you right now in your current setting.",
    "reply_mode": "fast"
  }'

curl "https://api.loreos.app/v1/sessions/$SESSION_ID/events?since=$CURSOR&limit=500" \
  -H "Authorization: Bearer $LOREOS_KEY"
```

A character `message.created` may advertise
`payload.pending_image.expected_event: "image.ready"`. The image is asynchronous and arrives as
a separate later `image.ready` event. Render it only when `payload.servable` is `true`, then
fetch `payload.image_url` and require an image content type rather than trusting the URL string.

The current LoreOS image runtime uses OpenAI GPT Image 2. The public API does not accept a
provider or model selector; treat those as release configuration, not portable request fields.
Image generation is metered. Inspect it with `GET /v1/usage?group_by=resource_type`:

```bash
curl "https://api.loreos.app/v1/usage?group_by=resource_type" \
  -H "Authorization: Bearer $LOREOS_KEY"
```

Look for the `image_generation` group and a non-zero `credits` value. A normal conversational
turn can still decline to attach a photo when policy or character state says it should. That is
not a missing route. For a release canary that deliberately uses a ready identity, positive image
thresholds, available daily cap, and an explicit safe selfie request, however, missing
`image.ready` is a failed canary and should be diagnosed from the reply event, persisted image
request, worker state, continuity verdict, and usage ledger before changing the prompt or model.

## Historical image request reports

Character-chat image generation is owned by the actor-native runtime. The former
force-generation writer is retired. The read-only image-probe routes remain as deterministic
reports over historical request rows: image URL, reference assets sent, and the engine's
consistency verdict.

> **Note**
>
> These routes do not enqueue or bill a new image generation.

### Read historical reports

Use `GET /v1/characters/{slug}/image-probe` to list retained rows and
`GET /v1/characters/{slug}/image-probe/{probe_id}` to inspect one.

The seven probe types:

| `probe_type`              | What it asks the engine to draw                                       |
| ------------------------- | --------------------------------------------------------------------- |
| `identity_selfie`         | a clear front-facing selfie showing the face                          |
| `full_body_identity`      | a full-body photo, head to toe                                        |
| `wardrobe_variation`      | the character in a different everyday outfit                          |
| `accessory_carry_forward` | the character keeping the same signature accessory                    |
| `same_scene_followup`     | another photo in the same place as the previous image                 |
| `prop_usage`              | the character holding/using an everyday personal item                 |
| `forbidden_visual_check`  | an ordinary selfie, used to confirm no forbidden visual traits appear |

The listed `probe_type` value describes how a historical row was created; it is not a current
writer input.

```json
{
  "data": {
    "probe_id": "…",
    "session_id": "…",
    "probe_type": "identity_selfie",
    "image_kind": "selfie",
    "request_status": "…",
    "decision": "send_image",
    "deliverable": true
  },
  "next_actions": [
    { "command": "GET /v1/characters/{slug}/image-probe/{probe_id}", "description": "Poll the probe result (image URL + assets sent + consistency)" },
    { "command": "GET /v1/sessions/{session_id}/events?since=0", "description": "Watch the session log for the image.ready event" }
  ]
}
```

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

| Field                         | Type           | Meaning                                                                                                                              |
| ----------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `probe_id`                    | string         | The probe's id.                                                                                                                      |
| `session_id`                  | string         | The session the `image.ready` event was delivered to.                                                                                |
| `probe_type`                  | string         | The probe type you requested.                                                                                                        |
| `image_kind`                  | string         | The forced image kind (`selfie` / `scene` / `object`).                                                                               |
| `request_status`              | string         | Raw pipeline status (`draft`, `generating`, `succeeded`, `failed`, …). Prefer the normalized `state` field below.                    |
| `state`                       | string         | Developer-friendly lifecycle: `queued` / `generating` / `ready` / `failed` / `blocked` / `unknown`.                                  |
| `image_url`                   | string \| null | The servable HTTPS URL, **or `null`** if no servable image was produced.                                                             |
| `servable`                    | bool           | `true` only when a fetchable HTTPS image exists.                                                                                     |
| `summary`                     | string         | Plain-language verdict (see the failure modes below).                                                                                |
| `assets_sent`                 | string\[]      | The reference asset ids the pipeline was given for this image.                                                                       |
| `consistency`                 | object \| null | The continuity verdict (see below); `null` if not evaluated.                                                                         |
| `failure_reason`              | string \| null | Why it failed, if it failed.                                                                                                         |
| `promotion_eligible`          | bool           | `true` only when `request_status == "succeeded"` **and** the consistency verdict passed.                                             |
| `continuity_dependent`        | bool           | `true` for `accessory_carry_forward` / `same_scene_followup` — their verdict is only meaningful when a prior image exists to follow. |
| `created_at` / `completed_at` | string \| null | ISO timestamps.                                                                                                                      |

The `consistency` object, when present:

```json
{
  "consistency": {
    "status": "passed",
    "reason": "...",
    "continuity_relation": "...",
    "parent_bitmap_available": true
  }
}
```

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

> **Note**
>
> **`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 historical rows (`accessory_carry_forward`,
`same_scene_followup`), the verdict reports whether a parent image was available.
`GET /v1/characters/{slug}/image-probe` lists retained rows most recent first.

## Historical inventory-generation reports

The former batch-generation writer is retired. Upload or register reusable wardrobe,
accessory, prop, place, and scene assets through the visual-assets API. The retained
`GET /v1/characters/{slug}/inventory/generations` route only reports historical rows.

> **Warning**
>
> Do not reconstruct a request from the historical fields below: there is no corresponding public
> `POST` operation. They exist only so an old row can be interpreted. To create reusable inventory
> now, upload/register it; to request a new conversational image, use the actor-native session flow
> above.

Historical rows may contain the following creation metadata:

| Field                | Type                     | Notes                                                                                                                                                                                                                                                          |
| -------------------- | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `categories`         | string\[] (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_id`         | string (required)        | An open sandbox session for this character (same role as in a probe).                                                                                                                                                                                          |
| `count_per_category` | int `1..4` (default `1`) | Images per category.                                                                                                                                                                                                                                           |
| `style_brief`        | string, optional         | Free-text style guidance threaded into the brief.                                                                                                                                                                                                              |
| `forbidden`          | string\[], optional      | Things to avoid in the generated images.                                                                                                                                                                                                                       |
| `hold_for_review`    | bool (default `false`)   | See activation behavior below.                                                                                                                                                                                                                                 |

### Historical batch cap

Retained batches were capped at eight metered generations:

```json
{
  "detail": {
    "code": "batch_too_large",
    "message": "12 images requested; max 8 per batch",
    "fix": "Reduce categories × count_per_category to ≤ 8."
  }
}
```

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

### Historical activation outcome

* **`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`).

### Historical batch record

Historical data reports how many items were requested, how many queued, and the per-item batch
entry:

```json
{
  "schema_version": "v0",
  "data": {
    "requested": 6,
    "queued": 6,
    "hold_for_review": false,
    "batch": [
      { "category": "wardrobe",  "request_id": "…", "request_status": "draft", "image_kind": "selfie", "deliverable": true },
      { "category": "wardrobe",  "request_id": "…", "request_status": "draft", "image_kind": "selfie", "deliverable": true },
      { "category": "accessory", "request_id": "…", "request_status": "draft", "image_kind": "object", "deliverable": true },
      { "category": "accessory", "request_id": "…", "request_status": "draft", "image_kind": "object", "deliverable": true },
      { "category": "prop",      "request_id": "…", "request_status": "draft", "image_kind": "object", "deliverable": true },
      { "category": "prop",      "request_id": "…", "request_status": "draft", "image_kind": "object", "deliverable": true }
    ]
  },
  "next_actions": [
    { "command": "GET /v1/characters/{slug}/inventory/generations", "description": "Poll the generation batch status" },
    { "command": "GET /v1/characters/{slug}/visual-inventory", "description": "See the new typed inventory assets as they complete" }
  ]
}
```

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

```json
{
  "schema_version": "v0",
  "data": {
    "categories": ["accessory", "place", "prop", "scene", "wardrobe"],
    "generations": [
      {
        "probe_id": "…",
        "category": "wardrobe",
        "image_kind": "selfie",
        "request_status": "succeeded",
        "state": "ready",
        "image_url": "https://…/wardrobe-01.png",
        "servable": true,
        "summary": "Image generated and eligible to promote into inventory.",
        "assets_sent": ["…"],
        "consistency": { "status": "passed", "reason": "…", "continuity_relation": "…", "parent_bitmap_available": true },
        "failure_reason": null,
        "promotion_eligible": true,
        "created_at": "2026-06-05T09:20:00Z",
        "completed_at": "2026-06-05T09:20:48Z"
      }
    ]
  }
}
```

`state` is the lifecycle to drive your UI off: **`queued` → `generating` → `ready`** (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`:

  ```json
  { "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:
  * **approve** → `PATCH /v1/characters/{slug}/visual-assets/{asset_id}` with
    `{"promotion_status": "approved_keyframe"}` (or `approved_anchor`) to promote it into active
    inventory;
  * **reject** → `PATCH .../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

#### Upload an identity image

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

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

#### Enable images

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

#### Check visual readiness

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

#### Exercise the actor-native runtime

Send a normal character-chat turn and read the session event log for an `image.ready` event.

#### (Optional) add inventory

Upload or register outfits, props, and scenes through the visual-assets API, then promote the
assets you want the runtime to use.