Skip to content

17 min read

Model-actionable error responses

On this page

A model-actionable error is one the caller can compute its next call from. The caller here cannot read your validator, search for the message, or ask a colleague — it has the tool schema it was given, the conversation so far, and nothing else — so every fact it needs in order to correct itself has to be in the response or it does not exist.

The information is almost always available at the moment of rejection and discarded before the response is written. A validator that has just finished checking membership against an enum holds the permitted set; a handler that rejected an amount holds the limit it compared against. What gets serialised instead is a sentence written for a human reading a log, and a human reading a log has the schema, the source and a search box. The strongest argument for leaving it that way is that agents recover anyway, which is true and is measuring the wrong thing.

A tool error is a compiler diagnostic for a caller who cannot read the source

Here is a support agent trying to close a ticket, with the tool results reproduced exactly as the model received them.

text
[turn 4] update_ticket(ticket_id="T-8841", status="OPEN")
      -> {"error": "Invalid status"}
[turn 5] update_ticket(ticket_id="T-8841", status="Open")
      -> {"error": "Invalid status"}
[turn 6] update_ticket(ticket_id="T-8841", status="ACTIVE")
      -> {"error": "Invalid status"}
[turn 7] get_ticket(ticket_id="T-8841")
      -> {"ticket_id": "T-8841", "status": "open", "assignee": "eng-3", ...}
[turn 8] update_ticket(ticket_id="T-8841", status="open")
      -> {"ticket_id": "T-8841", "status": "open", "updated_at": "2026-03-11T09:14:02Z"}

Five turns to change one field, four of them spent recovering information the server already had. The model's guesses are reasonable: uppercase, then title case, then a different word entirely, then a read call to find out what the field actually looks like in practice. Turn 7 is the interesting one, because it is the model building its own error message out of a successful read, having concluded that the failing endpoint was not going to tell it anything. That read cost 1,900 tokens of ticket JSON, most of it irrelevant, and it worked only because get_ticket happens to expose the same enum.

The same four turns collapse into one when the failure carries what the server already possessed at the moment it rejected the call:

json
{
  "error": "invalid_status",
  "field": "status",
  "message": "must be one of: open, pending, closed",
  "received": "OPEN",
  "retryable": false
}

The closest thing most engineers already have a strong opinion about is compiler error messages. C compilers spent decades emitting parse error before '}', which told you a rule had been violated somewhere near a location and left you to reconstruct which one. Rust and Elm made a deliberate project of the opposite: name the rule, quote the offending span, list what was expected, and where possible print the corrected form. The compiler always had that information; what changed was the decision to spend bytes putting it in the message rather than assuming the programmer would go and find it.

The parallel is close enough to be useful and it breaks in one place that matters. A programmer who gets parse error can open the grammar, read the standard, or search for the message. A model that gets {"error": "Invalid status"} cannot open your validator. So the burden that a compiler can partly offload onto a determined human sits entirely on the response, and each field in that body earns its place by removing one specific guess.

FieldWhat it does for the callerWhat happens without it
error — stable machine codeLets the model, the harness and your alerting all branch on the same token regardless of wording changesThe only identifier is prose that shifts whenever someone edits the string, so nothing downstream can key on it
fieldLocalises the fault to one argument out of the eight in the callThe model re-derives every argument, and frequently changes a correct one
message — the permitted set or the ruleTurns the next call into a correctionThe model samples plausible values, which is turns 4 to 6 above
receivedConfirms what the server actually parsed, which after context compaction the model may no longer have accuratelyCoercion and serialisation bugs look like validation bugs; the model argues with a value it thinks it sent
retryableSays whether time alone could change the outcomeEvery failure looks potentially transient

received is the field most often dropped as redundant, on the grounds that the model just sent the value and therefore knows it. That holds for a single turn and stops holding in a long-running agent whose earlier turns have been summarised, and it never held for values the model did not author directly — an id copied from a previous tool result, a date the harness reformatted, a number that arrived as a string. Echoing the parsed value costs about ten tokens and closes the gap between what the model believes it sent and what the server actually saw.

