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_V1StGXR8Z5jdHi6Bexport NATURALI_PROJECT=$PROJECT # lets the CLI omit --project-id
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 update-agent \
--agent-id agent_V1StGXR8Z5jdHi6B \
--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.updateAgent({
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_V1StGXR8Z5jdHi6B",
"project_id": "proj_V1StGXR8Z5jdHi6B",
"provider_id": "aip_V1StGXR8Z5jdHi6B",
"name": "geography-tutor",
"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"]
},
"status": "active"
}
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-generation \
--agent-id agent_V1StGXR8Z5jdHi6B \
--action-id tutorial.structured-output \
--messages '[{"role":"user","content":"Tell me about France."}]'
const { data: generation } = await naturali.generations.createGeneration({
path: { project_id: PROJECT, agent_id: AGENT },
body: {
action_id: 'tutorial.structured-output',
messages: [{ role: 'user', content: 'Tell me about France.' }],
},
});
console.log(generation?.object?.capital); // 'Paris' — a field, not a sentence
curl -sS -X POST \
"$NATURALI_API/projects/$PROJECT/agents/$AGENT/generations" \
-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_V1StGXR8Z5jdHi6B",
"agent_id": "agent_V1StGXR8Z5jdHi6B",
"status": "completed",
"text": "{\"country\":\"France\",\"capital\":\"Paris\",\"population\":68000000}",
"object": {
"country": "France",
"capital": "Paris",
"population": 68000000
},
"tool_calls": null
}
That object is the point: the platform parsed the model's reply for you and it
already matches the schema. text still carries the raw JSON, so nothing is
hidden — but code should read object.
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}/generationstakes 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
message.content, so the JSON arrives as text — which is why you should not bind a schema-bearing agent to a human-facing channel. See Structured output in the agents module page. - The run is metered like any other. Price it with the receipt from Your first agent generation.
What's next
- Route work on the parsed fields: a board agent column
can branch on
result.object.…, so a schema turns a model reply into control flow. - Give the agent tools — a constrained final answer and tool calls compose; the schema applies to the last turn.
- Look at what the run actually did, step by step, with traces.