Skip to main content
Every failure on /api/v2/ is the same shape.
Switch on code, never on message.code is a closed vocabulary. message is prose written to be shown to a person, and it may be reworded without a version bump.
request_id is what to quote in a support ticket. It is also on the X-Request-Id response header, including on responses that carry no body. A failure that ends a run which had already started carries three more fields beside the error object — id, status and trace_id — so you can tie the failure to the run it ended:
Some endpoints add one extra key naming the resource the failure was about. Treat the error object as open.

The codes

blocked is not failed

Two of those seventeen are decisions rather than faults, and both end a run as blocked: Nothing went wrong upstream. There was no model failure. The platform declined to spend, or asked you to slow down. Filing either as failed is what makes an error-rate dashboard useless and a support answer wrong — “your integration is broken” when the truthful answer is “you are out of credits”.
402 is deliberate. The two obvious alternatives are both misleading: 403 says the caller lacks permission, which is a different fix, and 429 says slow down, which will never help. 402 is what a billing-gated API returns.
For a client, the practical split is:
rate_limited (429) — honour any Retry-After.provider_unavailable (502), knowledge_unavailable (503) — transient. Back off.
usage_blocked (402) — retrying will never help. Top up or raise the cap.permission_denied (403) — the key is missing a scope. The message names it.invalid_request (400), structured_output_failed (422) — the request or the schema has to change.
internal_error (500), tool_execution_failed (502).For anything that writes to your systems, read the unknown-outcome rule first. A timeout on a write is not a failure — it is an unknown.

Run statuses

A run ends in one of four terminal statuses: queued and running are in flight, and a caller polling one may see it change. cancelled carries no error object. Attaching a fault to a stop would put it back into every error-rate panel that counts one, which is exactly what giving it its own status was meant to avoid.

Nothing internal leaks

message is the only text that reaches you, and it defaults to a fixed per-code string. The diagnostic detail — the exception, the id that was guessed, the provider that timed out — is logged against request_id and is not serialised. There is no field that carries it outward. So a message never contains a stack trace, a provider name, a database id, or any prompt text.
A resource in another workspace and a resource that does not exist answer 404 identically, byte for byte. There is no 403 that confirms existence.A 403 would turn id-guessing into an inventory. This rule does not soften at the public boundary — it gets stricter, because this is where the guessing happens.
The one place this reads oddly is preview: a caller without agent:preview asking to run a draft gets 404, not 403. Confirming that a draft exists but may not be run is exactly the confirmation the rule exists to withhold. invalid_context is a 403 and not a 422, for a related reason. A request context naming something you may not reach is an authorization refusal wearing a payload’s clothes. It is not an existence oracle either: the check is set membership in your own grants, so a resource that does not exist and one that exists elsewhere answer identically.

Three codes you may not expect

Errors elsewhere in the platform

The /api/v1/ surface and the Reseller API predate this envelope and do not all use it. Everything under /api/v2/ does.