Structured output
By the end of this tutorial you will have an agent whose generations come back as a validated JSON object instead of prose — ready to hand straight to code that expects fields, with no parsing and no prompt-engineering the model into behaving.
Two steps:
Both steps are one API call, shown for all three clients. The ids in the responses are examples — copy the ones your own calls return.
Prerequisites
-
A credential. A
nat_sk_…API key (or a session JWT from Auth) exported asNATURALI_TOKEN, and your client set up — the CLI, the SDK or plaincurlagainsthttps://api.naturali.ai/v1:export NATURALI_TOKEN=nat_sk_...export NATURALI_API=https://api.naturali.ai/v1 # curl examples only -
A working agent. That is what Your first agent generation builds — do it first if you haven't. Arrive here with both ids exported:
export PROJECT=proj_V1StGXR8Z5jdHi6Bexport AGENT=agent_V1StGXR8Z5jdHi6B
1. Attach a schema to the agent
A schema is agent configuration, not
a per-call argument: you set output_schema once and every non-streaming generation the
agent runs is constrained to it.
Set the instructions in the same call. The schema fixes the shape of the reply, so the "answer in one sentence" instruction the agent arrived with no longer describes what it returns.
- CLI
- SDK
- curl
naturali patch-agent \
--project-id "$PROJECT" \
--agent-id "$AGENT" \
--instructions 'You are a geography reference. Fill in the requested fields.' \
--output-schema '{
"type": "object",
"properties": {
"country": { "type": "string" },
"capital": { "type": "string" },
"population": { "type": "integer" }
},
"required": ["country", "capital"]
}'
const { data: agent } = await naturali.agents.patchAgent({
path: { project_id: PROJECT, agent_id: AGENT },
body: {
instructions: 'You are a geography reference. Fill in the requested fields.',
output_schema: {
type: 'object',
properties: {
country: { type: 'string' },
capital: { type: 'string' },
population: { type: 'integer' },
},
required: ['country', 'capital'],
},
},
});
curl -sS -X PATCH "$NATURALI_API/projects/$PROJECT/agents/$AGENT" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"instructions": "You are a geography reference. Fill in the requested fields.",
"output_schema": {
"type": "object",
"properties": {
"country": { "type": "string" },
"capital": { "type": "string" },
"population": { "type": "integer" }
},
"required": ["country", "capital"]
}
}'
{
"id": "agent_wq7H4Ka1eDFVsBXM",
"project_id": "proj_cT9LACJi0WypPf5U",
"ai_provider_id": "aip_NzjzJDzoId8cm1Hu",
"name": "refund-explainer",
"instructions": "You are a geography reference. Fill in the requested fields.",
"output_schema": {
"type": "object",
"required": ["country", "capital"],
"properties": {
"capital": { "type": "string" },
"country": { "type": "string" },
"population": { "type": "integer" }
}
},
"version": 2
}
The write archived configuration
version 2. required is what makes a field
reliable: a property the schema merely allows may be absent from the object; a
property it requires is always there.
2. Run a constrained generation
Run a generation exactly as you did before — the request body is unchanged. The schema lives on the agent, so there is nothing to pass.
- CLI
- SDK
- curl
naturali create-agent-generation \
--project-id "$PROJECT" \
--agent-id "$AGENT" \
--wait true \
--action-id tutorial.structured-output \
--messages '[{"role":"user","content":"Tell me about France."}]'
const { data: generation } = await naturali.agents.createAgentGeneration({
path: { project_id: PROJECT, agent_id: AGENT },
query: { wait: true },
body: {
action_id: 'tutorial.structured-output',
messages: [{ role: 'user', content: 'Tell me about France.' }],
},
});
console.log(generation?.output?.object?.capital); // 'Paris' — a field, not a sentence
curl -sS -X POST \
"$NATURALI_API/projects/$PROJECT/agents/$AGENT/generate?wait=true" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"action_id": "tutorial.structured-output",
"messages": [{ "role": "user", "content": "Tell me about France." }]
}'
{
"id": "gen_Rjmc7XiJedwRqwi5",
"trace_id": "trace_2buKecaY8KWKcNf0",
"status": "completed",
"ai_provider_id": "aip_NzjzJDzoId8cm1Hu",
"output": {
"model": "nova-lite-v1",
"content": "{\"country\":\"France\",\"capital\":\"Paris\"}",
"finish_reason": "stop",
"object": {
"country": "France",
"capital": "Paris"
}
}
}
That output.object is the point: the platform parsed the model's reply for you
and it already matches the schema — and it validated it, so a reply that broke
the schema would have failed the generation with
502 OUTPUT_SCHEMA_VALIDATION_FAILED naming the field rather than reaching your
code. output.content still carries the raw JSON, so nothing is hidden. This
model left out population, which the schema allows but does not require.
wait=true is what puts the object in the response. Without it the call is
background and answers 202
with an id to poll.
Send "stream": true and you get Server-Sent Events, no object, and the
schema is not applied — the two features are mutually exclusive at the platform
level. If you need the object, don't stream this agent.
Where the object goes next
- One schema per agent.
POST /v1/projects/{project_id}/agents/{agent_id}/generatetakes nooutput_schema, so two output shapes means two agents. Sendoutput_schema: nullon an update to clear it and get prose back. - A session turn is constrained too, but has nowhere to put the object. It returns the message content, so the JSON arrives as text — fine for code that parses it, wrong for a human reading the thread. See Structured output on the agents page.
What's next
- Branch an orchestration — a schema field, not a sentence, decides which agent answers next.
- Run tools in your own code — a constrained final answer and tool calls compose; the schema applies to the last turn.
- Roll out an agent version — ship a schema change to part of the traffic while the old shape stays live for the rest.