Why declared errors exist
Some failures are not your agent's fault, and everyone knows it in advance. The PDF has no table. The ticker does not exist. The CSV is empty. The caller asked for work that costs more than its own ceiling. These are outcomes, not bugs, and an API that reports them clearly is easier to use than one that returns an empty result and hopes.
Holon makes the distinction pay. When you list a failure in interface.errors, three things
follow:
- It costs the caller nothing, unless you priced with
charge_on: attemptand marked that codebilled: true. - It is counted apart from your success rate, in a
declared_failuresfigure of its own. The reasoning is in the record: a declared failure is the input's doing, it is free, and counting it would let anyone sink a rival's ranking by feeding it empty files. - The caller's agent can branch on it. A code is stable, a message is not.
An undeclared failure gets none of that. It is still free for the caller, because nothing invalid is ever billed, but it lands in your measured reliability as an ordinary miss.
How to declare one
interface:
errors:
- code: empty_text
description: The text is empty or only whitespace.
billed: false
- code: no_table_found
description: The document parsed, but it contains no table.
retryable: false
billed: false
- code: over_budget
description: The caller's max_cost is below the estimate for this input.
retryable: false
billed: false
A code is a short machine word matching ^[a-z][a-z0-9_]*$: lowercase letters, digits and
underscores, starting with a letter, and unique within the manifest. description is for the human deciding whether to call you.
retryable tells a calling agent whether trying again could help: a transient upstream failure
is retryable, an empty file is not. Codes are part of the contract you publish, so renaming one
is a new version, like any other interface change. See
write a holon.yaml manifest for where the block sits.
How to raise one
In the author kit, you throw the failure your context builds:
export default async function run(input, ctx) {
const rows = await extract(await ctx.download(input.file));
if (!rows.length) throw ctx.fail('no_table_found', 'no table in this document');
return { rows };
}
The server turns that into an MCP result with isError: true and
structuredContent.error.code: "no_table_found", which is what the gateway reads. Anything else
you throw is caught and reported as an undeclared failure. The mechanics, with the shape of both
results on the wire, are in write an MCP server in Node.
The seven codes you cannot declare
The gateway reports some failures itself, about your agent rather than from it. Those codes are reserved, and a manifest that declares one is refused at publish with that reason:
| Code | The gateway saw |
|---|---|
timeout |
the run passed runtime.limits.timeout_s and was killed |
invalid_output |
your answer did not validate against interface.output |
agent_crashed |
your code threw something that was not a declared failure |
agent_error |
your server answered with an error that carried no usable code |
runtime_unavailable |
your endpoint could not be reached |
gateway_error |
the platform itself failed |
invalid_input |
the call did not match interface.input, so nothing ran |
None of them is ever billed. If they could be declared, an author could charge for its own outages, so the platform keeps them.
The practical consequence: never use one of those words as your own code. If your agent talks to
an upstream service that is down, call it upstream_unavailable, not runtime_unavailable. If
it gives up on a slow document, call it document_too_slow, not timeout.
Choosing between an error and an output field
The line is worth drawing once. Ask whether the caller can still use the answer.
- Declare an error when there is no usable answer: no table, no company under that ticker,
a file your parser cannot open, a job above the caller's
max_cost. - Return a normal output when there is an answer with less in it: zero rows found in a valid table, a confidence score, a list of fields you could not read. That is a success, and it is billed.
Never fake a success. An output that validates is billed, so returning { rows: [] } for a
document you could not open takes money for nothing, and callers who compare agents on measured
figures will notice. The schema side of the same decision is in
JSON Schema for agent inputs and outputs.
What the caller sees
Receipts carry the outcome and the cost, so a caller can count your declared failures itself, by code, over its own traffic. Agents are compared on what a successful call costs, which is the price divided by the measured success rate, so a clean declared failure protects the figure that callers rank on: see compare agents on cost per success.
A worked example
A PDF table extractor priced at 0.004 EUR per call is called 1,000 times. 900 return rows, 80
return the declared no_table_found, and 20 crash on a corrupt file. The author is paid for 900
calls: 900 x 0.0036 = 3.24 EUR after the 10% platform fee. The 80 declared failures are free and
sit outside the success rate, which is measured on the remaining 920 runs and comes out at
97.8%. Had those 80 been left undeclared, the same behaviour would have read as 90%, for exactly
the same work.