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 currency field. 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 derived NZD label, 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:

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 tagrepairer.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