Orchestrations
A pipeline that runs your agents and tools in a fixed order, one step's output feeding the next, and pauses for a person when it needs one.
Overview
An orchestration is a directed graph: nodes that do work, and edges that say what runs next. Starting one produces a run, which accumulates state as it goes and can be read back at any point to see where it is.
The shape is forward-only and terminating: a run walks the graph and ends. That
is the whole difference from a board — a board's cards cycle
review → draft → review for as long as the work needs, so a board is a state
machine you push cards around, while an orchestration is a pipeline that runs
once and finishes.
A node does one of several things, named by its type:
type | What it does |
|---|---|
agent | Runs an agent, optionally parsing structured output. |
tool | Calls a tool — no model in the loop. |
transform | Computes a value from the run's state, with no external call. |
condition | Routes onward by matching an edge's condition label. |
knowledge | Queries a knowledge collection. |
memory_write | Writes to an actor's memory. |
human | Parks the run until a person supplies input. |
approval | Parks the run on an approval item, with its own expiry. |
loop | Runs a child orchestration once per item in a collection. |
poll | Re-calls a tool until an exit condition is true. |
delay | Waits, durably — the run survives a restart. |
webhook | Parks the run awaiting an inbound callback. |
emit_event | Emits an event any webhook can subscribe to. |
sub_orchestration | Runs another orchestration as one step. |
A node's remaining fields depend on its type, and this API passes the graph
through exactly as you wrote it rather than reshaping it. The runtime is the
authority on which combination is valid — which is why
POST /v1/projects/{project_id}/orchestrations/validate
exists: it runs the same checks create and update enforce, without persisting
anything.
See the OpenAPI spec for the full endpoint and schema reference, or browse it rendered under API Reference → Orchestrations.
Data Model
Orchestration
| Field | Type | Description |
|---|---|---|
id | string | Public orchestration ID (orch_ prefix). |
project_id | string | The owning project. |
name | string | Human-readable label. |
description | string, nullable | |
nodes | object[] | The graph's nodes; each carries at least id and type. |
edges | object[] | The connections; each carries at least from and to. |
state_schema | object, nullable | JSON Schema the accumulated state is validated against. |
input_schema | object, nullable | JSON Schema for a run's input. Its top-level properties seed state. |
created_at | string (date-time) | |
updated_at | string (date-time) |
Edge (edges[])
| Field | Type | Description |
|---|---|---|
from | string | Source node id. |
to | string | Target node id. |
condition | string, optional | For routing out of a condition node — the label to match. |
activation_group | string, optional | Groups edges so several must arrive before the target runs. |
activation_condition | all, any, optional | Whether all or any edge in the group must fire. |
Orchestration run
| Field | Type | Description |
|---|---|---|
id | string | Public run ID (orch_run_ prefix). |
orchestration_id | string | The orchestration this run is of. |
project_id | string | The owning project. |
status | string | See Run lifecycle. |
state | object | The accumulated state, as of this read. |
active_nodes | string[] | Node ids currently executing. |
artifacts | object | Node id → the output that node produced. |
error | object, nullable | Structured failure detail; null unless status is failed. |
trace_id | string, nullable | The trace recording what the run's agents did. |
input | object, nullable | The input the run was started with. |
output | object, nullable | The terminal node's artifact(s), once the run has succeeded. |
node_executions | object[] | One record per node attempt (see below). |
usage | object | Token/cost roll-up across the run's generations. Present on a single-run read; omitted from lists. |
required_action | object, nullable | What the run is waiting for while awaiting_input. |
started_at | string (date-time), nullable | |
completed_at | string (date-time), nullable | |
created_at | string (date-time) | |
updated_at | string (date-time) |
Node execution (node_executions[])
The orchestration analogue of a trace step — what a node received, what it produced, and whether it worked.
| Field | Type | Description |
|---|---|---|
node_id | string | The node that ran. |
node_type | string, nullable | Its type. |
attempt | integer | 1-based. A node with a retry policy produces one record per attempt. |
status | completed, failed, requires_action, skipped | |
input | object, nullable | The resolved input the node received. |
output | object, nullable | The artifact it produced; null when it failed. |
error | object, nullable | Failure detail. |
started_at | string (date-time), nullable | |
completed_at | string (date-time), nullable |
Key Concepts
A run is durable and asynchronous by default
Starting a run returns immediately with status: "queued", and a background
worker picks it up. Progress is read by polling
GET /v1/projects/{project_id}/orchestration-runs/{orchestration_run_id}.
That is the mode to build on: delay and poll waits park the run without
holding a worker, and it survives a restart — a run sleeping on a two-hour
delay is still there afterwards, on the same node, with the same state.
Pass wait: true on start to block until the run reaches a terminal or
awaiting_input state and get the settled run back in one call. It is
convenient for a short pipeline and for scripts; a run with any real waiting in
it should not be started that way.
Run lifecycle
status | Meaning |
|---|---|
queued | Created, waiting for a worker. |
running | Actively executing. |
sleeping | Parked on a delay/poll wait, or a node's retry backoff. No worker held. |
awaiting_input | Parked on a human, approval or webhook node. |
succeeded | Finished. output holds the terminal artifact(s). |
failed | A node failed terminally. error says which and why. |
cancelled | Cancelled before reaching a terminal state. |
expired | A wait passed its deadline. |
The first four are non-terminal; the last four are terminal, and cancelling a
run that has already reached one is a 409.
Pausing for a person: resume is not human-input
Two routes act on an awaiting_input run, and they are not interchangeable:
POST /v1/projects/{project_id}/orchestration-runs/{orchestration_run_id}/human-inputsatisfies the pause. It carries thenode_idthe run is parked on and theoutputthe person supplied, and the run advances past that node.POST /v1/projects/{project_id}/orchestration-runs/{orchestration_run_id}/resumeonly re-drives the run from its last checkpoint. It carries no payload, so a run parked on a human or webhook node re-parks on the same node. It is for nudging a run that should have progressed on its own, not for answering it.
Reach for human-input when a person has decided something. Reach for resume
essentially never, unless a run looks stuck.
required_action on the run says which of the two is wanted: its type is
human_input or webhook_receive, and it carries the node_id, the prompt
shown to the reviewer, and any constrained options.
Handing a run a per-user credential
A pipeline is often run for somebody — their account on a third-party API,
their calendar, their orders — while the orchestration, its agents and its tools
are defined once for the whole project. tool_context on
POST /v1/projects/{project_id}/orchestration-runs
is where that per-run difference goes: a flat map of strings, forwarded by the
runtime as X-Naturali-Context-<key> request headers on every http/mcp
tool call the run's agent nodes make, including the agents of any
loop or sub_orchestration child run.
The point is that a tool endpoint reads the value from a header it can trust instead of from model output. Nothing about the credential is written into a prompt, so no agent can be talked into revealing or altering it.
Three properties make it usable for a credential:
- It is stored on the run, not attached to one request, so it survives an
awaiting_inputpause, a durabledelay, a background drive and a restart — a run that resumes tomorrow still calls its tools with it. - It is never returned. Any principal on the project can read the project's
runs, so the bag is write-only on this API: a run read shows no
tool_context, the same way a tool read shows no auth headers. - It can be confined. By default every key reaches every tool the agent can
call; set the tool's
context_keysand a credential stops egressing to the rest of the tool set.
A key becomes an HTTP header name verbatim — no character is re-cased, so
ocaToken and oca_token are different keys — and may contain only letters,
digits and !#$%&'*+-.^_`|~. Two keys that differ only in case are rejected,
because HTTP would fold them into one header and silently drop a value.
To land a value in the header a target already expects, have the tool declare
Authorization: "Bearer {{context:ocaToken}}" in its own headers — see
Tools → landing the value in Authorization.
Start a run for one user:
- CLI
- SDK
- curl
naturali start-orchestration-run \
--project-id proj_V1StGXR8Z5jdHi6B \
--orchestration-id orch_V1StGXR8Z5jdHi6B \
--input '{"theme":"spring collection"}' \
--tool-context '{"ocaToken":"usr_token_V1StGXR8Z5jdHi6B"}'
const { data: run } = await naturali.orchestrations.startOrchestrationRun({
path: { project_id: 'proj_V1StGXR8Z5jdHi6B' },
body: {
orchestration_id: 'orch_V1StGXR8Z5jdHi6B',
input: { theme: 'spring collection' },
tool_context: { ocaToken: 'usr_token_V1StGXR8Z5jdHi6B' },
},
});
curl -X POST https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/orchestration-runs \
-H "Authorization: Bearer $NATURALI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"orchestration_id": "orch_V1StGXR8Z5jdHi6B",
"input": { "theme": "spring collection" },
"tool_context": { "ocaToken": "usr_token_V1StGXR8Z5jdHi6B" }
}'
A trigger that fires the same orchestration on a schedule
carries no tool_context — a schedule has no user to be run for. Start the run
directly when it needs one.
Validate before you write
A graph is rejected on create and update when it has blocking errors — a
duplicate node id, an edge naming a node that does not exist, a cycle with no
loop node in it, an input_mapping reference that resolves to nothing. Those
come back as 400 invalid_orchestration, with the runtime's own rule named
under details.upstream_code.
POST /v1/projects/{project_id}/orchestrations/validate
runs exactly those checks and persists nothing, so an editor can check a graph
as it is being written. It also returns non-blocking warnings — a state key
only written on one branch of a condition, for instance — which create and
update let through.
Running one on a schedule
Nothing here holds a clock. To run a graph nightly, point a
trigger at it with target_type: orchestration — each fire
starts a run, and the trigger's input becomes that run's input, so the
input_schema above sees the same shape either way. The firing's result
carries the orch_run_ id it started.
Listing runs is scoped by orchestration
GET /v1/projects/{project_id}/orchestration-runs
requires orchestration_id. It is not an optional filter: it is what scopes the
list, so ask for one orchestration's runs at a time rather than the project's.
Deleting an orchestration deletes its runs
DELETE /v1/projects/{project_id}/orchestrations/{orchestration_id}
removes the definition and every run of it, including their state,
artifacts and node execution records. There is no guard for runs still in
flight, and no way to get the history back. The traces the run's
agents produced are separate records and survive.
Examples
Create a two-step pipeline: an agent drafts, a person approves.
- CLI
- SDK
- curl
naturali create-orchestration \
--project-id proj_V1StGXR8Z5jdHi6B \
--name "Draft and approve" \
--nodes '[{"id":"draft","type":"agent","agent_id":"agent_V1StGXR8Z5jdHi6B","input_mapping":{"theme":{"var":"theme"}},"state_mapping":{"draft":{"var":"output.content"}}},{"id":"review","type":"human","prompt":"Publish this draft?","options":["yes","no"]}]' \
--edges '[{"from":"draft","to":"review"}]' \
--input-schema '{"type":"object","properties":{"theme":{"type":"string"}}}'
const { data: orchestration } =
await naturali.orchestrations.createOrchestration({
path: { project_id: 'proj_V1StGXR8Z5jdHi6B' },
body: {
name: 'Draft and approve',
nodes: [
{
id: 'draft',
type: 'agent',
agent_id: 'agent_V1StGXR8Z5jdHi6B',
input_mapping: { theme: { var: 'theme' } },
state_mapping: { draft: { var: 'output.content' } },
},
{
id: 'review',
type: 'human',
prompt: 'Publish this draft?',
options: ['yes', 'no'],
},
],
edges: [{ from: 'draft', to: 'review' }],
input_schema: {
type: 'object',
properties: { theme: { type: 'string' } },
},
},
});
curl -X POST https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/orchestrations \
-H "Authorization: Bearer $NATURALI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Draft and approve",
"nodes": [
{
"id": "draft",
"type": "agent",
"agent_id": "agent_V1StGXR8Z5jdHi6B",
"input_mapping": { "theme": { "var": "theme" } },
"state_mapping": { "draft": { "var": "output.content" } }
},
{
"id": "review",
"type": "human",
"prompt": "Publish this draft?",
"options": ["yes", "no"]
}
],
"edges": [{ "from": "draft", "to": "review" }],
"input_schema": {
"type": "object",
"properties": { "theme": { "type": "string" } }
}
}'
Start a run of it:
- CLI
- SDK
- curl
naturali start-orchestration-run \
--project-id proj_V1StGXR8Z5jdHi6B \
--orchestration-id orch_V1StGXR8Z5jdHi6B \
--input '{"theme":"spring collection"}'
const { data: run } = await naturali.orchestrations.startOrchestrationRun({
path: { project_id: 'proj_V1StGXR8Z5jdHi6B' },
body: {
orchestration_id: 'orch_V1StGXR8Z5jdHi6B',
input: { theme: 'spring collection' },
},
});
// run.status === 'queued' — poll getOrchestrationRun for progress.
curl -X POST https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/orchestration-runs \
-H "Authorization: Bearer $NATURALI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"orchestration_id": "orch_V1StGXR8Z5jdHi6B",
"input": { "theme": "spring collection" }
}'
Once the run reports awaiting_input, answer the human node:
- CLI
- SDK
- curl
naturali submit-human-input \
--project-id proj_V1StGXR8Z5jdHi6B \
--orchestration-run-id orch_run_V1StGXR8Z5jdHi6B \
--node-id review \
--output '{"choice":"yes"}'
const { data: advanced } = await naturali.orchestrations.submitHumanInput({
path: {
project_id: 'proj_V1StGXR8Z5jdHi6B',
orchestration_run_id: 'orch_run_V1StGXR8Z5jdHi6B',
},
body: { node_id: 'review', output: { choice: 'yes' } },
});
curl -X POST https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/orchestration-runs/orch_run_V1StGXR8Z5jdHi6B/human-input \
-H "Authorization: Bearer $NATURALI_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "node_id": "review", "output": { "choice": "yes" } }'