The message field carries a specific obligation: it should name the permitted values when they are enumerable, and state the rule when they are not. must be one of: open, pending, closed is complete. For an unbounded field the equivalent is a rule plus one concrete instance — must be an ISO 8601 timestamp in UTC, e.g. 2026-03-11T09:14:02Z — because a rule alone leaves format questions open and an example alone leaves the boundary unstated. Where the correct move is a different call rather than a corrected argument, say that instead: ticket must be assigned before it can be closed; call assign_ticket first gives the model a sequence, which is a thing it can follow, in place of a constraint it has to infer.

Why a rule and an example are both needed, when either sounds sufficient, comes out of what each one under-determines. A rule names the family of acceptable values and leaves the surface form open: told to send an ISO 8601 timestamp in UTC, a caller still has to decide whether to write a Z, an offset, or fractional seconds, and the specification permits several of those. An example fixes the surface form and leaves the family open: shown one timestamp, a caller cannot tell which parts of it were required and which were incidental to that particular value. Give both and the two constraints intersect, which is the smallest amount of text that pins down a single thing to send. The same pairing is why the enum case needs no example — listing every permitted value is both the rule and the instances, so there is nothing left under-determined.

When the permitted set is too large to print

Three statuses fit in a message comfortably. A country_code field with 249 ISO 3166 values does not, since listing them costs about 620 tokens on every rejection, and a sku or customer_id field has no enumerable set at all. The obligation does not disappear at that point, it changes form: state the rule, then narrow the space with whatever the server can compute cheaply from the value it was given.

The 620 is worth deriving rather than trusting, because the derivation is what tells you when the threshold has been crossed on some other field. A two-letter code with the comma and space after it comes to roughly two and a half tokens, for the same reason short machine-shaped strings tokenize badly anywhere: there are no long merges in the vocabulary for sequences that are not words. Two and a half tokens across 249 codes is a little over six hundred. Set that against the 30-odd tokens a well-formed error body costs and the list is some twenty times the response it would sit inside — paid on every rejection, for a field where the caller almost always sent something recognisable.

json
{
  "error": "unknown_country_code",
  "field": "country_code",
  "message": "must be a two-letter ISO 3166-1 alpha-2 code",
  "received": "UK",
  "did_you_mean": ["GB"],
  "retryable": false
}

did_you_mean comes from an alias table and an edit-distance pass — how many single-character changes separate the received value from a valid one — over the 249 codes, which runs in about 0.2 ms and is only reached on a failure. Aliases matter more than edit distance here, because the values that show up wrong are mostly plausible other names rather than typos — UK for GB, EN for GB, USA for US — and those are the substitutions a model makes when it is generating from world knowledge rather than from your schema. Where the value space is genuinely open, such as a customer id, the useful thing to name is the call that produces valid ones: no customer matches 'acme-corp'; resolve an id with search_customers(query). That gives the model a two-step path it can execute, in place of an invitation to try a differently formatted guess.

The reason aliases beat edit distance on this particular field is the mechanism that produced the wrong value. An edit-distance pass assumes the caller was aiming at the right string and slipped, which is what happens to a human typing into a form. A model is not typing; it is producing the token sequence that best fits everything it knows, and what it knows about the United Kingdom includes the letters UK far more strongly than it includes GB. That value is not a near miss at GB — the two share no characters at all, so no edit distance brings them together — it is a correct answer to a different question. So the repair has to be a table of the substitutions world knowledge actually makes, and it is worth populating that table from your own rejection logs rather than from imagination, since the logs record which wrong answers your callers reach for.

One error per response means one correction per round trip

