Skip to main content

Formations

A whole agent stack described in one template, deployed as one unit.

Overview​

Everything else in this API is imperative: you create an agent, then a tool, then wire the second into the first, and you keep track of the ids in between. A formation inverts that. You write one template naming the agents, tools, memory stores, triggers and everything else the project needs, reference one resource from another by its logical name, and deploy the lot with a single call. The runtime works out the order, resolves the references, and rolls the whole deployment back if any resource fails.

Update the same formation with a changed template and it converges: resources are created, updated or deleted so the project matches what the template now says. Delete the formation and everything it created goes with it, except what the template asked to retain.

If you know CloudFormation or Terraform, you already know the shape. The reason to reach for it here is the same one: a stack you can review in a pull request, apply from CI, and tear down without hunting for the pieces.

This module is a verbatim mirror of the runtime: every field, method, status code and error shape is the runtime's own, re-rooted under the project in the path.

A template cannot declare past a plan

A template is a second way to create everything else in this API, so the plan rules that apply to the routes apply to a declaration too, and are answered before anything is deployed — a validate is refused as firmly as an apply.

A template declaring a resource whose feature the plan does not include answers 403 plan_feature_not_included. A template declaring more channels or triggers than the plan allows answers 403 plan_limit_reached, with the offending declaration named in details.field.

A validate or plan counts what the template itself declares. A deploy also counts the account: its triggers across every project, minus the ones this formation already owns, plus the ones it declares — so re-applying a formation that already owns its triggers is never refused for that reason. A channel is counted against the account when the deploy creates it.

See the OpenAPI spec for the full endpoint and schema reference, or browse it rendered under API Reference → Formations.

Data Model​

Formation​

FieldTypeDescription
idstringPublic formation ID (form_ prefix).
project_idstringThe owning project.
namestringHuman-readable stack name. Unique per project — reusing one is a 409.
templateobjectThe template as applied, echoed back verbatim.
outputsobject, nullableResolved values of the template's outputs block after the last deploy.
statusstringcreating, active, updating, failed, deleting, deleted or delete_failed.
metadataobject, nullableStatic annotations supplied at create/update. Not a substitution site.
resolved_metadataobject, nullableThe template's own metadata block with ref/param/sub expressions resolved.
resourcesarray, nullableOne record per managed resource (present on get/create/update).
errorobject, nullableWhy the formation is failed or delete_failed — or, on an active one, a superseded resource that could not be disposed of.
created_atstring (date-time)
updated_atstring (date-time)

FormationResource​

FieldTypeDescription
logical_idstringThe name the template gave it.
resource_typestringWhich kind of resource it is.
physical_resource_idstring, nullableThe real resource's public id — an agent_…, a tool_….
statusstringpending, created, updated, deleted or failed.

The physical_resource_id is the bridge back to the rest of this API: a formation-created agent is an ordinary agent, readable and callable through agents like any other.

Key Concepts​

A template is parameters, resources and outputs​

resources is the only required block. Each entry maps a logical id — your name for the resource, used to reference it elsewhere — to a type and a properties bag whose shape depends on that type.

Three expressions can appear anywhere in a property value, which is why the declaration itself is free-form:

ExpressionResolves to
{ "ref": "LogicalId" }that resource's physical_resource_id, after it is created
{ "param": "Name" }the value of a declared parameter
{ "sub": "text ${Name}" }the string with parameter values interpolated

A ref also declares a dependency, so ordering is derived rather than stated; depends_on is there for the ordering a ref does not express. Deploy a system from a template has an agent ref its provider and tool before either exists.

Parameters are declared with an optional default, and a parameter without one must be supplied at deploy time. Mark a sensitive one no_echo, and a value you want carried across an update use_previous_value — the same idea as CloudFormation's, declared in the template rather than passed on each call.

Validate, then plan, then deploy​

Three calls, in increasing commitment, and the middle one is the one worth building a habit around:

CallWhat it does
POST …/formations/validateParses and type-checks the template. Creates nothing.
POST …/formations/planSays what a deploy would create, update, replace or delete. Creates nothing.
POST …/formations / PUT …/formations/{formation_id}Applies it.

A plan is what makes an update reviewable: the diff between the template in your branch and the stack in production, before anything moves. Deploy a system from a template plans an instruction change before applying it.

Which resource types this API accepts​

The template's type is a plain string rather than a fixed list, because the runtime lets a deployment register its own types. What this deployment accepts is channel and naturali_ai_provider — naturali's own, both below — plus ai_provider, tool, agent, actor, conversation, dataset, dataset_item, document, file, guardrail, ingestion_rule, memory, memory_store, model_route, eval, orchestration, quota, secret, session, trigger and workflow — each of those documented by its own <Type>ResourceProperties schema in the spec. The two naturali types have no such schema, because they are not the runtime's; their properties are documented below.

