A board that runs itself
By the end of this tutorial you will have a working board that moves a card without you — a column that runs an agent when a card arrives, reads the agent's answer, and fires the right move on its own.
Five steps:
- Give the board something to decide with — an agent whose answer is structured, so a rule can read it.
- Define the board — its columns, its moves, and the one column that works by itself.
- Put a card on it.
- Watch it move itself.
- Read the audit trail — proof the move was made by automation, not by you.
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 nat_sk_… API key exported as NATURALI_TOKEN, and
a project with a working provider in it. Both come from
Create a provider. If schema-constrained agents are new
to you, Structured output covers them first — this
tutorial uses one but does not re-explain it.
Arrive with:
export NATURALI_TOKEN=nat_sk_...
export PROJECT=proj_V1StGXR8Z5jdHi6B
export PROVIDER=aip_V1StGXR8Z5jdHi6B
Client setup is the same as every tutorial:
- CLI
- SDK
- curl
pnpm add -g @naturali/cli
The CLI reads NATURALI_TOKEN from the environment. See the
CLI guide.
pnpm add @naturali/sdk
import { NaturaliClient } from '@naturali/sdk';
const naturali = new NaturaliClient({ token: process.env.NATURALI_TOKEN });
Every call resolves to { data, error } and never throws on a non-2xx — see the
SDK guide. The snippets read data directly for brevity.
export NATURALI_API=https://api.naturali.ai/v1
1. Give the board something to decide with
A column that routes a card has to read something from what its agent
returned, and prose is a poor thing to branch on. The answer is an
output_schema — the agent replies with a parsed object, and every field of it
becomes addressable in a routing rule.
Structured output covers that mechanism on its own. It is worth reading, but you do not need its agent here: a board routes on its own decision, so this one gets an agent shaped for the question this board asks.
- CLI
- SDK
- curl
naturali create-agent \
--provider-id "$PROVIDER" \
--name invoice-classifier \
--instructions 'Decide whether the input describes approved work. Answer with the schema.' \
--output-schema '{"type":"object","properties":{"approved":{"type":"boolean"},"reason":{"type":"string"}},"required":["approved","reason"]}'
const { data: agent } = await naturali.agents.createAgent({
path: { project_id: PROJECT },
body: {
provider_id: PROVIDER,
name: 'invoice-classifier',
instructions:
'Decide whether the input describes approved work. Answer with the schema.',
output_schema: {
type: 'object',
properties: {
approved: { type: 'boolean' },
reason: { type: 'string' },
},
required: ['approved', 'reason'],
},
},
});
curl -sS -X POST "$NATURALI_API/projects/$PROJECT/agents" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"provider_id": "'"$PROVIDER"'",
"name": "invoice-classifier",
"instructions": "Decide whether the input describes approved work. Answer with the schema.",
"output_schema": {
"type": "object",
"properties": {
"approved": { "type": "boolean" },
"reason": { "type": "string" }
},
"required": ["approved", "reason"]
}
}'
{
"id": "agent_V1StGXR8Z5jdHi6B",
"name": "invoice-classifier",
"output_schema": {
"type": "object",
"properties": {
"approved": { "type": "boolean" },
"reason": { "type": "string" }
},
"required": ["approved", "reason"]
}
}
export AGENT=agent_V1StGXR8Z5jdHi6B
2. Define the board
A board is its columns (states) and the moves between them
(transitions). This one has four columns:
| Column | What it does |
|---|---|
intake | kind: human — where cards land and wait for a person |
classify | runs the agent on entry and routes the card from its answer |
approved / rejected | terminal: true — entering one closes the card |
The whole tutorial is in the on_enter block: dispatch names the agent,
input_mapping feeds it the card's payload, and on_complete is an ordered
list of rules read against { task, result } — first match wins.
- CLI
- SDK
- curl
naturali create-board \
--project-id "$PROJECT" \
--name "Invoice triage" \
--payload-schema '{"type":"object","properties":{"text":{"type":"string"}},"required":["text"]}' \
--states '[{"name":"intake","initial":true,"kind":"human"},{"name":"classify","on_enter":{"dispatch":{"kind":"agent","agent_id":"'"$AGENT"'","input_mapping":{"text":{"var":"task.payload.text"}}},"on_complete":[{"when":{"==":[{"var":"result.object.approved"},true]},"transition":"approve"},{"when":{"==":[{"var":"result.object.approved"},false]},"transition":"reject"}],"on_failure":"needs_human"}},{"name":"approved","terminal":true},{"name":"rejected","terminal":true}]' \
--transitions '[{"name":"start_classify","from":["intake"],"to":"classify"},{"name":"approve","from":["classify"],"to":"approved"},{"name":"reject","from":["classify"],"to":"rejected"},{"name":"needs_human","from":["classify"],"to":"intake"}]'
The definition is one line per flag here; the SDK and curl tabs show the same board expanded, if you want to read it laid out.
const { data: board } = await naturali.boards.createBoard({
path: { project_id: PROJECT },
body: {
name: 'Invoice triage',
payload_schema: {
type: 'object',
properties: { text: { type: 'string' } },
required: ['text'],
},
states: [
{ name: 'intake', initial: true, kind: 'human' },
{
name: 'classify',
on_enter: {
dispatch: {
kind: 'agent',
agent_id: AGENT,
input_mapping: { text: { var: 'task.payload.text' } },
},
on_complete: [
{
when: { '==': [{ var: 'result.object.approved' }, true] },
transition: 'approve',
},
{
when: { '==': [{ var: 'result.object.approved' }, false] },
transition: 'reject',
},
],
on_failure: 'needs_human',
},
},
{ name: 'approved', terminal: true },
{ name: 'rejected', terminal: true },
],
transitions: [
{ name: 'start_classify', from: ['intake'], to: 'classify' },
{ name: 'approve', from: ['classify'], to: 'approved' },
{ name: 'reject', from: ['classify'], to: 'rejected' },
{ name: 'needs_human', from: ['classify'], to: 'intake' },
],
},
});
curl -sS -X POST "$NATURALI_API/projects/$PROJECT/boards" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"name": "Invoice triage",
"payload_schema": {
"type": "object",
"properties": { "text": { "type": "string" } },
"required": ["text"]
},
"states": [
{ "name": "intake", "initial": true, "kind": "human" },
{ "name": "classify",
"on_enter": {
"dispatch": { "kind": "agent", "agent_id": "'"$AGENT"'",
"input_mapping": { "text": { "var": "task.payload.text" } } },
"on_complete": [
{ "when": { "==": [{ "var": "result.object.approved" }, true] }, "transition": "approve" },
{ "when": { "==": [{ "var": "result.object.approved" }, false] }, "transition": "reject" }
],
"on_failure": "needs_human" } },
{ "name": "approved", "terminal": true },
{ "name": "rejected", "terminal": true }
],
"transitions": [
{ "name": "start_classify", "from": ["intake"], "to": "classify" },
{ "name": "approve", "from": ["classify"], "to": "approved" },
{ "name": "reject", "from": ["classify"], "to": "rejected" },
{ "name": "needs_human", "from": ["classify"], "to": "intake" }
]
}'
{
"id": "brd_V1StGXR8Z5jdHi6B",
"project_id": "proj_V1StGXR8Z5jdHi6B",
"name": "Invoice triage",
"states": [
{ "name": "intake", "initial": true, "kind": "human" },
{ "name": "classify", "on_enter": { "dispatch": { "kind": "agent", "agent_id": "agent_V1StGXR8Z5jdHi6B" } } },
{ "name": "approved", "terminal": true },
{ "name": "rejected", "terminal": true }
]
}
export BOARD=brd_V1StGXR8Z5jdHi6B
on_complete and on_failure route by move name, and a move that is not in
transitions — or is not valid from this column — is a 400 at write time, not
a card that silently never moves. That is why needs_human is declared even
though the happy path never uses it.
3. Put a card on it
A card is a task. It lands in the initial column, and
its payload is the working data the column's input_mapping reads — validated
against the board's payload_schema.
- CLI
- SDK
- curl
naturali create-task \
--project-id "$PROJECT" \
--board-id "$BOARD" \
--title "Invoice 4821" \
--payload '{"text":"Finance signed off on the invoice."}'
const { data: card } = await naturali.tasks.createTask({
path: { project_id: PROJECT },
body: {
board_id: BOARD,
title: 'Invoice 4821',
payload: { text: 'Finance signed off on the invoice.' },
},
});
curl -sS -X POST "$NATURALI_API/projects/$PROJECT/tasks" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"board_id": "'"$BOARD"'",
"title": "Invoice 4821",
"payload": { "text": "Finance signed off on the invoice." }
}'
{
"id": "task_V1StGXR8Z5jdHi6B",
"board_id": "brd_V1StGXR8Z5jdHi6B",
"title": "Invoice 4821",
"state": "intake",
"status": "open",
"payload": { "text": "Finance signed off on the invoice." },
"automation_status": null,
"active_dispatch": null
}
export CARD=task_V1StGXR8Z5jdHi6B
The card is parked in intake, doing nothing — that column is kind: human.
4. Watch it move itself
Fire the one move out of intake. This is the only push you give the card:
everything after it happens on its own.
Cards move only through the transition operation — a human, an integration and the board's own automation all take the identical path, which is what makes the history in step 5 complete.
- CLI
- SDK
- curl
naturali transition-task \
--project-id "$PROJECT" \
--task-id "$CARD" \
--transition start_classify
await naturali.tasks.transitionTask({
path: { project_id: PROJECT, task_id: CARD },
body: { transition: 'start_classify' },
});
curl -sS -X POST "$NATURALI_API/projects/$PROJECT/tasks/$CARD:transition" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H 'Content-Type: application/json' \
-d '{ "transition": "start_classify" }'
Entering classify fires the dispatch. Read the card back:
- CLI
- SDK
- curl
naturali get-task --project-id "$PROJECT" --task-id "$CARD"
const { data: card } = await naturali.tasks.getTask({
path: { project_id: PROJECT, task_id: CARD },
});
curl -sS "$NATURALI_API/projects/$PROJECT/tasks/$CARD" \
-H "Authorization: Bearer $NATURALI_TOKEN"
If you catch it mid-flight, the card reports the dispatch it is waiting on:
{
"state": "classify",
"automation_status": "running",
"active_dispatch": { "kind": "agent", "id": "gen_V1StGXR8Z5jdHi6B", "status": "running" }
}
You may well miss that window — a small model answers in a second or two, and the card has already moved itself:
{
"state": "approved",
"status": "closed",
"automation_status": null,
"active_dispatch": null,
"payload": {
"text": "Finance signed off on the invoice.",
"last_result": {
"model": "amazon.nova-lite-v1:0",
"object": { "approved": true, "reason": "Finance signed off on the invoice." },
"content": "{\"reason\":\"Finance signed off on the invoice.\",\"approved\":true}",
"finishReason": "stop",
"responseMessages": [{ "role": "assistant", "content": [{ "type": "text", "text": "…" }] }]
}
}
}
Nobody fired approve. The rule result.object.approved == true matched, the
column fired the move, and approved is terminal — so the card closed on
arrival.
Note what last_result holds: the whole generation result, not just the
parsed object. object is the part your rule read; content is the same JSON as
text. A later column chaining off this one reads
task.last_result.object.<field>.
classify means no rule matchedIf the card sits in classify reporting automation_status: "completed", the
dispatch finished and no rule matched — a deliberate "done, awaiting routing
or a human" state rather than a silent stall. The usual cause is a rule reading
a field the result does not have: check last_result to see what the agent
actually returned.
automation_status: "failed" is the other case — the dispatch itself failed and
no on_failure was declared. A failed dispatch never reaches on_complete, so
a catch-all rule cannot advance a card on work that did not happen.
5. Read the audit trail
Every move the card ever made, in order — the same record whether a person, an integration or the board itself fired it.
- CLI
- SDK
- curl
naturali list-task-transitions --project-id "$PROJECT" --task-id "$CARD"
const { data: history } = await naturali.tasks.listTaskTransitions({
path: { project_id: PROJECT, task_id: CARD },
});
curl -sS "$NATURALI_API/projects/$PROJECT/tasks/$CARD/transitions" \
-H "Authorization: Bearer $NATURALI_TOKEN"
{
"data": [
{ "from_state": null, "to_state": "intake", "transition": null,
"principal_kind": "api_key", "generation_id": null },
{ "from_state": "intake", "to_state": "classify", "transition": "start_classify",
"principal_kind": "api_key", "generation_id": null },
{ "from_state": "classify", "to_state": "approved", "transition": "approve",
"principal_kind": "automation", "generation_id": "gen_V1StGXR8Z5jdHi6B" }
],
"next_cursor": null
}
That last record is the value delivered. principal_kind: "automation" says the
board moved the card, and generation_id names the exact generation that
decided it — readable through Generations if you
want to see the prompt and what it cost.
Your two moves report api_key, not user: every call reaches the platform
under one service credential, so the record cannot tell a person from an
integration.
What's next
- Swap the agent column for a tool column (
kind: tool) and the same board routes on a deterministic HTTP call, with no model in the loop — Boards. - Chain columns: a later column reads the previous one's answer from
task.last_result. Mind the shape — an agent column writes its generation output, a tool column writes the run's state. To carry a specific field past more than one column, declarepayload_writeson the dispatch that produces it instead of relying onlast_result, which the next dispatch overwrites — Carrying a field past a column that doesn't return it. - Everything a board can express — backward moves, cancellation on exit, why guards and approval gates are not in this version — is on the Boards module page.