Skip to main content

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:

  1. Give the board something to decide with — an agent whose answer is structured, so a rule can read it.
  2. Define the board — its columns, its moves, and the one column that works by itself.
  3. Put a card on it.
  4. Watch it move itself.
  5. 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:

pnpm add -g @naturali/cli

The CLI reads NATURALI_TOKEN from the environment. See the CLI guide.

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.

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

ColumnWhat it does
intakekind: human — where cards land and wait for a person
classifyruns the agent on entry and routes the card from its answer
approved / rejectedterminal: 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.

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.

{
"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
Every move a rule names must be declared

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.

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

naturali transition-task \
--project-id "$PROJECT" \
--task-id "$CARD" \
--transition start_classify

Entering classify fires the dispatch. Read the card back:

naturali get-task --project-id "$PROJECT" --task-id "$CARD"

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>.

A card parked in classify means no rule matched

If 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.

naturali list-task-transitions --project-id "$PROJECT" --task-id "$CARD"
{
"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, declare payload_writes on the dispatch that produces it instead of relying on last_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.