Error codes
error_code on a tool call is a stable public contract. Codes are added, never repurposed, so a client may switch on the string and keep working across releases.
A refused call is a successful RPC. ExecuteTool answers with a ToolCall whose status and error_code say what happened; it does not raise a transport error. Read the code and act on it rather than retrying blindly.
| Code | When it fires |
|---|---|
rate_limited | Upstream answered HTTP 429, or the toolkit's declared rate_limit budget was empty when the call arrived. Transient: retry after the window refills. The detail ends in a retry_after_seconds value when a budget refusal or a provider Retry-After named one. |
auth_expired | The connected account is expired, refresh failed, or upstream answered 401 or 403. The end user has to authorize again. |
invalid_arguments | The tool slug is unknown, arguments_json will not unmarshal, a required path parameter is missing, or upstream answered 400 or 422. Fix the arguments; a retry of the same call fails the same way. |
not_connected | The entity holds no active connected account for the tool's toolkit. Start a connection before calling again. |
denied | The mutation gate refused a destructive tool for want of confirm: true. Tell the user what would be removed, then call again with "confirm": true inside arguments_json, beside the tool's own fields. This message declares no confirm field of its own, and one sent at the request level is ignored. |
upstream_error | A transport error, a body-read error, upstream 5xx, or an unhandled status code. Transient for read tools, which execution retries. |
upstream_timeout | The external app did not answer inside the timeout, or answered HTTP 408. |
internal | atmon failed on its own side: a store read errored, an authorization error was unrecognized, the policy gate itself errored, or an on-prem relay answered an error code this taxonomy does not carry, which means the relay is built against another vocabulary. In the relay case the detail quotes the code the relay sent, so the row still says what happened even though the code is this platform's. |
policy_denied | A policy rule refused the call, or entity visibility hides the tool from this entity. Final for the arguments as given. |
velocity_exceeded | A policy velocity limit had no token left. Worth retrying once the window refills. |
approval_pending | A policy approval gate parked the call. The response carries status TOOL_CALL_STATUS_PENDING_APPROVAL and an approval_id; retry with that id once a human approves it. |
outcome_unknown | An earlier attempt claimed this call's idempotency key and never recorded an outcome, which is what a process death between the two leaves behind. The call was not repeated, because repeating it could double an effect the app already applied. Account the row as unresolved: it is never a success and never a clean failure. |
resource_leased | Another caller holds the resource this call would mutate. The detail names the holder and the expiry, so a retry is worth making once the hold ends. |
connection_incomplete | The toolkit's base_url names account variables (a subdomain, an application id, a cluster address) and this account carries no value for one of them, so the connection cannot say where its calls go. The account exists and may hold a live credential, so not_connected would be wrong. Reconnect with the missing value; no retry helps. |
budget_exhausted | The project's provider quota for the toolkit is spent for the window. Distinct from rate_limited, which is a pace the provider or the client sets: this window is a budget a person wrote, so the call comes back when the window rolls or when someone raises the number. The detail names the scope whose ceiling refused it and the instant the window resets. |
tool_not_in_snapshot | The project's pinned catalog snapshot does not carry this tool. Routing searches the latest catalog, so a slate can name a tool a pinned environment cannot run. Promote a snapshot that holds it rather than retrying. |
provider_unavailable | The deployment holds an open incident for this tool's provider, because enough calls to one toolkit at one resolved host failed, from enough separate projects, that the provider rather than any one tenant's connection is the cause. The call was refused before it took a lease, spent a quota or resolved a credential, so its idempotency key is still free. No operator action releases it: probe traffic reopens the path as soon as the provider answers. The detail names the incident. |
relay_unavailable | This tool runs on an on-prem relay, because its toolkit declares execution: relay, and the platform could not hand the call to one. No relay is connected for the selector, the relay was revoked, its work stream dropped mid-call, or the deployment connects no relay at all. The call is refused rather than queued, because a customer network this deployment cannot dial is not a wait anyone can bound. The detail names the selector and the last heartbeat, so the answer says how long the host has been gone. Nothing was sent, so the idempotency key is still free and the retry once the relay dials back in runs the call for real. |
What to do with each
Every code carries its own answer, from the same source as the table above. A code that appears there and not here would be a code nobody said what to do with.
| Code | Retry the same call? |
|---|---|
rate_limited | Yes, once the window refills. The retry_after_seconds value in the detail says when. |
auth_expired | No. The end user has to authorize the account again first. |
invalid_arguments | No. Fix the arguments; the same call fails the same way. |
not_connected | No. Connect an account for this entity and toolkit first. |
denied | Only with "confirm": true in the call's own arguments, after telling the user what would change. |
upstream_error | Yes. A read tool was already retried up to three times before this answer came back; retry a write yourself only under the same idempotency key. |
upstream_timeout | Yes, on the same terms as upstream_error. |
internal | Once. If it repeats, the fault is on our side rather than in the call. |
policy_denied | No. A rule refused these arguments; different arguments may pass. |
velocity_exceeded | Yes, once the limit's window refills. |
approval_pending | Yes, carrying the approval_id, once a human approves it. |
outcome_unknown | Not blindly. The effect may already have landed, so read the app's own state before deciding whether to call again. |
resource_leased | Yes, once the hold the detail names expires. |
connection_incomplete | No. Reconnect the account with the missing value first. |
budget_exhausted | No, until the window the detail names rolls or somebody raises the ceiling. |
tool_not_in_snapshot | No. Promote a catalog snapshot that carries the tool. |
provider_unavailable | Yes, and nothing has to be done first: probe traffic reopens the path as soon as the provider answers. |
relay_unavailable | Yes, once the relay dials back in. Nothing was sent, so the call runs for real. |
Statuses
ToolCall.status is coarser than the code and is what a UI shows:
| Status | Meaning |
|---|---|
TOOL_CALL_STATUS_SUCCEEDED | The external app answered and the result is shaped to the output schema. |
TOOL_CALL_STATUS_FAILED | The call ran and failed. error_code says how. |
TOOL_CALL_STATUS_DENIED | Refused before any credential was resolved: the mutation gate or a policy rule. |
TOOL_CALL_STATUS_PENDING_APPROVAL | Parked on a human. approval_id is what releases it. |
TOOL_CALL_STATUS_RUNNING | In flight. |
Every one of these is recorded. A denial and a park leave a receipt exactly like a success, which is what makes the record of what happened complete rather than best-effort.
The limits behind four of these codes, and what to do about each, are in Limits. What an assistant should say when it meets one is in The four verbs.