Every error is a JSON body with an error field and the matching HTTP status. Some carry a
detail with a more specific code, and some carry extra fields (max_chars, retry_after,
reset_at).
{ "error": "profile_mismatch", "message": "That profile does not belong to the avatar this session resolved to.", "profile_id": "019fe4a0-…" }
POST /api/v1/sessions| Status | Error | Meaning | Retry? |
|---|---|---|---|
503 |
fleet_busy |
Full past the wait horizon, or no box registered | No — surface to the user |
503 |
custom_avatar_unavailable |
No live box can currently serve custom avatars | Yes, with backoff |
422 |
custom_avatar_unsupported |
Every registered box lacks custom-avatar support | No — a fleet-level condition |
503 |
no_box_available |
No box could be leased for this operation | Yes |
503 |
signing_key_not_configured |
Environment misconfiguration | No — contact support |
402 |
quota body | Cumulative minutes exhausted (anonymous tier); carries reset_at, retry_after |
After retry_after |
202 queued is not an error — see session lifecycle.
A 503 custom_avatar_unavailable means the platform knows of no box that has proven it can
materialize custom avatars. It clears on its own once the fleet recovers; stock avatars (persona)
usually still work meanwhile, which makes a stock fallback a reasonable degradation path.
| Status | Error | Meaning |
|---|---|---|
404 |
avatar_not_found |
No such avatar, or it is deleted |
400 |
avatar_not_published |
The avatar has no published version to mint against |
400 |
invalid_avatar_id |
Missing or non-string |
400 |
avatar_ref_avatar_id_exclusive |
Sent both — pick one |
400 |
avatar_ref_persona_exclusive |
Custom avatar and stock persona both named |
400 |
invalid_avatar_version_id |
Missing or non-string |
400 |
version_not_published |
The pinned version is not published/sealed |
403 |
avatar_not_in_workspace |
The avatar belongs to another workspace |
404 |
voice_version_not_found |
Unknown voice_ref |
403 |
voice_not_in_workspace |
The voice belongs to another workspace |
| Status | Error | Meaning |
|---|---|---|
400 |
invalid_profile_id |
Missing or non-string |
400 |
profile_mismatch |
The profile does not belong to the resolved avatar (or no avatar was resolved) |
400 |
invalid_context_system_prompt |
Not a string, or over 40,960 characters |
400 |
invalid_context_backstory |
Not a string, or over 16,384 characters |
400 |
system_prompt_too_long |
Profile authoring; carries max_chars |
400 |
backstory_too_long |
Profile authoring; carries max_chars |
400 |
invalid_config + detail |
unknown_config_key:*, config_not_boolean:*, config_not_integer:*, config_out_of_range:*, config_too_large |
400 |
invalid_config_json + detail |
Same vocabulary, profile authoring |
400 |
config_too_large |
Merged session+profile config over 1,024 bytes |
400 |
invalid_tools_json + detail |
Profile authoring: not_an_object, unknown_tools_field:*, invalid_tool_name, too_many_tools, tool_name_conflict:* |
400 |
invalid_field_type |
A profile text field was sent as a non-string |
409 |
profile_is_default |
Cannot delete the default profile — switch first |
403 |
stock_read_only |
Profile mutation on a stock avatar |
404 |
not_found |
Unknown avatar or profile on the profile routes |
All 403, all meaning “this credential kind may not send that parameter” — see
1. Authentication.
| Error | Parameter refused |
|---|---|
custom_avatar_not_allowed |
avatar_ref / voice_ref from a publishable key |
avatar_version_pin_not_allowed |
avatar_version_id |
profile_pin_not_allowed |
profile_id |
tools_context_not_allowed |
tools, extra_tools, context.*, config, extra_* |
session_cap_not_allowed |
session_cap_seconds |
extra_tools_not_allowed (400) |
extra_tools on the first-party /api/sessions route |
| Status | Error | Meaning |
|---|---|---|
400 |
invalid_json |
Body did not parse |
400 |
missing_params |
A required path parameter is absent |
401 |
unauthorized |
Missing/invalid credential |
403 |
forbidden |
Authenticated but not permitted |
403 |
workspace_mismatch |
A workspace-bound key named a different workspace in the path |
403 |
custom_avatars_disabled |
The workspace lacks the custom_avatars feature (authoring only); carries feature + workspace_id |
400 |
invalid_response_language |
Not a recognizable BCP-47 tag or language name |
400 |
invalid_observability |
Not a boolean |
400 |
invalid_session_cap_seconds |
Not an integer in 60–1200 |
400 |
agent_claim_too_large |
agent object over 1,024 bytes |
404 |
not_found |
Unknown session on the lifecycle routes |
403 |
invalid_seat_token |
The seat token does not match that session |
Retry only what is retryable. fleet_busy and custom_avatar_unsupported are terminal for
this attempt — retrying in a tight loop makes a capacity problem worse. custom_avatar_unavailable
and no_box_available are transient; exponential backoff starting around 2 s is reasonable.
Never retry a 4xx unchanged. Every 400/403 here is a statement about the request, not the
system’s mood.
Distinguish “busy” from “broken” in your UI. 202 queued with a position and ETA is a good
experience if you show it; a spinner that silently hides a queue is not.
switch (res.status) {
case 201: return connect(body);
case 202: return showQueue(body.position, body.eta_seconds), pollAgain(body.queue_ticket);
case 402: return showQuotaExhausted(body.reset_at);
case 503:
return body.error === 'fleet_busy'
? showAllLinesBusy() // terminal for now
: retryWithBackoff(); // custom_avatar_unavailable, no_box_available
default: return reportBug(body.error, body.detail);
}
Back to the index.