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

# Errors and retries

Every `/v1` error uses the same envelope. Read `body.detail.code` to branch in code, show
`body.detail.message` to your operator logs, and treat `body.detail.fix` as the one-line
remediation hint (it is written for a coding agent):

```json
{
  "detail": {
    "code": "budget_exceeded",
    "message": "app credit limit reached",
    "fix": "Raise the app budget through the console/account plane or preview provisioning channel, or wait; see GET /v1/rates."
  }
}
```

The HTTP status is authoritative for retry policy; `code` is authoritative for *which*
thing went wrong. The two rules that hold across the whole surface:

* **`404`, never `403`, for resources** — an id you do not own looks identical to one that
  does not exist (timing-safe, so an attacker cannot probe other tenants). A `403` is only
  ever about your **key** or a **demo-key restriction**, not about a missing resource.
* **`402` fires *before* the expensive work** — over-budget message sends and image
  generations are rejected pre-flight, so a `402` means nothing was charged and no model ran.

> **Note**
>
> A handful of envelope edge cases are not the `{code, message, fix}` shape — the two
> auth failures (`401`, and the invalid/revoked `403`) and the two infrastructure `503`s
> return a **plain-string** `detail`. They are called out explicitly in the tables below so
> your client does not assume `detail.code` always exists.

## Retry decision in one line

* **`429`, `502`, `500 internal_error`, and the two infrastructure `503`s are retryable** —
  back off and try again (the `500`/`502`/`503` cases are transient; `429` after its window).
* **Everything in the `4xx` range below (except `429`) is a caller fix, not a retry** —
  retrying the same request reproduces the same error. Correct the request, the character
  setup, the budget, or the key first.
* **`409 session_exited` is terminal for that session** — start a new session; do not retry.

## Request and authoring errors (`4xx` — fix the request)

These come from a malformed or out-of-range request body. None are retryable as-is; fix the
named field and resend. `validation_error` is the one you will hit most: it is what every
Pydantic field error (missing `slug`, missing `display_name`, a `voice_samples` entry with no
`text`, an `example_dialogues` entry missing either a `user` or `character` turn, a
`forbidden_style` entry that is a string not an object, an out-of-range
`behavioral_thresholds` value, …) is rendered as, with the failing `loc: msg` quoted into
`message`.