A validator that returns on the first failure imposes its own serialisation on the caller. A create_ticket call carrying an uppercase status, a priority of high where the enum is p1/p2/p3, and a due date written as next Friday is three faults in one call, and a fail-fast validator turns them into three rejections and three model round trips, at roughly 2 to 3 seconds and 1,500 tokens each. All three checks are independent and all three had already been evaluated in the same request handler.

json
{
  "error": "validation_failed",
  "message": "3 fields were rejected. Correct all of them and call again.",
  "errors": [
    {"field": "status", "code": "invalid_status",
     "message": "must be one of: open, pending, closed", "received": "OPEN"},
    {"field": "priority", "code": "invalid_priority",
     "message": "must be one of: p1, p2, p3", "received": "high"},
    {"field": "due_at", "code": "invalid_format",
     "message": "must be an ISO 8601 timestamp in UTC, e.g. 2026-03-11T09:14:02Z",
     "received": "next Friday"}
  ],
  "retryable": false
}

Batching also removes a failure the serial version invites, which is collateral damage on fields that were correct. A model re-emitting an eight-argument call to fix one named field regenerates the other seven, and a value it derived earlier from a tool result — an id, a timestamp it reformatted — sometimes comes back subtly different. Each extra round trip is another chance for that, so three sequential corrections are three opportunities to break something that worked, while one response listing every fault gives the model a single edit to perform.

That regeneration is worth being precise about, because "the model corrects the field" is a description of the intent rather than of the mechanism. There is no edit operation available to a caller that speaks in generated text. To change one argument, the model emits the whole call again — the tool name, and every argument in it — and each of those arguments is produced afresh from the conversation rather than copied from the previous turn. The named field is the one it is attending to and will get right. The other seven are re-derived, and a value it originally lifted out of an earlier tool result, or reformatted from something a user wrote, is re-derived from the same material and can come back subtly different. So the number of arguments at risk on any correction is all of them, and the number of corrections is what multiplies that risk. Naming the field does not reduce the seven; batching the faults reduces the number of times the seven are put through it.

The batching applies only to checks that can run independently, which in practice means argument shape and enum membership. Authorisation, existence and business rules run in sequence for a reason, since there is no honest way to tell a caller that a ticket's status is invalid and that the ticket does not exist, and reporting on a record you have not confirmed the caller may read is a disclosure. The practical split is a validation pass over the arguments that collects every violation, followed by the ordered checks that stop at the first one. The array wants a cap as well — ten entries is generous — so that a malformed call against a forty-field payload returns a bounded body rather than 2,000 tokens of diagnosis the model will not read to the end.

Completion hides the cost of a bare error

The transcript above ends in success. Every turn was logged, the ticket was closed, and the task completed. On any metric that asks whether the agent achieved its goal, that run is a pass, which is why bare error strings survive review so comfortably: the evidence against them does not appear in the outcome column.

It appears in three other places. The first is cost and latency, which scale linearly with the wasted turns — four extra model round trips at roughly 2 to 3 seconds and 1,500 to 2,500 tokens each, against a fix that costs about 30 tokens of response body once. On a workflow running 40,000 times a month, four avoidable turns is somewhere near 300 million tokens of pure recovery traffic, and the number is stable enough to put in a budget.

The second is the distribution rather than the mean. Sampling — the model draws each token from a distribution rather than always taking the likeliest one, so two runs of the same failing call diverge — makes recovery a random variable, so the honest summary of a bare error is not "the model gets there in four turns" but a spread: in an internal measurement across 200 runs of one ticket workflow, 68% recovered within three attempts, 24% took four to seven, and 8% never produced a valid status at all before hitting the harness turn limit, at which point the agent told the user the ticket could not be updated. The mean looks acceptable; the tail is a support ticket.

The third is that the recovery path is doing work you did not design and cannot see. Turn 7 succeeded because get_ticket happens to return the status in its canonical lowercase form. Remove that field from the read response, or add a status_label that renders it as "Open" for the UI, and the recovery path breaks in a way that has nothing to do with update_ticket. Agents that recover by inference are coupled to incidental properties of unrelated endpoints, and those couplings are invisible until they snap.