The list is closed. A template naming anything else is refused with 400 unsupported_resource_type before it reaches the runtime, and that includes a type the runtime itself supports. What is on the list is not kept by hand: it is the resource types belonging to the modules this API serves, so a module arriving here brings its resource type with it, and one that is not served cannot be reached by declaring it instead of calling it.

channel — naturali's own resource type​

One type in that list is not the runtime's: channel. The runtime owns the deploy engine and calls back to naturali for this one type's lifecycle, so a channel is declarable in a template exactly like anything else, and a template is the only place where connecting a channel and the agent it routes to are one reviewable unit.

Its properties are the field names the channels API takes:

resources:
SupportAgent:
type: agent
properties:
name: support
model: anthropic.claude-sonnet-5
instructions: Answer questions about an order.
WhatsApp:
type: channel
properties:
channel: whatsapp
phone_number_id: '123456789'
access_token:
param: WhatsAppToken
default:
agent_id:
ref: SupportAgent
A channel's credential is write-only, and that is what makes it safe here

access_token, bot_token and code are declared write-only, so the deploy engine sends them and then drops them before storing the property snapshot it diffs against on the next deploy — a tenant credential never lands in the resource ledger, and reading the resource back never returns one.

The visible consequence: a credential always reads as changed, so every update re-sends it. That is why it belongs in a no_echo parameter rather than inline in the template, and it is safe rather than merely tolerable because the transports' credential paths are idempotent.

naturali_ai_provider — the managed offering, declared​

The second type that is not the runtime's. A provider: "naturali" record is not a field rewrite but a provisioning transaction — it is created, priced against the catalog, and rolled back if pricing fails — so it cannot be reached by relaying a template. Declaring it as a resource type puts that work where the deploy engine asks for it.

Two properties, and no credential:

resources:
Managed:
type: naturali_ai_provider
properties:
name: house-models
default_model: nova-lite-v1
Support:
type: agent
properties:
name: support
instructions: Answer questions about an order.
ai_provider_id:
ref: Managed

default_model is a catalog model that GET /v1/models lists as available, and it is what decides which of naturali's sources serves the provider — you never name the source. name defaults to naturali, or naturali-vertex for a Vertex-served model. secret_id, config and base_url are refused rather than ignored: the offering runs on naturali's own model access, and a caller who sent a credential expects it to be used. Bring your own with an ai_provider resource instead.

{ref} on it resolves to the real provider id, so it drops into any field that takes an ai_provider_id — the deployed agent generates on one.

Point it at the other source and the provider is replaced, not edited

A managed provider's source is fixed when it is created — it carries that cloud's configuration and the price rows for that cloud's models. Changing default_model to one the other source serves therefore cannot be done in place: a new provider is created and the old one removed under its deletion_policy, and anything holding the old id by {ref} is re-pointed by the deploy.

Changing the name, or moving to another model of the same source, is an ordinary in-place update.

Removing a managed provider — the superseded one here, or every resource when the formation is deleted — force-deletes it, because the provider is priced as it is created and a price override would otherwise refuse the teardown. The spend already metered against it is kept and unlinked, not deleted. See deleting a provider in use.

An agent in the same template can name a catalog model

Writing model: nova-lite-v1 on an agent that points at this provider works: the {ref} names a resource whose type says the provider is naturali's, so the name is resolved from the template itself rather than from a provider record that does not exist yet.

An agent pointing at a provider of your own is untouched, deliberately — your credential uses the vendor's own model strings, and rewriting one would put a naturali name on your generation in a trace and on an invoice.

Leaving model unset and inheriting the provider's default_model still works, and still means the model is named in one place.

Five of the runtime's own types are not available here

A template naming api_key, chat, policy, project_price or webhook is refused with 400 unsupported_resource_type before it reaches the runtime, and the refusal is deliberate rather than an omission.

api_key is the one that matters most: the key it would mint belongs to the upstream runtime, not to naturali, so anyone holding it would talk to the runtime directly — outside the project membership, the role checks and everything else this API enforces on every request. The other four belong to surfaces naturali serves itself or does not expose: webhooks here is naturali's own event system rather than the runtime's platform events, policy is the runtime's IAM, project_price is the pricing naturali owns for managed models, and chat overlaps the channels stack.

Everything those five would have done is still reachable — through API keys, webhooks and channels, which are naturali's own routes for the same jobs.

