Errors and retries

Every error an adapter hits, whether to retry it, and the in-product fallback.

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

1{
2 "detail": {
3 "code": "budget_exceeded",
4 "message": "app credit limit reached",
5 "fix": "Raise the app budget (budget_policies.limit_credits) or wait; see /v1/rates."
6 }
7}

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.

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 503s 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 503s 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, 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.

codeHTTPWhen it happensRetryableFallback in your product
validation_error422Any request-body field fails schema validation (wrong type, out of range, missing required field). The failing field path is in message.NoSurface 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_character422POST /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).NoFix the named range; re-validate before create.
invalid_update422PATCH /v1/characters/{slug} — the update body failed validation.NoSame as invalid_character; correct the field and resend.
invalid_affordance_schema422affordance_schema is not a known preset (dating/tutor), a { "preset": "…" } pointer, or a valid full schema with unique dimension keys.NoUse a built-in preset or a full inline schema. See Character authoring → affordance schema.
slug_taken409The character slug is already owned by another app. (Reusing your own app’s slug is fine.)NoChoose a different slug.
no_updates400A PATCH/settings call sent no changeable fields.NoSend at least one field to change.
invalid_environment422POST /v1/apps with environment not sandbox/production.NoPass sandbox or production.
invalid_app_id422An app_id path/body value is not a UUID.NoUse an app_id from GET /v1/apps.
invalid_enforcement422POST /v1/budget-policies with enforcement not hard/soft.NoUse hard or soft.
invalid_group_by422GET /v1/usage?group_by= is not a supported dimension.NoUse resource_type (default) or another supported dimension named in message.
per_user_policy_unsupported422POST /v1/budget-policies carried external_user_ref (this route is app-level only; a per-user policy here would not be enforced — fail-fast).NoCap one end-user with POST /v1/external-users/{ref}/budget-policy (or PUT /v1/users/{ref}/limit).
invalid_category422A visual-asset upload or inventory/generate used an unknown category.NoUse a category from the list in message.
invalid_asset422A visual-asset upload is missing asset_key/label.NoProvide both.
invalid_state422PATCH .../visual-assets/{id} sent an unknown promotion/qa/asset-status enum value.NoUse an allowed enum value.
invalid_probe_type422POST .../image-probe used an unknown probe_type.NoUse one of the probe types listed in message.
batch_too_large422POST .../inventory/generate requested more than the per-batch image cap (categories × count_per_category).NoReduce categories × count per category to the cap named in message.
image_rejected422An uploaded image was not a usable PNG/JPEG/WebP.NoUpload a valid image.
missing_scope_owner422A Story Room run used a scope that needs a scope_owner_id/session_id/thread_id and none was provided.NoProvide the scope owner, or use character_global scope.
invalid_story_room_mode422A Story Room run requested fixture_shadow (a local CLI/test mode, not a BaaS mode).NoUse db_shadow, review, or autocommit.
invalid_story_room_run422A Story Room run had invalid mode/autocommit flags.NoFix the mode and autocommit flags.

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, …).

codeHTTPWhen it happensRetryableFallback in your product
session_exited409You 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_identity409An 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.NoUpload 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_version409GET .../runtime-preview (or similar) for a character with no active published version.NoCreate/republish the character via POST or PATCH /v1/characters.
image_probe_failed409An 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_activatable409POST .../story-room/candidates/{id}/approve on a candidate that cannot be activated (reason in message).NoReject it or rerun Story Room after fixing the source issue.
candidate_not_rejectable409POST .../story-room/candidates/{id}/reject on already-committed history.NoAppend a compensating event instead of rejecting committed history.

Budget (402 — before any spend)

codeHTTPWhen it happensRetryableFallback in your product
budget_exceeded402A 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 resetsRaise the app budget (budget_policies.limit_credits) or the user cap (PUT /v1/users/{ref}/limit); 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. Two of them (401, and the invalid/revoked 403) return a plain-string detail, so do not read detail.code for those — branch on the HTTP status.

ConditionHTTPdetail shapeWhen it happensRetryableFallback
Missing key401plain string "api key required" (+ WWW-Authenticate: Bearer)No Authorization: Bearer <key> header.NoSend the key. Keep it server-side.
Invalid / revoked key403plain string "api key invalid or revoked"The key does not resolve, or was revoked, or its app is inactive.NoUse a valid active key; reissue if revoked.
api_key_expired403{ code, message, fix }A time-limited key (a demo sandbox key, or any key with an expiry) has expired.NoRequest a new sandbox key, or use a persistent account key.
demo_key_surface_disabled403{ code, message, fix }A demo sandbox key tried a surface it is not allowed to use: app creation, API-key issuance, budget-policy changes, visual-asset uploads, image generation, visual-asset lifecycle, or webhook delivery.NoUse a persistent developer key for that operation. See Staging and evaluation.
demo_quota_exceeded403{ code, message, fix }A demo key hit its character / session / message cap.NoReuse 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.

codeHTTPWhen it happensRetryableFallback
demo_sandbox_disabled503Public demo issuance is turned off (operator flag / maintenance / abuse controls). detail is the {code,…} envelope.No — issuance is offUse an invite key; invite keys always work. Email daniel@contxts.io.
demo_sandbox_rate_limited429Too 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 an invite key. Do not re-mint a demo key per eval run — use a persistent staging key instead.
demo_input_too_large413A demo-key message exceeded the input-character cap.NoShorten the message, or use a persistent developer key.
demo_sandbox_challenge_required403The operator enabled a browser challenge and no turnstile_token was sent.No (until you provide a token)Submit a Cloudflare Turnstile token, or request an invite key.
demo_sandbox_challenge_failed403The submitted challenge token did not verify.NoRetry the challenge, or request an invite key.
demo_sandbox_challenge_unavailable503Challenge verification was momentarily unreachable.Yes, laterRetry later, or request an invite key.
demo_sandbox_missing_client_ip400The request origin could not be identified for rate limiting (rare).NoRetry from a normal browser, or request an invite key.

For a visual POC or a repeated eval loop, do not fight the demo issuance rate limit — mint a persistent staging key (POST /v1/appsPOST /v1/api-keys, which requires an existing key for your tenant) and reuse it. See Staging and evaluation.

Server / transient (5xx — retry with backoff)

code (or shape)HTTPWhen it happensRetryableFallback
internal_error500An 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_error502A transient failure in the image engine during a probe/inventory run.YesRetry.
(plain string) “Database connection temporarily unavailable…“503The database was momentarily unavailable. detail is a plain string.Yes (with backoff)Retry after a moment.
(plain string) “API connection pool is busy…“503The 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_missing500Server 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 503s.
  • Retry only 429 (after its window), 502, 500 internal_error, and the infrastructure 503s — 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.