Your integration does not want a paragraph. It wants a number it can add up, a status it can branch on, and a list it can loop over. Until this week the Botonom command API handed it a paragraph and left the parsing to you.
To get structured data out of an AI agent API, send the shape you want alongside the instruction and read the typed object back beside the prose. The agent's own reply should stay prose, because that is what a person reads and what carries files; the object is produced by reading the finished turn into your schema. Parsing sentences with regular expressions is the failure mode this replaces, and it fails on the day the model rephrases something rather than the day you write it.
What a run looked like before
POST /v1/agents/command could always do the work. The agent runs headless with its own persona, skills and permissions, calls the tools it needs, produces files. The answer came back in result_text, written for a person.
So every integration on top of it began with a regular expression aimed at a sentence somebody's model wrote. That parse is not wrong on the day you write it. It is wrong three weeks later, when the agent phrases the same total differently and your expression silently matches the wrong figure. Nothing throws. The number is just wrong from then on.
What changed
You describe the shape you want, and the same run returns it.
POST /v1/agents/command
{
"agent_id": "<agent-uuid>",
"instruction": "Review this month's orders and summarise revenue.",
"schema": {
"type": "object",
"properties": {
"total_revenue": { "type": "number", "description": "Sum of quantity x price" },
"top_product": { "type": "string", "description": "Name of the highest-earning product" }
},
"required": ["total_revenue", "top_product"]
}
}
command_status then carries both halves:
{
"status": "completed",
"result_text": "This month's revenue is 27,400 TRY; Masa earned the most...",
"output": { "total_revenue": 27400, "top_product": "Masa" },
"output_error": null
}
The prose is still there. It is what a person reads when they open the run in the dashboard, and removing it to make room for JSON would have traded one audience for another.
Why the object is read off the turn, not forced onto it
The obvious implementation is to constrain the agent's own reply to the schema. We did not do that, and the reason is worth stating because it applies to anyone building the same thing.
An agent turn is not a single completion. It is a loop: the model calls tools, reads results, calls more tools, and eventually writes a reply. Three things sit on top of that reply, and a schema constraint fights all three at once.
| What the reply carries | What a schema constraint does to it |
|---|---|
| The tool loop | A response format applied to the final message pulls against the model's freedom to keep calling tools |
| Media markers | Charts and files are attached through markers in the reply text, and a JSON object has nowhere to put them |
| The persona | Every agent has a voice, a language rule and a set of behaviour rules that produce prose |
So the turn runs completely untouched, and a second, much smaller pass reads the finished turn into the requested shape. The agent behaves exactly as it did before you asked for structure. Asking for an object changes what you receive, never what the agent does.
The cost is one extra model call per command, on a small model, over a short input. The benefit is that neither half is compromised: people get prose, code gets data.
What you get when you do not ask
Leaving schema out does not put you back to parsing sentences. The run still returns an object, in a standard envelope:
| Field | Meaning |
|---|---|
result | ok, partial, needs_input, refused or failed |
summary | One sentence, in the language of your instruction |
follow_up | What the agent still needs from you, or null |
actions | The capabilities it actually used |
files | The files it produced |
The envelope is deliberately small. It answers the four questions any caller has, and nothing else: did it work, what happened in one line, what did it do, what came out. A bigger default would invite the model to fill fields nobody reads.
The point is that the endpoint is programmable by default, rather than only when somebody remembers to ask.
Two fields nobody asked the model about
actions and files are not written by the model. They are computed from the run.
This is a deliberate line, and we think it is the most useful idea in the feature. Which tools ran is a fact the platform already holds. Which files were produced is recorded in the markers the client renders. Asking a model to restate facts it already has is how a run that called two tools gets reported as three, and how a file that failed to upload gets described as attached.
So the model is asked only for the genuinely linguistic parts: a summary, an outcome word, what is still pending. Everything checkable is checked instead of asked.
One detail from that split. The agent's own working-memory tool is filtered out of actions. It is the single most-called tool in production, and leaving it in would have buried the two calls a caller actually cares about under housekeeping.
Optional means nullable, and that is on purpose
Strict structured output has two requirements that no hand-written schema has: every object must declare additionalProperties: false, and every property must appear in required. Our first live attempt failed on exactly that, with an error most developers should never have to read:
Invalid schema for response_format: 'additionalProperties' is required to be supplied and to be false.
Pushing that onto callers would have made a simple feature unpleasant, so the platform normalises the schema itself. A property you leave out of required becomes required-but-nullable.
The side effect turned out better than the fix. A field you did not mark required is always present in output, and it is null when the run did not produce it. You never have to tell an absent key from a null one, because there is only ever one of them.
A schema is not a contract with reality
output can be null. When it is, output_error says why, and the run is still completed with its prose answer intact. A shape that could not be filled is not a failed job, and failing the run would have thrown away work the agent really did.
The supported subset is small on purpose: string, number, integer, boolean, array, object, up to fifty properties, nested up to five levels. Anything else is refused at request time, with the path named:
400 SCHEMA_INVALID
schema is not supported: properties.when: unsupported type "date"
A schema that is accepted and then quietly ignored is worse than one that is refused, because you find out at the wrong end of the run.
The mistake we made in our own first test
We asked for a field called product_count and did not describe it. The run came back with 18. The table had three products and eighteen units, and both readings of the field name are defensible.
The description on a property is not documentation for humans. It is the instruction the agent reads. One sentence would have settled it:
"product_count": {
"type": "integer",
"description": "How many DISTINCT products appear, not units sold"
}
If a field name could be read two ways by a careful colleague, it will be read both ways by an agent across enough runs. Naming the field is half the work; saying what you mean by it is the other half.
Where this fits
The command endpoint was already the way software drives an agent. This closes the other half: software can now read the answer without guessing at prose. The full contract, including the default envelope and the validation rules, is in the API documentation.
The fastest way to see it is to send one: the API Playground runs a real command against your own agent with your own key, and the schema field is already in the request body waiting to be edited.
Frequently asked questions
Does asking for a schema change how the agent behaves?
No. The turn runs first and unchanged, and the object is read off the finished run. Same tools, same reply, same files. That is the reason it was built as a second pass rather than as a constrained response.
What happens if the agent cannot fill my schema?
output comes back null and output_error explains why. The run still completes and result_text still holds the agent's answer, so you never lose work because a shape did not fit.
Should I send a schema on every request?
Only where your code reads the answer. A run whose output a person reads is better served by the default envelope, which already tells you whether it worked and what it did without you designing anything.
Can I use it with restricted autonomy?
Yes. The response shape and the agent's permission to act are independent settings. A run that may compose but not act returns its structured answer the same way.
Does the object replace the text answer?
No, they arrive together. One is for a person reading the run, the other is for your code, and both are always present.
Is there a size limit on the schema?
Fifty properties and five levels of nesting. If you are approaching either, the instruction is usually doing two jobs, and it is better split into two commands with two shapes.

