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

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

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

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​