The first of those three is the only one that can be settled with arithmetic, and both multiplications are worth performing. Four wasted turns at the middle of the 1,500-to-2,500 band is around eight thousand tokens per affected run, and 40,000 runs a month at eight thousand tokens is somewhere around 300 million. The comparison it invites is the one that matters: the fix is about 30 tokens of response body, paid once per rejection rather than once a month, and the ratio between the two is large enough that the precision of either figure stops mattering. What that arithmetic assumes is that every run reaches the error path, which is the condition to state when quoting it — apply the figure to the share of runs that actually fail rather than to all 40,000, and it falls in proportion while the ratio does not move at all.

The second reads off a partition. Those three shares cover the same 200 runs and add up to all of them, which is what makes the tail readable rather than anecdotal: nothing has been left out of the summary and the last group is not a rounding artefact. The mean sits inside the first two groups and says nothing whatever about the third, because the third has no number of attempts in it — those runs did not recover, so there is no attempt count for an average to include. That is the specific way an average misleads here, and it is not imprecision. It is that the statistic is computed over the runs that finished, while the runs that did not finish are the ones the argument is about.

A fixed enum belongs in the schema, not only in the error

A well-formed error does not compensate for a schema that omitted the constraint. If status is a fixed three-value enum, it belongs in the tool schema as an enum, where it shapes generation before a call is ever made rather than correcting it afterwards; the error path then handles the residue — stale values, race conditions, constraints the schema cannot express such as "closed tickets cannot be reopened after 30 days". Treating the error body as the place where the contract lives means every caller learns the contract by violating it.

The mirror of that belief is at least as common and belongs beside it: that a schema carrying the enum makes the error body unnecessary. It is tempting for a good reason — a constraint declared in the schema is in the model's context before it generates anything, which is earlier and therefore looks strictly better than correcting afterwards. What it misses is that a schema constrains what the caller is told, not what the caller sends. Generation is not validation. A value the schema forbids can still be emitted, and it is emitted most readily for the values a model holds strong prior beliefs about — a status it has seen rendered as Open in a thousand interfaces. So the schema shapes the first attempt and the error body handles whatever the shaping did not catch. The two are not alternatives, and removing either leaves a real class of failure with nothing addressing it.

There is also a real limit on how much to say. The temptation once you accept the principle is to return the full schema on every validation failure, or a paragraph of guidance, and that inverts the economics: a 1,200-token error body sent on 5% of calls costs more than the turns it saves, and a long error competes with the rest of the context for the model's attention. The permitted set, the offending value, the rule and a pointer to the right next call is roughly where the return diminishes.

Two audiences read the same failure

The error body is text that goes into a model's context and therefore functions as an instruction, which means any user-controlled data you interpolate into message is a channel into the agent's reasoning — echo received as a quoted value, never as free prose that says "the user asked for X, so do Y". And the model is not the only reader: your on-call engineer wants the row id, the tenant and the internal reason, and those belong in the log, keyed by the same stable error code, rather than in the body.

An error body written for a model is a short, structured statement of what the server already knew when it said no: a stable code, the field at fault, the value it parsed, and either the permitted set or the rule that value broke. That converts the next call from a sample of plausible strings into an edit, which is the difference between one round trip and four — and, in the tail that never appears in a success rate, between a closed ticket and a user being told the system could not do it.

All of this assumes the model has something better to send. The harder failures are the ones where it does not, and no rewording of the message changes that: a slot booked two seconds ago, a balance below the requested amount, an account that will never permit this operation. In those cases the response has to say that repetition is futile, and say it in a form the model can act on before it has spent four turns discovering it.

Tool design — semantics — read the current version at https://daviddominguez.dev/learn/en/tool-design-semantics/model-actionable-error-responses

Back to top