Skip to main content

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:

  1. Attach a schema to the agent.
  2. Run a constrained generation.

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

  1. A credential. A nat_sk_… API key (or a session JWT from Auth) exported as NATURALI_TOKEN, and your client set up — the CLI, the SDK or plain curl against https://api.naturali.ai/v1:

    export NATURALI_TOKEN=nat_sk_...
    export NATURALI_API=https://api.naturali.ai/v1 # curl examples only
  2. 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_V1StGXR8Z5jdHi6B
    export AGENT=agent_V1StGXR8Z5jdHi6B
    export 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.

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"]
}'
{
"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.

naturali create-generation \
--agent-id agent_V1StGXR8Z5jdHi6B \
--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.

A streaming generation is never constrained

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

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.