Reachable is not declarable, and for a webhook that is the difference that shows: one created through its own route is not planned, reconciled or deleted with the stack that uses it, so a template describing work whose results are pushed to your endpoint leaves that last leg imperative. The channel type above is the delivery surface that is declarable, but it carries a messaging product rather than a server-to-server push.

A declared provider is held to the same credential rule as a created one

A template's ai_provider resource goes through the same check POST …/ai-providers applies: a bedrock or vertex provider must carry a credential of its own — a secret_id, or config.apiKey. Without one those SDKs fall back to an ambient credential that is not the project's, so the record would generate on access it never brought. See AI providers for the rule itself.

A {"ref": …} to a secret resource in the same template satisfies it. That is the idiomatic shape — declare the secret and the provider together — and it resolves to a real secret before the provider is created.

One thing a template must do that a direct create need not: write provider as a value the request can read. A literal, or a parameter carrying one:

parameters:
Slug:
type: String
default: bedrock
resources:
Key:
type: secret
properties: { name: vendor-key, value: { param: VendorKey } }
Provider:
type: ai_provider
properties:
name: my-bedrock
provider: { param: Slug }
secret_id: { ref: Key }

provider decides whether the credential rule applies at all, so a value this API cannot read before the deploy — a {"ref": …} or a {"sub": …} composing the slug — is refused with 400 bad_request rather than assumed harmless.

Deleting a resource from the template deletes it for real​

Removing a resource from a template and updating the formation deletes the physical resource. Set deletion_policy: retain on a resource to keep it alive and only drop it from the stack — the escape hatch for a memory store or a document whose contents outlive the stack that created it.

An active formation can still carry an error​

status and error are usually read together — failed explains itself in error. One case breaks that pairing: a deploy that replaced a resource, realised the desired state, and then could not delete the one it superseded. The formation is active, because what you asked for exists and works, and error is FORMATION_REPLACE_CLEANUP_FAILED with error.meta.failures naming every resource still live.

Read it as "applied, with something left over", not as a failure. The superseded resource stays on the formation as pending cleanup and is retried on the next deploy or teardown, which clears the error once it is gone. Nothing references it any more, so it does not affect what the stack does — but it is still a real resource, and for a managed provider it is still a priced one.

So a deploy that checks only status === "active" will call a partial cleanup a clean success. Check error too.

Events are the deploy log​

Every deploy, update and teardown is recorded as an operation, with the plan it applied and — for a failure — which resource broke it. That history is what GET …/formations/{formation_id}/events returns, and it is where a failed status explains itself.

Examples​

Plan a template before applying it​

naturali plan-formation \
--project-id proj_V1StGXR8Z5jdHi6B \
--template "$(cat stack.yaml)"

Deploy an agent and the tool it calls​

The template below is the smallest useful stack: one tool, one agent that has it bound, and the agent's id as an output. ref is what removes the two-step dance — the agent is declared before the tool exists, and the runtime fills the id in.

parameters:
ToolToken:
type: string
no_echo: true
resources:
SupportTool:
type: tool
properties:
name: lookup-order
type: http
description: Looks up an order by id
parameters:
type: object
properties:
order_id:
type: string
required:
- order_id
execute:
url: https://api.example.com/orders/{order_id}
headers:
Authorization:
sub: 'Bearer ${ToolToken}'
SupportAgent:
type: agent
properties:
name: support
model: anthropic.claude-sonnet-4-20250514-v1:0
instructions: Answer order questions. Look the order up before answering.
tool_bindings:
- tool_id:
ref: SupportTool
outputs:
agent_id:
ref: SupportAgent
naturali create-formation \
--project-id proj_V1StGXR8Z5jdHi6B \
--name support-stack \
--template "$(cat stack.yaml)" \
--parameters '{"ToolToken":"tok_live_123"}'

Apply a changed template to the same stack​

Deploying the same stack again is an update, not a second stack: the formation converges on whatever the template now says.

naturali update-formation \
--project-id proj_V1StGXR8Z5jdHi6B \
--formation-id form_V1StGXR8Z5jdHi6B \
--template "$(cat stack.yaml)"

Read the deploy history​

naturali list-formation-events \
--project-id proj_V1StGXR8Z5jdHi6B \
--formation-id form_V1StGXR8Z5jdHi6B \
--limit 10
  • Agents and Tools — what a stack usually declares.
  • Projects — the boundary a formation deploys inside.
  • Secrets — where a credential a template references should live.
  • Channels — the one resource type here that is naturali's own.
  • Triggers — how a declared stack starts doing work on its own.