Errors
Errors in Rails Sandbox are written to be read by LLMs as well as by humans. Each failure is a small, coded value with a stable type (or, in one historical method, a bare string) — never a stack trace, never prose-only. An agent can branch on the code; a human can read the same payload. This page is the reference for what comes back, what each code means, the next action to take, and the one rule that matters most: no server contract error is ever retryable — take a different action, never a blind retry.
Rails Sandbox is a Rails-compatible API: a faithful mock of the public Integrations API (
2026-01) plus a small set of clearly-labelled proposed extensions. It is not vendor documentation.
See also: the Lifecycle (where each error can occur in the flow) and the API Reference (per-method error unions). Machine-readable surfaces: /llms.txt, /llms-full.txt, /openapi.json.
The wire error model
There is no shared error envelope. Rails Sandbox does not wrap failures in a common {code, message, retryable} object. Instead, each method declares its own error union, and the variant object is the response body. A handler signals failure by throwing; the transport renders that variant plus an HTTP status.
The standard form is a type-tagged object — a single discriminant field, type:
{ "type": "not_found" }
The standard variants, with their HTTP status (the status is mock realism — the OpenAPI spec declares only 200 and default, so the body's type is the contract signal, and the status is there because a real HTTP client expects one):
| Wire body | HTTP | Meaning |
|---|---|---|
{ "type": "unauthorized" } |
401 | API key or Partly-Integration-ID missing/invalid. |
{ "type": "forbidden" } |
403 | Credentials valid but wrong scope (e.g. a supplier-only method called with a repairer credential). |
{ "type": "not_found" } |
404 | No record matches the id/identity, or it is out of your org's scope. |
{ "type": "bad_request" } |
400 | Identity/selector missing, ambiguous, or both-and-neither. |
{ "type": "unprocessable", "message": "…" } |
422 | The request is well-formed but rejected; the only standard variant carrying a message. |
{ "type": "work_provider_not_found" } |
404 | The work_provider_id (insurer) does not exist. |
The install method (integrations.insert, the only unauthenticated method) has its own union — see Authentication: integration_not_found (404), o_auth_not_supported_by_integration (403), invalid_client_secret (401), invalid_access_code (401).
The bare-string asymmetry (D24)
There is exactly one exception to the type-tagged shape. repairer.jobs.parts.insert (the identify_parts MCP tool) returns its error variants as bare JSON strings, not {type} objects. Its union is a union of string consts: "unauthorized" | "forbidden" | "not_found" | "unprocessable".
The same logical error therefore has two different wire shapes depending on the method. Compare a not_found:
{ "type": "not_found" }
"not_found"
The first is what every standard method returns. The second — a bare JSON string, the literal four characters "not_found" as the entire response body — is what repairer.jobs.parts.insert returns.
Why reproduce this instead of normalizing it? Because it is faithful to the real the contract: JobsPartsInsertErrorV1 in the OpenAPI spec is a oneOf of bare string consts, while sibling error unions are oneOf of {type} objects. Smoothing it to {type:"not_found"} for this one method would be a fidelity regression — a documented mock should match the contract it claims to mock, warts included. A conformance test asserts the negative: {type:"not_found"} is non-conformant for this method. This is a labelled simplification only in the sense that the asymmetry is real and we did not paper over it.
If you call this method directly over the wire, branch on the string value, not on a .type field. If you call it through the Rails MCP, the asymmetry is already hidden for you: the MCP layer maps both shapes to a single uniform code, so an agent never sees the difference.
Common coded variants, by method group
Each row is the code an agent will see and the next step that resolves it. (For methods reached through the MCP, this is also the message you get back — the message names the next action so the agent self-corrects from the message alone.)
Auth and scope (every authenticated method)
| Code | Meaning | Next step |
|---|---|---|
unauthorized |
API key or Partly-Integration-ID missing/invalid. A configuration issue, not a workflow step. |
Fix the credentials; do not retry or re-auth in a loop. Through the MCP the server already holds demo creds — this should not occur. |
forbidden |
Credentials valid but lack the scope. Supplier-only actions (confirm_procurement) need the supplier role; repairer actions need the repairer role. |
Use the correct-scope tool / credential pair. Do not retry with the same scope. |
bad_request |
Identity/selector missing, ambiguous, or both-and-neither. | Send exactly one well-formed identity (a job id XOR an external reference), then retry. |
Jobs
| Code | Meaning | Next step |
|---|---|---|
work_provider_not_found |
The work_provider_id (insurer) does not exist. |
List valid providers via list_work_providers, or omit the field for a cash / self-pay job. |
not_found |
No record matches the id/identity (or it is out of scope). | Re-check the id via get_job / track_procurement, then retry. |
Parts (repairer.jobs.parts.insert — bare-string on the wire, enriched uniformly via the MCP)
| Code | Meaning | Next step |
|---|---|---|
not_found |
The job you are adding parts to does not exist. | Call open_repair_job first (or fetch the right id via get_job), then re-submit the parts. |
unprocessable |
A part was rejected — each part needs a description (or an explicit identity). | Provide a free-text description (and quantity) for every part, then retry; this tool resubmits the full list for you. |
Basket
| Code | Meaning | Next step |
|---|---|---|
not_found |
No basket: the job id/identity does not resolve to a job. | Open it with open_repair_job, add parts with identify_parts, then call recommend_basket. A job that exists but has no priced parts returns an EMPTY basket, not this error. |
The basket response carries no
currencyfield. Currency is a procurement-level concept (D52) — it appears later in the lifecycle on the procurement (currency_code), never on a basket offer. Any code that surfaces a currency on a basket offer is inventing it. The MCP's projected basket may show a derivedNZDlabel, but that is a presentation-layer convenience, not a wire claim.
Procurement (place / get / invoices)
| Code | Meaning | Next step |
|---|---|---|
unprocessable (place) |
Your offer selection is not in the job's latest basket — unknown sellable_id/supplier_business_id, quantity over the offer, or mismatched job_part_ids. |
Re-fetch recommend_basket, pick an offer it returned, then retry. |
not_found (get) |
No procurement matches that id. | Get a valid id from place_procurement, or list a job's procurements via get_job, then retry. |
bad_request (invoices) |
Selector missing or doubled. | Provide exactly one selector — a procurement_id OR a job selector (job_id / external_id), not both, not neither. |
Confirm (supplier.procurements.confirm)
| Code | Meaning | Next step |
|---|---|---|
not_found |
One of three causes: (1) the procurement id is unknown, (2) it is not in order_requested (already confirmed/cancelled), or (3) it belongs to a supplier this server cannot act as. |
Re-check with track_procurement. If it still shows order_requested, the order is with a different supplier — re-place it via place_procurement selecting the seeded supplier's offers. |
forbidden |
The procurement belongs to a different supplier org. | Do not retry with the same scope. |
Retryability: never blind-retry a contract error (D41)
No server contract error is ever retryable. A a contract error always needs a different action — a fixed input, a different id, the correct-scope tool — never the same call sent again.
retryable: true is reserved exclusively for transport faults — conditions where the call never completed or never reached a contract handler, so nothing about your inputs is wrong. There are exactly two:
| Transport fault | retryable |
Meaning | Action |
|---|---|---|---|
network_error |
true |
The call did not complete; the request never reached the API. (http_status: 0.) |
Nothing in your inputs is wrong — retry the same call as-is. |
non_json_response |
true |
The server returned a non-JSON body, likely a transient edge error — not a contract error. (Carries the real upstream status.) | Not an input problem — retry the same call as-is. |
Every other code — every coded contract variant, plus the SDK-local invalid_request_body and the unknown / internal fallbacks — is retryable: false. The semantic the MCP attaches to the field is exact: retryable: true means "retry the same call verbatim (transport only)"; retryable: false means "fix or act first." An agent that treats a 403 forbidden as retryable will loop forever; the contract is designed so it cannot.
Worked example: place_procurement with a non-confirmable supplier (D53)
This is the most instructive error in the system, because it is caught before the wire and explains itself fully.
place_procurement is itself a clearly-labelled proposed extension:
(Proposed extension beyond the public 2026-01 API — the public contract stops at
order_confirmed; this completes the buyer loop.)
The demo seed ships three supplier organizations but only one supplier credential — S1, "Christchurch Toyota — Parts" (business_id 0b000000-0000-4000-8000-000000000010). Confirming a procurement is a supplier-scope action, and a supplier can only confirm its own orders. So an order placed against S2 or S3 offers could be created, but it could never be confirmed — it would be a dead-end stuck in order_requested, unable to reach order_confirmed.
Rather than let an agent dig that hole, place_procurement fails fast. If any selected offer's supplier_business_id is not S1's, the tool refuses the call up front and returns:
code:unprocessableis_extension:true(it is the extension tool reporting)http_status:0— the call never reached the API; this is a local guard
The guidance message names the only confirmable supplier and points back to recommend_basket:
No order was placed. All selected offers must come from a SINGLE supplier this
server can later confirm as, and this demo holds ONE supplier credential —
"Christchurch Toyota — Parts" (its OEM "Toyota Genuine …" offers in
recommend_basket). An order placed with any other supplier could never reach
order_confirmed, so it is refused up front rather than left as a dead-end
order_requested. Re-run recommend_basket and select offers whose
supplier_business_id is Christchurch Toyota — Parts, then retry place_procurement.
A per-call detail is appended, naming the offending ids and the confirmable supplier:
You selected offers from supplier_business_id(s) <ids>. The only confirmable
supplier in this demo is "Christchurch Toyota — Parts"
(business_id 0b000000-0000-4000-8000-000000000010).
Two craft details worth noting. First, this guard uses a synthetic method tag — repairer.procurements.insert.supplier_scope, which is never a real wire method — so its message does not collide with the genuine repairer.procurements.insert|unprocessable ("offers not in basket") guidance above. Second, because http_status is 0 and the code is unprocessable (not a transport fault), this error is not retryable: the agent must change its offer selection, exactly as the message instructs. That is the whole point of the design — the error tells an LLM what went wrong and what to do next, and the recovery is a different action, not a retry.
Where to go next
- Lifecycle — where each error appears in the procurement flow, and the faithful-vs-extension boundary.
- API Reference — the per-method error union for every wire method.
- Authentication — the install (
integrations.insert) error variants and the header contract.