| `code`                         | HTTP | When it happens                                                                                                                                                                              | Retryable | Fallback in your product                                                                                                                                                     |
| ------------------------------ | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `validation_error`             | 422  | Any request-body field fails schema validation (wrong type, out of range, missing required field). The failing field path is in `message`.                                                   | No        | Surface the field path to the developer/operator; fix the body and resend. For agent-authored characters, `POST /v1/characters/validate` first so this never reaches a user. |
| `invalid_character`            | 422  | `POST /v1/characters` (or `/validate`) — the character spec failed the typed authoring contract (e.g. `behavioral_thresholds.image_proactivity` or `profile.drama_intensity` out of `0..1`). | No        | Fix the named range; re-validate before create.                                                                                                                              |
| `invalid_update`               | 422  | `PATCH /v1/characters/{slug}` — the update body failed validation.                                                                                                                           | No        | Same as `invalid_character`; correct the field and resend.                                                                                                                   |
| `invalid_affordance_schema`    | 422  | `affordance_schema` is not a known preset (`dating`/`tutor`), a `{ "preset": "…" }` pointer, or a valid full schema with unique dimension keys.                                              | No        | Use a built-in preset or a full inline schema. See [Character authoring → affordance schema](./character-authoring#affordance-schema-the-relational-domain).                 |
| `slug_taken`                   | 409  | The character `slug` is already owned by **another** app. (Reusing your own app's slug is fine.)                                                                                             | No        | Choose a different slug.                                                                                                                                                     |
| `slug_reserved`                | 409  | The canonical slug belongs to a permanently deleted character tombstone. Deleted slugs cannot be recreated or republished.                                                                   | No        | Generate a new unique slug for this character/test run.                                                                                                                      |
| `delete_confirmation_mismatch` | 422  | `confirm_slug` does not exactly equal the resolved canonical character slug.                                                                                                                 | No        | Read the canonical slug, then retry only after explicit destructive confirmation.                                                                                            |
| `no_updates`                   | 400  | A `PATCH`/settings call sent **no** changeable fields.                                                                                                                                       | No        | Send at least one field to change.                                                                                                                                           |
| `invalid_app_id`               | 422  | An `app_id` path/body value is not a UUID.                                                                                                                                                   | No        | Use the `app_id` from `GET /v1/me`.                                                                                                                                          |
| `invalid_group_by`             | 422  | `GET /v1/usage?group_by=` is not a supported dimension.                                                                                                                                      | No        | Use `resource_type` (default) or another supported dimension named in `message`.                                                                                             |
| `invalid_category`             | 422  | A visual-asset upload used an unknown category.                                                                                                                                              | No        | Use a category from the list in `message`.                                                                                                                                   |
| `invalid_asset`                | 422  | A visual-asset upload is missing `asset_key`/`label`.                                                                                                                                        | No        | Provide both.                                                                                                                                                                |
| `invalid_state`                | 422  | `PATCH .../visual-assets/{id}` sent an unknown promotion/qa/asset-status enum value.                                                                                                         | No        | Use an allowed enum value.                                                                                                                                                   |
| `image_rejected`               | 422  | An uploaded image was not a usable PNG/JPEG/WebP.                                                                                                                                            | No        | Upload a valid image.                                                                                                                                                        |
| `missing_scope_owner`          | 422  | A Story Room run used a scope that needs a `scope_owner_id`/`session_id`/`thread_id` and none was provided.                                                                                  | No        | Provide the scope owner, or use `character_global` scope.                                                                                                                    |
| `invalid_story_room_mode`      | 422  | A Story Room run requested `fixture_shadow` (a local CLI/test mode, not a BaaS mode).                                                                                                        | No        | Use `db_shadow`, `review`, or `autocommit`.                                                                                                                                  |
| `invalid_story_room_run`       | 422  | A Story Room run had invalid mode/autocommit flags.                                                                                                                                          | No        | Fix the mode and autocommit flags.                                                                                                                                           |
| `import_commit_disabled`       | 503  | `POST /v1/imports/{import_id}/commit` reached a ready import, but accepted external-memory writes are disabled for the environment.                                                          | No        | Treat the preview as staged-only. Ask the operator to enable import commits before retrying commit.                                                                          |

## State conflicts (`409` — fix the state, then proceed)

A `409` means the request is well-formed but the **current state** does not allow it.
Retrying the identical call will keep failing until you change the underlying state (upload
identity, publish a version, start a new session, …).

| `code`                                     | HTTP | When it happens                                                                                                                                                                                                                     | Retryable                      | Fallback in your product                                                                                                                                                                                          |
| ------------------------------------------ | ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `session_exited`                           | 409  | You sent a message / probe / inventory request to a session the character has **left**.                                                                                                                                             | No (terminal for that session) | Start a new session and continue there. Treat the `session.exited` event as the signal to do this proactively.                                                                                                    |
| `needs_identity`                           | 409  | An image operation (upload of a non-identity asset, selfie probe, inventory generation) ran before the character has an **active identity** image, or no fetchable identity reference + `profile.identity_anchors` could be seeded. | No                             | Upload an `identity` image **first** (`POST /v1/characters/{slug}/visual-assets`, `category=identity`); set `profile.identity_anchors` as a dict for selfies. Check `GET /v1/characters/{slug}/visual-readiness`. |
| `no_active_version`                        | 409  | `GET .../runtime-preview` (or similar) for a character with no active published version.                                                                                                                                            | No                             | Create/republish the character via `POST` or `PATCH /v1/characters`.                                                                                                                                              |
| `image_probe_failed`                       | 409  | An image probe could not run given the character's current image setup.                                                                                                                                                             | No (fix setup)                 | Check the character's identity + inventory setup and retry once fixed.                                                                                                                                            |
| `candidate_not_activatable`                | 409  | `POST .../story-room/candidates/{id}/approve` on a candidate that cannot be activated (reason in `message`).                                                                                                                        | No                             | Reject it or rerun Story Room after fixing the source issue.                                                                                                                                                      |
| `candidate_not_rejectable`                 | 409  | `POST .../story-room/candidates/{id}/reject` on already-committed history.                                                                                                                                                          | No                             | Append a compensating event instead of rejecting committed history.                                                                                                                                               |
| `evidence_not_verified`                    | 409  | `POST .../evidence/{evidence_id}/state-candidate` received evidence whose `verification_status` is not `verified`.                                                                                                                  | No                             | Verify or replace the evidence first. A researched lead is not lived canon.                                                                                                                                       |
| `production_activation_blocked`            | 409  | `POST /v1/account/apps/{app_id}/production-activation` found one or more readiness blockers. The error includes the current `readiness` object and `next_actions`.                                                                  | No                             | Follow `detail.readiness.next_actions`, clear every blocker, then retry activation for the same app.                                                                                                              |
| `character_must_be_archived_before_delete` | 409  | Character DELETE used the default `force=false` while the character was still published.                                                                                                                                            | No                             | Prefer `POST .../archive`, then DELETE with exact `confirm_slug`; use `force=true` only in an explicitly approved destructive flow.                                                                               |

## Research review and execution result codes

Not every research failure is an HTTP error. `POST /v1/characters/{slug}/state-reviews`
records a review and returns gate outcomes in `data.gate_result.issues`; a research run can
also return a provider result with `success: false` and `error_code`. Branch on these codes
without treating a successful HTTP response as a successful state commit.

| `code`                              | Where it appears                                  | Meaning                                                                                | What your agent should do                                                             |
| ----------------------------------- | ------------------------------------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| `candidate_not_found`               | `data.gate_result.issues[]`                       | A requested candidate id does not belong to this app/character or no longer exists.    | Remove the id or fetch the character's current candidate list.                        |
| `candidate_not_accepted_for_review` | `data.gate_result.issues[]`                       | The candidate has not reached `accepted_for_review`.                                   | Complete candidate review first; do not force a commit.                               |
| `evidence_not_found`                | `data.gate_result.issues[]`                       | A requested or candidate-linked evidence id was not found in this app/character scope. | Remove the id or submit the missing evidence in the same app/character scope.         |
| `evidence_not_verified`             | `data.gate_result.issues[]` (and the `409` above) | Evidence exists but is not verified.                                                   | Verify or replace it before requesting a durable state candidate.                     |
| `verified_evidence_required`        | `data.gate_result.issues[]`                       | The review has no verified evidence to support its candidates.                         | Attach at least one verified evidence item.                                           |
| `public_eligible_evidence_required` | `data.gate_result.issues[]`                       | A `public_memory_candidate` review has no evidence allowed for public use.             | Attach verified evidence with `public_eligible: true`, or choose a non-public target. |
| `target_mismatch`                   | `data.gate_result.issues[]`                       | A candidate's requested target differs from the state-review target.                   | Split the review by target or choose the candidate's target.                          |
| `stale_grounding_query`             | Research execution provider result `error_code`   | A provider generated query years older than the run's freshness policy allows.         | Replan with current-year constraints; do not admit the stale result as evidence.      |

## Budget (`402` — before any spend)

| `code`            | HTTP | When it happens                                                                                                                                                                                                 | Retryable                                        | Fallback in your product                                                                                                                                                                                                                                                                 |
| ----------------- | ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `budget_exceeded` | 402  | A metered message send or image generation would exceed the **app** or **per-user** credit cap. Rejected **pre-flight** — nothing was charged and no model ran. `message` says which cap (`per-user` vs `app`). | Not until the cap is raised or the period resets | Raise the app budget through the console/account plane or preview provisioning channel, or raise one user's cap with `POST /v1/external-users/{ref}/budget-policy`; see `GET /v1/rates`. In-product, show the end-user a "you're out of credits / try later" state rather than an error. |

## Auth (`401` / `403` — fix the key)

These are about your **key**, never a missing resource. The generic invalid/revoked
case returns a plain-string `detail`; known wrong credential prefixes return a structured
`detail.code` so coding agents can fix the right plane.

| Condition                              | HTTP | `detail` shape                                                                | When it happens                                                                                                                                                                              | Retryable | Fallback                                                                                                                                                                                                                         |
| -------------------------------------- | ---- | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Missing key                            | 401  | **plain string** `"api key required"` (+ `WWW-Authenticate: Bearer`)          | No `Authorization: Bearer <key>` header.                                                                                                                                                     | No        | Send the key. Keep it server-side.                                                                                                                                                                                               |
| Invalid / revoked key                  | 403  | **plain string** `"api key invalid or revoked"`                               | The key does not resolve, or was revoked, or its app is inactive.                                                                                                                            | No        | Use a valid active key; reissue if revoked.                                                                                                                                                                                      |
| `production_not_active`                | 403  | `{ code, message, fix, top_up_url, readiness_endpoint, activation_endpoint }` | A `ck_...` runtime key belongs to a production app that has not passed readiness/activation yet.                                                                                             | No        | Follow the [Go live (production)](./go-live-production) steps: top up at `top_up_url`, set an app cap, call `production-readiness`, then `production-activation` to promote your existing app in place. Do not create a new app. |
| `account_token_used_as_runtime_key`    | 403  | `{ code, message, fix }`                                                      | A `lat_...` account token was sent to a runtime `/v1/*` route.                                                                                                                               | No        | Use `lat_...` only on `/v1/account/*`, then issue a `ck_...` runtime key.                                                                                                                                                        |
| `clerk_account_api_key_not_loreos_key` | 403  | `{ code, message, fix }`                                                      | A Clerk Account API key (`ak_...`) was sent to LoreOS.                                                                                                                                       | No        | Do not use Clerk's Account/API Keys UI. Issue a LoreOS Runtime API Key from the LoreOS console/account API.                                                                                                                      |
| `runtime_key_prefix_invalid`           | 403  | `{ code, message, fix }`                                                      | The bearer token does not look like a LoreOS runtime key.                                                                                                                                    | No        | Runtime API keys currently start with `ck_...`; account tokens start with `lat_...`; Clerk Account API keys start with `ak_...`.                                                                                                 |
| `api_key_expired`                      | 403  | `{ code, message, fix }`                                                      | A time-limited key (a demo sandbox key, or any key with an expiry) has expired.                                                                                                              | No        | Request a new sandbox key, or use a persistent account key.                                                                                                                                                                      |
| `demo_key_surface_disabled`            | 403  | `{ code, message, fix }`                                                      | A **demo sandbox key** tried a surface it is not allowed to use: managed Telegram, webhook delivery, visual-asset uploads, image generation, visual-asset lifecycle, or app-setting changes. | No        | Use a **persistent** developer key for that operation. See [Staging and evaluation](./staging-and-evaluation).                                                                                                                   |
| `demo_quota_exceeded`                  | 403  | `{ code, message, fix }`                                                      | A demo key hit its character / session / message cap.                                                                                                                                        | No        | Reuse the existing sandbox resource, or request a persistent developer key.                                                                                                                                                      |

## Demo sandbox key issuance (`POST /v1/demo/sandbox-key`)

These are specific to minting a no-auth demo key. The exact caps are in
[Authentication](./authentication#no-key-testing-demo-sandbox-key).
Call `GET /v1/demo/sandbox-key/availability` first. It returns a normal success envelope and
never creates demo state; branch on `data.status` and `data.issuance_available` rather than using
an issuance error as a health check.

| `code`                               | HTTP | When it happens                                                                                                         | Retryable                                                    | Fallback                                                                                                                                       |
| ------------------------------------ | ---- | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `demo_sandbox_disabled`              | 503  | Public demo issuance is turned off (operator flag / maintenance / abuse controls). `detail` is the `{code,…}` envelope. | No — issuance is off                                         | Use a persistent runtime key from self-serve account access, the console, or preview invite provisioning.                                      |
| `demo_sandbox_rate_limited`          | 429  | Too many demo keys were issued from this IP / fingerprint inside the issuance window.                                   | Yes, after the window (the `fix` tells you roughly how long) | Wait the window and retry, or use a persistent runtime key. Do **not** re-mint a demo key per eval run — use a persistent staging key instead. |
| `demo_input_too_large`               | 413  | A demo-key message exceeded the input-character cap.                                                                    | No                                                           | Shorten the message, or use a persistent developer key.                                                                                        |
| `demo_sandbox_challenge_required`    | 403  | The operator enabled a browser challenge and no `turnstile_token` was sent.                                             | No (until you provide a token)                               | Submit a Cloudflare Turnstile token, or use a persistent runtime key.                                                                          |
| `demo_sandbox_challenge_failed`      | 403  | The submitted challenge token did not verify.                                                                           | No                                                           | Retry the challenge, or use a persistent runtime key.                                                                                          |
| `demo_sandbox_challenge_unavailable` | 503  | Challenge verification was momentarily unreachable.                                                                     | Yes, later                                                   | Retry later, or use a persistent runtime key.                                                                                                  |
| `demo_sandbox_missing_client_ip`     | 400  | The request origin could not be identified for rate limiting (rare).                                                    | No                                                           | Retry from a normal browser, or use a persistent runtime key.                                                                                  |

> **Tip**
>
> For a visual POC or a repeated eval loop, do not fight the demo issuance rate limit — mint a
> **persistent staging key** through self-serve account access or preview provisioning and reuse it. See
> [Staging and evaluation](./staging-and-evaluation).

## Server / transient (`5xx` — retry with backoff)

| `code` (or shape)                                                                                                    | HTTP | When it happens                                                                 | Retryable            | Fallback                                                               |
| -------------------------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------- | -------------------- | ---------------------------------------------------------------------- |
| `internal_error`                                                                                                     | 500  | An unhandled server error on `/v1`. The envelope carries a `trace_id` in `fix`. | Yes (with backoff)   | Retry; if it persists, contact support with the `trace_id` from `fix`. |
| `image_engine_error`                                                                                                 | 502  | A transient failure in the image engine during a probe/inventory run.           | Yes                  | Retry.                                                                 |
| *(plain string)* "Database connection temporarily unavailable…"                                                      | 503  | The database was momentarily unavailable. `detail` is a **plain string**.       | Yes (with backoff)   | Retry after a moment.                                                  |
| *(plain string)* "API connection pool is busy…"                                                                      | 503  | The server's connection pool was saturated. `detail` is a **plain string**.     | Yes (with backoff)   | Retry after a moment.                                                  |
| `no_trace` / `unscoped_context` / `demo_sandbox_rate_limit_secret_missing` / `demo_sandbox_turnstile_secret_missing` | 500  | Server misconfiguration (these should not occur in normal operation).           | No (server-side fix) | Contact support; do not loop-retry.                                    |

## Failures that arrive as events, not HTTP errors

A reply is asynchronous, so a **reply that fails after `POST /messages` was accepted** does
**not** surface as an HTTP error — `POST /messages` already returned `202`-style `accepted`
with a cursor. Watch the event log for:

* **`message.failed`** (`role: "system"`) — the reply for a turn failed. Payload is
  `{ turn_index, reason, recoverable }`. If `recoverable` is true, the turn is eligible for
  the platform's reply-recovery sweep; if false, prompt the user to resend.

So a robust client handles errors in **two** places: the synchronous error envelope on the
call you made, and `message.failed` on the session event stream.

## Client checklist

* Branch on `body.detail.code` for the envelope errors; branch on **HTTP status** for the
  plain-string auth `401`/`403` and the infrastructure `503`s.
* Retry only `429` (after its window), `502`, `500 internal_error`, and the infrastructure
  `503`s — with backoff. Never blind-retry a `4xx` request error.
* Treat `409 session_exited` as terminal: open a new session.
* Treat `402 budget_exceeded` as "out of credits," not "try again now."
* Subscribe to `message.failed` so an async reply failure is not silently lost.