Model a process as a workflow
By the end of this tutorial you will have a customer-reply process modelled as a workflow: an agent drafts, a person reviews, sends the draft back with feedback and approves the redraft — proven by a task whose history records every move, the backward one included.
Workflow or orchestration? An orchestration runs forward to an end on its own. A workflow holds a piece of work in named states that people and agents move it between — back as well as forward — for as long as the work lives.
Eight steps:
- Create the writer agent.
- Declare the workflow.
- Open a task — the agent starts drafting.
- Read the draft.
- Leave feedback.
- Send it back — the backward move.
- Approve it.
- Read the history — the proof.
Every call is shown for all three clients. The ids in the responses are examples — copy the ones your own calls return.
Prerequisites
- A credential exported as
NATURALI_TOKEN, a project id asPROJECTand a working AI provider id asPROVIDER— from Your first agent generation. - A client set up as in that tutorial.
export NATURALI_TOKEN=nat_sk_...
export PROJECT=proj_V1StGXR8Z5jdHi6B
export PROVIDER=aip_V1StGXR8Z5jdHi6B
Every entry into a state that dispatches an agent is one generation, so this tutorial counts as two runs against your plan and draws its managed tokens on the balance. A move into such a state is refused while the project owes for usage already served — see A negative balance stops what a task would dispatch.
1. Create the writer agent
The agent the workflow calls to draft. It reads one message holding the customer's request and any reviewer feedback.
- CLI
- SDK
- curl
naturali create-agent \
--project-id "$PROJECT" \
--ai-provider-id "$PROVIDER" \
--name reply-writer \
--instructions 'You draft short replies to customer requests for a small online store. Write two or three sentences, friendly and specific. If reviewer feedback is given, follow it. Reply with only the draft.'
const { data: writer } = await naturali.agents.createAgent({
path: { project_id: process.env.PROJECT! },
body: {
ai_provider_id: process.env.PROVIDER!,
name: 'reply-writer',
instructions:
'You draft short replies to customer requests for a small online store. Write two or three sentences, friendly and specific. If reviewer feedback is given, follow it. Reply with only the draft.',
},
});
curl -X POST "https://api.naturali.ai/v1/projects/$PROJECT/agents" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"ai_provider_id\": \"$PROVIDER\",
\"name\": \"reply-writer\",
\"instructions\": \"You draft short replies to customer requests for a small online store. Write two or three sentences, friendly and specific. If reviewer feedback is given, follow it. Reply with only the draft.\"
}"
{
"id": "agent_5Esky5akqCfoCjJY",
"name": "reply-writer",
"ai_provider_id": "aip_CLcDNRe8GpP1pBNs",
"model": null,
"version": 1
}
export WRITER=agent_5Esky5akqCfoCjJY
2. Declare the workflow
draftisinitialand carrieson_enter: entering it dispatches the writer.input_mappingbuilds the agent's message from the task with JSON Logic — the request, and the feedback ornone. When the draft completes,on_completefiressubmit.reviewis ahumanstate: it dispatches nothing and waits for a person.sentisterminal: reaching it closes the task.revisegoes fromreviewback todraft— the move a one-way graph cannot make.
STATES=$(cat <<EOF
[
{
"name": "draft",
"initial": true,
"on_enter": {
"dispatch": {
"kind": "agent",
"agent_id": "$WRITER",
"input_mapping": {
"prompt": { "cat": [
"Customer request: ", { "var": "task.payload.request" },
"\nReviewer feedback: ", { "var": ["task.payload.feedback", "none"] }
] }
}
},
"on_complete": [{ "when": true, "transition": "submit" }],
"on_failure": "submit"
}
},
{ "name": "review", "kind": "human" },
{ "name": "sent", "terminal": true }
]
EOF
)
TRANSITIONS='[
{ "name": "submit", "from": ["draft"], "to": "review" },
{ "name": "revise", "from": ["review"], "to": "draft" },
{ "name": "approve", "from": ["review"], "to": "sent" }
]'
on_failure: submit sends a failed draft to review as well, so a person
always sees the task rather than finding it stuck.
- CLI
- SDK
- curl
naturali create-workflow \
--project-id "$PROJECT" \
--name customer-reply \
--states "$STATES" \
--transitions "$TRANSITIONS"
const { data: workflow } = await naturali.workflows.createWorkflow({
path: { project_id: process.env.PROJECT! },
body: {
name: 'customer-reply',
states: [
{
name: 'draft',
initial: true,
on_enter: {
dispatch: {
kind: 'agent',
agent_id: writer!.id,
input_mapping: {
prompt: {
cat: [
'Customer request: ',
{ var: 'task.payload.request' },
'\nReviewer feedback: ',
{ var: ['task.payload.feedback', 'none'] },
],
},
},
},
on_complete: [{ when: true, transition: 'submit' }],
on_failure: 'submit',
},
},
{ name: 'review', kind: 'human' },
{ name: 'sent', terminal: true },
],
transitions: [
{ name: 'submit', from: ['draft'], to: 'review' },
{ name: 'revise', from: ['review'], to: 'draft' },
{ name: 'approve', from: ['review'], to: 'sent' },
],
},
});
curl -X POST "https://api.naturali.ai/v1/projects/$PROJECT/workflows" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"name\": \"customer-reply\",
\"states\": $STATES,
\"transitions\": $TRANSITIONS
}"
{
"id": "wfl_UN6kjkWLS93DdpB7",
"name": "customer-reply",
"version": 1,
"states": [
{
"name": "draft",
"initial": true,
"on_enter": {
"dispatch": {
"kind": "agent",
"agent_id": "agent_5Esky5akqCfoCjJY",
"input_mapping": {
"prompt": {
"cat": [
"Customer request: ",
{ "var": "task.payload.request" },
"\nReviewer feedback: ",
{ "var": ["task.payload.feedback", "none"] }
]
}
}
},
"on_complete": [{ "when": true, "transition": "submit" }],
"on_failure": "submit"
}
},
{ "kind": "human", "name": "review" },
{ "name": "sent", "terminal": true }
],
"transitions": [
{ "to": "review", "from": ["draft"], "name": "submit" },
{ "to": "draft", "from": ["review"], "name": "revise" },
{ "to": "sent", "from": ["review"], "name": "approve" }
]
}
export WORKFLOW=wfl_UN6kjkWLS93DdpB7
3. Open a task
A task is one customer request moving through the workflow. Its payload is
yours — here the request the writer reads. The task starts in draft, so
creating it dispatches the writer at once.
- CLI
- SDK
- curl
naturali create-task \
--project-id "$PROJECT" \
--workflow-id "$WORKFLOW" \
--title "Order 1042: late delivery" \
--payload '{ "request": "My order 1042 was due on Monday and still has not arrived. Where is it?" }'
const { data: task } = await naturali.tasks.createTask({
path: { project_id: process.env.PROJECT! },
body: {
workflow_id: workflow!.id,
title: 'Order 1042: late delivery',
payload: {
request:
'My order 1042 was due on Monday and still has not arrived. Where is it?',
},
},
});
curl -X POST "https://api.naturali.ai/v1/projects/$PROJECT/tasks" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"workflow_id\": \"$WORKFLOW\",
\"title\": \"Order 1042: late delivery\",
\"payload\": { \"request\": \"My order 1042 was due on Monday and still has not arrived. Where is it?\" }
}"
{
"id": "task_AAek4fmGzU5FiPJm",
"workflow_id": "wfl_UN6kjkWLS93DdpB7",
"workflow_version": 1,
"title": "Order 1042: late delivery",
"state": "draft",
"status": "open",
"payload": {
"request": "My order 1042 was due on Monday and still has not arrived. Where is it?"
},
"last_result": null,
"automation_chain_depth": 0,
"pending_transition": null
}
export TASK=task_AAek4fmGzU5FiPJm
workflow_version is
pinned: editing the workflow later leaves this task on
the machine it started on.
4. Read the draft
When the writer finishes, on_complete fires submit and the task moves to
review on its own. Read it until state is review — here it took about a
second. The draft is in last_result.content.
- CLI
- SDK
- curl
naturali get-task \
--project-id "$PROJECT" \
--task-id "$TASK"
const waitForReview = async () => {
for (;;) {
const { data: current } = await naturali.tasks.getTask({
path: { project_id: process.env.PROJECT!, task_id: task!.id },
});
if (current!.state === 'review') return current!;
await new Promise((resolve) => {
return setTimeout(resolve, 1000);
});
}
};
const drafted = await waitForReview();
console.log(drafted.last_result);
curl "https://api.naturali.ai/v1/projects/$PROJECT/tasks/$TASK" \
-H "Authorization: Bearer $NATURALI_TOKEN"
{
"id": "task_AAek4fmGzU5FiPJm",
"state": "review",
"status": "open",
"last_result": {
"model": "glm-4.7-flash",
"content": "It looks like your order 1042 is running a bit late today. We’ve checked the status and it appears to be stuck with the carrier pending final delivery, so I expect it with you by end of day. Thanks for your patience",
"finishReason": "stop"
},
"automation_status": null,
"automation_chain_depth": 1
}
The draft promises a delivery time nobody checked — the reviewer will not send that.
5. Leave feedback
PATCH /v1/projects/{project_id}/tasks/{task_id}
edits the payload,
never the state. A payload patch merges into what is there,
so the request stays and feedback is added — the field the writer's
input_mapping reads.
- CLI
- SDK
- curl
naturali update-task \
--project-id "$PROJECT" \
--task-id "$TASK" \
--payload '{ "feedback": "Do not promise a delivery time. Say we opened a trace with the carrier and will email an update within 24 hours." }'
await naturali.tasks.updateTask({
path: { project_id: process.env.PROJECT!, task_id: task!.id },
body: {
payload: {
feedback:
'Do not promise a delivery time. Say we opened a trace with the carrier and will email an update within 24 hours.',
},
},
});
curl -X PATCH "https://api.naturali.ai/v1/projects/$PROJECT/tasks/$TASK" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "payload": { "feedback": "Do not promise a delivery time. Say we opened a trace with the carrier and will email an update within 24 hours." } }'
{
"id": "task_AAek4fmGzU5FiPJm",
"state": "review",
"status": "open",
"payload": {
"request": "My order 1042 was due on Monday and still has not arrived. Where is it?",
"feedback": "Do not promise a delivery time. Say we opened a trace with the carrier and will email an update within 24 hours."
}
}
6. Send it back
You move a task by firing a transition by name with
POST /v1/projects/{project_id}/tasks/{task_id}/transitions;
the workflow
decides whether the move is legal. revise takes the task from
review back to draft, which dispatches the writer again — this time with
the feedback.
- CLI
- SDK
- curl
naturali transition-task \
--project-id "$PROJECT" \
--task-id "$TASK" \
--transition revise \
--note "No delivery promises"
await naturali.tasks.transitionTask({
path: { project_id: process.env.PROJECT!, task_id: task!.id },
body: { transition: 'revise', note: 'No delivery promises' },
});
const redrafted = await waitForReview();
console.log(redrafted.last_result);
curl -X POST "https://api.naturali.ai/v1/projects/$PROJECT/tasks/$TASK/transitions" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "transition": "revise", "note": "No delivery promises" }'
{
"id": "task_AAek4fmGzU5FiPJm",
"state": "draft",
"status": "open",
"automation_chain_depth": 0
}
Read the task again as in step 4. Once it is back in
review, the new draft follows the feedback:
{
"state": "review",
"last_result": {
"content": "We’ve opened a trace with the carrier to locate your order. We will send you an email with further updates within 24 hours."
}
}
7. Approve it
approve moves the task to sent, a terminal state, which closes it.
- CLI
- SDK
- curl
naturali transition-task \
--project-id "$PROJECT" \
--task-id "$TASK" \
--transition approve
const { data: sent } = await naturali.tasks.transitionTask({
path: { project_id: process.env.PROJECT!, task_id: task!.id },
body: { transition: 'approve' },
});
curl -X POST "https://api.naturali.ai/v1/projects/$PROJECT/tasks/$TASK/transitions" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "transition": "approve" }'
{
"id": "task_AAek4fmGzU5FiPJm",
"state": "sent",
"status": "closed"
}
A closed task moves no further: firing another transition now answers
409 TASK_TRANSITION_CONFLICT.
8. Read the history
GET /v1/projects/{project_id}/tasks/{task_id}/history
returns every move, oldest first, with who made it and what caused it. It is
append-only.
- CLI
- SDK
- curl
naturali get-task-history \
--project-id "$PROJECT" \
--task-id "$TASK"
const { data: history } = await naturali.tasks.getTaskHistory({
path: { project_id: process.env.PROJECT!, task_id: task!.id },
});
for (const move of history ?? []) {
console.log(move.from_state, '→', move.to_state, move.principal_kind);
}
curl "https://api.naturali.ai/v1/projects/$PROJECT/tasks/$TASK/history" \
-H "Authorization: Bearer $NATURALI_TOKEN"
[
{
"from_state": null,
"to_state": "draft",
"transition": null,
"principal_kind": "api_key",
"generation_id": null,
"note": null
},
{
"from_state": "draft",
"to_state": "review",
"transition": "submit",
"principal_kind": "automation",
"generation_id": "gen_vkrMX0GBQEP4pRJ5",
"note": null
},
{
"from_state": "review",
"to_state": "draft",
"transition": "revise",
"principal_kind": "api_key",
"generation_id": null,
"note": "No delivery promises"
},
{
"from_state": "draft",
"to_state": "review",
"transition": "submit",
"principal_kind": "automation",
"generation_id": "gen_OEvJjZQ1pyoEQgD0",
"note": null
},
{
"from_state": "review",
"to_state": "sent",
"transition": "approve",
"principal_kind": "api_key",
"generation_id": null,
"note": null
}
]
That is the process, proven: each submit was made by
automation and names
the generation that drafted, every move you made
is api_key with your key's id in principal_id (trimmed above), and the
revise from review back to draft sits in the record beside the others.
What's next
- Guard a move — a
guardrefuses a transition unless the task passes a JSON Logic check, andrequires_approvalparks it for a person; see Guards and approvals. - Dispatch more than an agent —
on_entercan run a tool or a whole orchestration. - Change the process safely — editing a workflow archives the previous version and leaves tasks in flight on theirs; see Versioning.
- Pause the automation — a paused task keeps its place and dispatches nothing; see A pause stops the automation.