Skip to main content

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:

  1. Create the writer agent.
  2. Declare the workflow.
  3. Open a task — the agent starts drafting.
  4. Read the draft.
  5. Leave feedback.
  6. Send it back — the backward move.
  7. Approve it.
  8. 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 as PROJECT and a working AI provider id as PROVIDER — 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
Each draft is a run

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.

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.'
{
"id": "agent_5Esky5akqCfoCjJY",
"name": "reply-writer",
"ai_provider_id": "aip_CLcDNRe8GpP1pBNs",
"model": null,
"version": 1
}
export WRITER=agent_5Esky5akqCfoCjJY

2. Declare the workflow​

Three states and three moves:

  • draft is initial and carries on_enter: entering it dispatches the writer. input_mapping builds the agent's message from the task with JSON Logic — the request, and the feedback or none. When the draft completes, on_complete fires submit.
  • review is a human state: it dispatches nothing and waits for a person.
  • sent is terminal: reaching it closes the task.
  • revise goes from review back to draft — 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.

naturali create-workflow \
--project-id "$PROJECT" \
--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.

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

naturali get-task \
--project-id "$PROJECT" \
--task-id "$TASK"
{
"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.

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

naturali transition-task \
--project-id "$PROJECT" \
--task-id "$TASK" \
--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.

naturali transition-task \
--project-id "$PROJECT" \
--task-id "$TASK" \
--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.

naturali get-task-history \
--project-id "$PROJECT" \
--task-id "$TASK"
[
{
"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 guard refuses a transition unless the task passes a JSON Logic check, and requires_approval parks it for a person; see Guards and approvals.
  • Dispatch more than an agent — on_enter can 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.