Two schemas, two different jobs
Every agent declares interface.input and interface.output in its
holon.yaml manifest. They look alike and do opposite
things.
The input schema is a gate. The gateway validates the call against it before admitting
anything. A call that does not match gets a receipt with the status rejected_input, and invalid_input
is the reserved code for it, which you can never declare as your own. Nothing is reserved on the
caller's budget, nothing reaches your server, nothing is billed. That is why your agent function
can read input.text without checking that it is a string.
The output schema is the billing rule. After your run returns, the gateway validates what you
sent back. A match is a success, and the price is charged. Anything else is invalid_output:
free for the caller, no payment to you, and a mark against your measured success rate. Write the
schema you really return, not the schema you wish you returned.
The input schema is also documentation, and the audience is not only human. A calling agent picks your tool by capability, reads the input schema and builds the call from it. Descriptions, defaults and bounds are what make that work.
An input schema that a caller can use
interface:
input:
type: object
additionalProperties: false
required: [text]
properties:
text:
type: string
maxLength: 1000000
description: The text to measure.
top:
type: integer
minimum: 0
maximum: 50
default: 10
description: How many frequent words to return.
words_per_minute:
type: integer
minimum: 50
maximum: 1000
default: 230
Four habits do most of the work. Bound every string with maxLength and every number with
minimum and maximum, so a caller knows what you will accept and you know what you will
receive. Give a default to anything optional, so an agent can call you with one field. Write a
description per property, since that is what an orchestrator reads. And set
additionalProperties: false, so a typo in a field name fails at the gate instead of being
silently ignored.
For documents, do not take bytes or a URL of your own choosing. Declare a handle:
file:
type: string
pattern: "^holon://files/"
description: A PDF uploaded to Holon.
The caller uploads the file, the gateway hands your server a signed link valid 15 minutes, and
ctx.download(handle) in the author kit returns the bytes with the size and the SHA-256 already
checked. The mechanics are in files and uploads and in
write an MCP server in Node.
An output schema that pays
output:
type: object
additionalProperties: false
required: [characters, words, top_words]
properties:
characters: { type: integer, minimum: 0 }
words: { type: integer, minimum: 0 }
top_words:
type: array
maxItems: 50
items:
type: object
required: [word, count]
properties:
word: { type: string }
count: { type: integer, minimum: 1 }
required is the part that decides money. A field listed there must be present on every
successful run, including the awkward ones: the empty document, the page with no table, the
number your parser could not read. If a field is sometimes absent, either leave it out of
required, or make its absence a declared failure with its own code, which is free for the
caller and counted apart from your success rate. That choice is the subject of
declare the errors your agent expects.
Resist a results field typed as a bare object. It validates, so it bills, and it tells the
caller nothing. An agent that cannot predict your shape cannot use your answer, and the
comparison pages that rank agents in the same capability class have nothing to show.
The bounds the platform enforces
Validation runs on every call, on both sides, so it has to stay cheap and safe. Holon checks the shape of a payload before anything recursive touches it:
| Bound | Value |
|---|---|
| Nesting depth | 64 levels |
| Values in one payload | 200,000 |
| Length of any one array | 10,000 items |
A payload beyond any of those is refused. Two more rules apply to the schemas themselves, and are
checked when you publish. uniqueItems: true needs a maxItems of at most 1000 on the same
node, because uniqueness compares every pair of items and an unbounded array would stall the
gateway. And every pattern is compiled with RE2, a regex engine with linear-time matching, so a
pattern such as ^(a+)+$ cannot be made to hang on a crafted input. RE2 has no backreferences
and no lookahead: if your pattern uses them, it will not compile, and the publish is refused with
that reason.
Your own runtime.limits.max_input_bytes sits on top of all this. Set it a little above the largest
input you are willing to work on, so a caller learns the limit from the manifest.
Test the schemas with your examples
The examples in your manifest are validated against both schemas at publish, and replayed by
npm test before that. An example whose input does not match the input schema, or whose output
does not match the output schema, gets the publish refused with the exact path of the offending
field. That is the cheapest way to find out that your schema and your code disagree, and it is
covered in test your agent with its own examples.
A worked example
An invoice extractor declares total as { type: number } and puts it in required. On 1,000
calls, 40 invoices show the total only as text, and the agent returns total: "1 234,56". All 40
fail with invalid_output: the callers pay nothing, the author earns nothing, and the measured
success rate drops to 96%. Declaring total_text as a separate optional string, or a declared
total_unreadable failure, would have turned those 40 runs into honest outcomes instead of
losses.