Chapter 3 of 7 · 17 min read
Model-actionable error responses
On this page
- A tool error is a compiler diagnostic for a caller who cannot read the source
- When the permitted set is too large to print
- One error per response means one correction per round trip
- Completion hides the cost of a bare error
- A fixed enum belongs in the schema, not only in the error
- Two audiences read the same failure
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.
[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:
{
"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.
| Field | What it does for the caller | What happens without it |
|---|---|---|
error — stable machine code | Lets the model, the harness and your alerting all branch on the same token regardless of wording changes | The only identifier is prose that shifts whenever someone edits the string, so nothing downstream can key on it |
field | Localises the fault to one argument out of the eight in the call | The model re-derives every argument, and frequently changes a correct one |
message — the permitted set or the rule | Turns the next call into a correction | The model samples plausible values, which is turns 4 to 6 above |
received | Confirms what the server actually parsed, which after context compaction the model may no longer have accurately | Coercion and serialisation bugs look like validation bugs; the model argues with a value it thinks it sent |
retryable | Says whether time alone could change the outcome | Every 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.
{
"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.
{
"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