Deploy a system from a template
By the end of this tutorial you will have a small agentic system — a managed model provider, an order-lookup tool and a support agent that calls it — declared in one formation template, deployed with one call, and changed in place by editing the template.
Six steps:
- Write and validate the template.
- Deploy it.
- Run the deployed agent.
- Plan a change.
- Apply it.
- Run the agent again.
Every step is 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 project. The one you used in Your first agent generation works; the template brings its own provider, so no
PROVIDERis needed:export PROJECT=proj_cT9LACJi0WypPf5U -
jq, for the curl examples: it puts the YAML file into the JSON body.
A template is plain YAML, which is exactly what an assistant connected over MCP is good at. Describe the system, and it can write the template, validate it and deploy it with the same calls as below.
1. Write and validate the template
Save this as stack.yaml. Three resources: Models is the
managed offering,
OrderLookup an http tool, and Support the agent.
ref
stands in for an id that does not exist yet, so the agent names its
provider and its tool before either is created.
resources:
Models:
type: naturali_ai_provider
properties:
name: stack-models
default_model: glm-4.7-flash
OrderLookup:
type: tool
properties:
name: lookup-order
type: http
description: Looks up an order by its id.
parameters:
type: object
properties:
order_id:
type: string
required:
- order_id
execute:
url: https://httpbin.org/anything/orders/{order_id}
method: GET
Support:
type: agent
properties:
name: stack-support
ai_provider_id:
ref: Models
instructions: >-
You answer questions about orders. Look the order up with
lookup-order before answering, and say which URL you checked.
tool_bindings:
- tool_id:
ref: OrderLookup
outputs:
agent_id:
ref: Support
The tool calls httpbin.org, which echoes the request back — in your system it
is your order API. Validate before deploying:
it parses and type-checks the template and creates nothing.
- CLI
- SDK
- curl
naturali validate-formation \
--project-id "$PROJECT" \
--template "$(cat stack.yaml)"
import { readFileSync } from 'node:fs';
const { data: result } = await naturali.formations.validateFormation({
path: { project_id: PROJECT },
body: { template: readFileSync('stack.yaml', 'utf8') },
});
curl -sS -X POST "$NATURALI_API/projects/$PROJECT/formations/validate" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H 'Content-Type: application/json' \
-d "$(jq -Rn --rawfile t stack.yaml '{template: $t}')"
{ "valid": true, "errors": [], "warnings": [] }
A template may only declare the resource types this API serves; api_key,
policy or webhook answer 400 unsupported_resource_type — see
which resource types this API accepts.
2. Deploy it
POST /v1/projects/{project_id}/formations
creates the three resources in
dependency order
and answers once they exist.
- CLI
- SDK
- curl
naturali create-formation \
--project-id "$PROJECT" \
--name support-stack \
--template "$(cat stack.yaml)"
const { data: formation } = await naturali.formations.createFormation({
path: { project_id: PROJECT },
body: {
name: 'support-stack',
template: readFileSync('stack.yaml', 'utf8'),
},
});
curl -sS -X POST "$NATURALI_API/projects/$PROJECT/formations" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H 'Content-Type: application/json' \
-d "$(jq -Rn --rawfile t stack.yaml '{name: "support-stack", template: $t}')"
{
"id": "form_EPis15Nfukary167",
"name": "support-stack",
"status": "active",
"outputs": { "agent_id": "agent_EmeXFnXV6RRtFHvp" },
"error": null,
"resources": [
{
"logical_id": "Models",
"resource_type": "naturali_ai_provider",
"physical_resource_id": "aip_eHPAQ6qpUkB70D3N",
"status": "created"
},
{
"logical_id": "OrderLookup",
"resource_type": "tool",
"physical_resource_id": "tool_8Vfntfb4p4z2goON",
"status": "created"
},
{
"logical_id": "Support",
"resource_type": "agent",
"physical_resource_id": "agent_EmeXFnXV6RRtFHvp",
"status": "created"
}
]
}
Each physical_resource_id is an ordinary resource, readable through its own
module. The outputs block hands you the agent's id:
export FORMATION=form_EPis15Nfukary167
export AGENT=agent_EmeXFnXV6RRtFHvp
3. Run the deployed agent
- CLI
- SDK
- curl
naturali create-agent-generation \
--project-id "$PROJECT" \
--agent-id "$AGENT" \
--wait true \
--messages '[{"role":"user","content":"Where is order 1001?"}]'
const { data: generation } = await naturali.agents.createAgentGeneration({
path: { project_id: PROJECT, agent_id: AGENT },
query: { wait: true },
body: {
messages: [{ role: 'user', content: 'Where is order 1001?' }],
},
});
curl -sS -X POST \
"$NATURALI_API/projects/$PROJECT/agents/$AGENT/generate?wait=true" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H 'Content-Type: application/json' \
-d '{ "messages": [{ "role": "user", "content": "Where is order 1001?" }] }'
{
"id": "gen_xNbNEQalLzlMvTkm",
"trace_id": "trace_GjICOF5cL7NJ7Kjj",
"status": "completed",
"ai_provider_id": "aip_eHPAQ6qpUkB70D3N",
"output": {
"model": "glm-4.7-flash",
"content": "I checked the order at URL: https://httpbin.org/anything/orders/1001\n\nUnfortunately, the lookup returned an empty data response, which means order 1001 either:\n- Doesn't exist in the system\n- Has no tracking information available\n- Failed to be found\n\nCould you double-check the order number…",
"finish_reason": "stop"
}
}
ai_provider_id is the Models resource, and the reply names the URL the
tool called: every piece the template declared took part. The echo carries no
order data, so the agent guesses at why — the next step fixes that.
4. Plan a change
Edit stack.yaml: add two sentences to the agent's instructions, so
Support reads:
Support:
type: agent
properties:
name: stack-support
ai_provider_id:
ref: Models
instructions: >-
You answer questions about orders. Look the order up with
lookup-order before answering, and say which URL you checked.
If the lookup returns no order data, say the order is still
being processed and will update within a day.
tool_bindings:
- tool_id:
ref: OrderLookup
POST /v1/projects/{project_id}/formations/plan
with the formation's id
compares the edited template against what is deployed,
and creates nothing.
- CLI
- SDK
- curl
naturali plan-formation \
--project-id "$PROJECT" \
--formation-id "$FORMATION" \
--template "$(cat stack.yaml)"
const { data: plan } = await naturali.formations.planFormation({
path: { project_id: PROJECT },
body: {
formation_id: FORMATION,
template: readFileSync('stack.yaml', 'utf8'),
},
});
curl -sS -X POST "$NATURALI_API/projects/$PROJECT/formations/plan" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H 'Content-Type: application/json' \
-d "$(jq -Rn --rawfile t stack.yaml --arg f "$FORMATION" \
'{formation_id: $f, template: $t}')"
{
"changes": [
{
"logical_id": "Models",
"resource_type": "naturali_ai_provider",
"action": "no-op",
"physical_resource_id": "aip_eHPAQ6qpUkB70D3N"
},
{
"logical_id": "OrderLookup",
"resource_type": "tool",
"action": "no-op",
"physical_resource_id": "tool_8Vfntfb4p4z2goON"
},
{
"logical_id": "Support",
"resource_type": "agent",
"action": "update",
"physical_resource_id": "agent_EmeXFnXV6RRtFHvp",
"diff": {
"desired": {
"instructions": "You answer questions about orders. Look the order up with lookup-order before answering, and say which URL you checked. If the lookup returns no order data, say the order is still being processed and will update within a day."
},
"current": {
"instructions": "You answer questions about orders. Look the order up with lookup-order before answering, and say which URL you checked."
}
}
}
]
}
One update, two no-ops: the agent changes in place and keeps its id. Each
diff also carries the unchanged properties, trimmed here.
5. Apply it
PUT /v1/projects/{project_id}/formations/{formation_id}
converges the stack
on the edited template.
- CLI
- SDK
- curl
naturali update-formation \
--project-id "$PROJECT" \
--formation-id "$FORMATION" \
--template "$(cat stack.yaml)"
const { data: updated } = await naturali.formations.updateFormation({
path: { project_id: PROJECT, formation_id: FORMATION },
body: { template: readFileSync('stack.yaml', 'utf8') },
});
curl -sS -X PUT "$NATURALI_API/projects/$PROJECT/formations/$FORMATION" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H 'Content-Type: application/json' \
-d "$(jq -Rn --rawfile t stack.yaml '{template: $t}')"
{
"id": "form_EPis15Nfukary167",
"status": "active",
"outputs": { "agent_id": "agent_EmeXFnXV6RRtFHvp" },
"resources": [
{
"logical_id": "Models",
"physical_resource_id": "aip_eHPAQ6qpUkB70D3N",
"status": "created"
},
{
"logical_id": "OrderLookup",
"physical_resource_id": "tool_8Vfntfb4p4z2goON",
"status": "created"
},
{
"logical_id": "Support",
"physical_resource_id": "agent_EmeXFnXV6RRtFHvp",
"status": "updated"
}
]
}
The same agent id, now updated. Like any agent write, the change archived a
version: the agent is at
version 2.
6. Run the agent again
Ask the same question as in step 3.
- CLI
- SDK
- curl
naturali create-agent-generation \
--project-id "$PROJECT" \
--agent-id "$AGENT" \
--wait true \
--messages '[{"role":"user","content":"Where is order 1001?"}]'
const { data: again } = await naturali.agents.createAgentGeneration({
path: { project_id: PROJECT, agent_id: AGENT },
query: { wait: true },
body: {
messages: [{ role: 'user', content: 'Where is order 1001?' }],
},
});
curl -sS -X POST \
"$NATURALI_API/projects/$PROJECT/agents/$AGENT/generate?wait=true" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H 'Content-Type: application/json' \
-d '{ "messages": [{ "role": "user", "content": "Where is order 1001?" }] }'
{
"id": "gen_W1OWLuDVtAd66lFN",
"status": "completed",
"output": {
"content": "I checked https://httpbin.org/anything/orders/1001, but that URL doesn't return any order data. Based on the response, it appears that order 1001 is still being processed. The order should update within a day. Would you like me to check again later, or is there anything else I can help you with?"
}
}
The answer follows the new instructions. The system changed by editing one file, and the agent, its id and its history stayed where they were.
Clean up
Deleting the formation deletes everything it created. An agent that has run is
the exception: the teardown answers 409 FORMATION_DELETE_FAILED and deletes
nothing, because the agent has generations and traces.
Force-delete the agent first — that removes its history too — then the formation.
- CLI
- SDK
- curl
naturali delete-agent \
--project-id "$PROJECT" \
--agent-id "$AGENT" \
--force true
await naturali.agents.deleteAgent({
path: { project_id: PROJECT, agent_id: AGENT },
query: { force: true },
});
curl -sS -X DELETE "$NATURALI_API/projects/$PROJECT/agents/$AGENT?force=true" \
-H "Authorization: Bearer $NATURALI_TOKEN"
- CLI
- SDK
- curl
naturali delete-formation \
--project-id "$PROJECT" \
--formation-id "$FORMATION"
await naturali.formations.deleteFormation({
path: { project_id: PROJECT, formation_id: FORMATION },
});
curl -sS -X DELETE "$NATURALI_API/projects/$PROJECT/formations/$FORMATION" \
-H "Authorization: Bearer $NATURALI_TOKEN"
{ "success": true }
To keep a resource when its formation goes, set
deletion_policy: retain
on it in the template.
What's next
- Formations
— parameters,
no_echosecrets,depends_on, and the deploy history atGET …/formations/{formation_id}/events. - Channels in a template — declare the WhatsApp or Discord connection beside the agent it routes to.
- Run an agent on a schedule — a
triggerresource declares the same schedule; plan limits on triggers apply to a template too. - Versioning and staged rollout — serve a changed agent to part of the traffic before every caller gets it.