Boards
A kanban whose columns can run your agents and tools, and route cards onward by themselves.
Overview
A board is a versioned definition of columns (states) and the moves
between them (transitions). The cards that live on it are Tasks.
A column can do one of five things:
- run an agent on entry, and route the card onward from what the agent returned;
- run a tool on entry, deterministically — no model in the loop — and route the card onward from the tool's result;
- poll a tool on entry — call it repeatedly, with a wait between attempts, until a condition on its response is met (or a call ceiling is reached) — for an asynchronous job with no push/webhook completion signal: a render, a batch export, any status endpoint you'd otherwise have to poll yourself;
- wait on entry (
kind: delay) before completing — a plain pause, no tool call, for a cooldown or a deliberate throttle between automated steps; - park the card (
kind: human) until a person fires one of the moves the board declares from it.
That is the whole model. Backward moves are ordinary moves, so a card can cycle
review → draft → review as many times as the work needs — which is exactly what
a forward-only pipeline cannot express, and why this is a board rather than an
orchestration.
Each board is backed by a runtime workflow; the naturali record maps the two. The definition lives on the runtime and is read from there when shaping responses, so a board reads back as the definition you wrote.
See the OpenAPI spec for the full endpoint and schema reference, or browse it rendered under API Reference → Boards.
Data Model
Board
| Field | Type | Description |
|---|---|---|
id | string | Public board ID (brd_ prefix). |
project_id | string | The owning project. |
name | string | Human-readable label. |
description | string, nullable | |
states | object[] | The columns (see below). |
transitions | object[] | The moves the board allows. |
payload_schema | object, nullable | JSON Schema every card's payload is validated against. |
created_at | string (date-time) | |
updated_at | string (date-time) |
Column (states[])
| Field | Type | Description |
|---|---|---|
name | string | Unique within the board. |
initial | boolean | Exactly one column must set this — where new cards land. |
terminal | boolean | Entering a terminal column closes the card. |
kind | human, nullable | A parking column: it never dispatches. |
stalled_after | integer, nullable | Seconds a card may sit here before it counts as parked too long. The card then reports stalled_at and stalled — see Spotting a parked card. It never moves a card. |
on_enter | object, nullable | The column's automation (see below). |
Move (transitions[])
| Field | Type | Description |
|---|---|---|
name | string | Unique within the board; the name a caller fires. |
from | string[] | The columns the move is valid from. |
to | string | The single column it moves the card to. |
A move that is not declared here cannot be fired by anyone — there is no
free-move escape hatch. Declare an explicit any-column move (listing every
column in from) if a board needs one.
Key Concepts
Column automation (on_enter)
{
"dispatch": {
"kind": "agent",
"agent_id": "agt_writer",
"input_mapping": { "theme": { "var": "task.payload.theme" } }
},
"on_complete": [
{ "when": { "==": [{ "var": "result.object.approved" }, true] }, "transition": "approve" },
{ "when": true, "transition": "reject" }
],
"on_failure": "needs_human"
}
dispatch— one agent (kind: agent,agent_id), one tool (kind: tool,tool_id), a polled tool (kind: poll,tool_id,exit_condition,interval— see Poll and delay columns below), or a plain wait (kind: delay,duration).input_mappingis JSON Logic over{ task }, so it reads the card's payload (not accepted ondelay, which names no resource).payload_writesis JSON Logic over the dispatch's own result (the same contexton_completesees,{ task, result }) that writes named keys into the card's payload when the dispatch completes — see Carrying a field past a column that doesn't return it below.on_complete— rules evaluated in order against{ task, result }; the first match fires its move, as theautomationprincipal, through the same single door a person uses.resultis the agent's generation output, or a tool's own result object. Routing an agent column on structured output (result.object.…) needs the agent to declare anoutput_schema; set one and every field of it is addressable in a rule. The result is also written to the card's read-onlylast_resultfield for a later column to map in — but mind its shape: an agent column writes its generation output, while a tool column writes the enclosing run's state, which leaves the tool's own result undernodes.tool. Chain off a tool column with{"var": "task.last_result.nodes.tool.<field>"}. Inside the tool column's ownon_complete,result.<field>works as written.last_resultalso carriesinput— exactly what that column's owninput_mappingresolved — alongsidenodes.tool; see Carrying a field past a column that doesn't return it below for what that's for.on_failure— where a card goes when the dispatch fails terminally. Accepts a declared move, or a column reachable from this one by exactly one declared move. Omit it to leave a failed card in place for a person, reportingautomation_status: failed. Applies to every dispatch kind alike.
A failed dispatch never reaches on_complete, so a catch-all {"when": true}
rule cannot advance a card on failed work, and a failure leaves no
last_result behind.
When no rule matches, the card stays put reporting
automation_status: completed — a deliberate "done, awaiting routing or a human"
state, never a silent stall.
Poll and delay columns
Before poll and delay existed, the only way to wait between attempts at
something was a kind: human column that did nothing on its own, with the
actual retry pushed onto a person or an external scheduler hitting the board
API. poll and delay are for exactly that gap: an asynchronous job with no
push/webhook completion signal.
{
"dispatch": {
"kind": "poll",
"tool_id": "tool_get_render_status",
"input_mapping": { "render_id": { "var": "task.payload.render_id" } },
"exit_condition": {
"or": [
{ "==": [{ "var": "response.done" }, true] },
{ "==": [{ "var": "response.fatal_error" }, true] }
]
},
"interval": "20s",
"max_iterations": 60,
"on_timeout": "fail"
},
"on_complete": [
{ "when": { "==": [{ "var": "result.fatal_error" }, true] }, "transition": "render_failed" },
{ "when": { "==": [{ "var": "result.done" }, true] }, "transition": "render_done" }
]
}
kind: pollcallstool_idrepeatedly.exit_conditionis JSON Logic evaluated after every attempt against{ response, attempt }(responseis the tool's latest result,attempta 1-based count); a truthy result stops polling.intervalis the wait between attempts (20s,5m,2h, …).max_iterationscaps the number of attempts (default 10, up to 1000).on_timeoutdecides what happens if the ceiling is reached with the condition still unmet:failfails the dispatch (routed viaon_failure, same as any failed dispatch);continue(the default) completes the dispatch anyway, soon_completecan branch on the unmet condition.kind: delayjust waitsduration(5m,2h, …) before completing — no tool call, noinput_mapping. Route it onward with an ordinaryon_completerule, the same as any other column.- The wait is durable, not a held-open request. Both kinds park the card
on the runtime the same way a
sleepingorchestration run does — no in-process sleep, no HTTP request held open — so a card mid-poll or mid-delay survives a platform restart exactly like one sitting in ahumancolumn does. resultfor a poll column is the tool's own latest response — the same shapeexit_condition'sresponsesees — soon_completeandpayload_writesread it asresult.<field>, identically to a tool column. The poll's own bookkeeping (how many attempts it took, whether it stopped on the condition or on the ceiling) is not addressable from a rule: route on the response instead, which underon_timeout: continuestill carries the not-finished-yet shape that made the condition stay false.
The render-status example above replaces a two-column workaround (a human
parking column plus a manual "check status" column an operator or an external
scheduler had to trigger) with one poll column that retries itself.
Carrying a field past a column that doesn't return it
last_result is overwritten whole by every dispatch, so a field produced two
or more columns back — an agent's structured title/text, say, or a
document id a POST tool returned — is gone by the time a later column's
response has taken its place, unless something carries it forward
deterministically.
Declare payload_writes on the dispatch that produces the value:
{
"dispatch": {
"kind": "agent",
"agent_id": "agt_writer",
"payload_writes": {
"post_text_document_id": { "var": "result.object.document_id" }
}
}
}
Each key is written into the card's payload in the same atomic step that
already writes last_result, evaluated over { task, result } — the
identical context and result shape on_complete sees, so a tool column reads
its own result at result.<field> here exactly as it does in its own
on_complete rules, with no nodes.tool prefix to remember. A later column
reads the value straight back with
{"var": "task.payload.<key>"} — no smuggling required.
A write is a raw overwrite of its key: in a board with a loop
(review → draft → review), a value written on an earlier pass through a
column lingers in the payload until that column runs again. Design keys with
that in mind.
Before this, carrying a value past one hop meant one of two smuggling
patterns: echoing it through an agent's output_schema ("repeat this id
unchanged," with no corruption detection if the model altered it), or adding
it to an unrelated column's own input_mapping purely so it would resurface
in last_result.input ({"var": "task.last_result.input.<field>"})
one column later. payload_writes replaces both — prefer it for any new
board.
GET tool columns can fail against a strict target
A tool dispatched from a column carries its parameters as a request body
regardless of execute.method (see Tools) —
harmless against most GET endpoints, but a target that rejects a body on
GET will fail every time as a column dispatch, even for a tool that works
fine when an agent calls it with the same definition. If a GET tool column
fails outright, try POST against an equivalent endpoint before assuming the
tool or the board definition is wrong.
Entering a column cancels the last one's work
The card's position is the source of truth. Moving a card out of a column cancels whatever that column had in flight, so a human who drags a card back does not have a stale agent run finishing behind them.
Human columns are the UI's contract
A kind: human column dispatches nothing. Which moves the board declares from it
is the set of buttons a UI should render — read the board and you know the legal
moves, with no second source of truth.
Automation agents must finish on their own
A column's agent has to complete server-side. An agent that stops to ask its
caller to run a function (a client-executed tool) cannot be resumed from a board,
so give automation agents http/mcp tools only.
Spotting a parked card
A column's automation can fail in a way routing never sees. on_failure covers
a dispatch that failed; it does nothing for one that never came back — a save
column posting to an external service that hangs, or an agent whose run was
cancelled out from under it. The card just sits there, and on a board nobody is
watching it sits there indefinitely.
stalled_after on a column is the deadline for that. A card in the column
reports:
| Field | Meaning |
|---|---|
stalled_at | entered_state_at plus the column's stalled_after. Null when the column declares no threshold, and null on a closed card. |
stalled | Whether stalled_at has passed. |
{ "name": "save_text", "stalled_after": 900, "on_enter": { "...": "..." } }
A card that has been in save_text for more than fifteen minutes then comes
back with "stalled": true, and stays that way until it moves — it describes
the card, not a one-off notification, so a poll that runs every minute cannot
miss it. Filter a board read client-side on the flag to get the cards that need
a person:
- CLI
- SDK
- curl
naturali list-tasks --project-id proj_V1StGXR8Z5jdHi6B --board-id brd_V1StGXR8Z5jdHi6B
const { data } = await client.listTasks({
project_id: 'proj_V1StGXR8Z5jdHi6B',
board_id: 'brd_V1StGXR8Z5jdHi6B',
});
const parked = data.filter((task) => task.stalled);
curl -s "https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/tasks?board_id=brd_V1StGXR8Z5jdHi6B" \
-H "Authorization: Bearer $NATURALI_API_KEY" \
| jq '[.data[] | select(.stalled)]'
Being stalled never moves a card and never fails its dispatch. It makes the
parking visible; deciding what to do about it stays yours. Leave stalled_after
off a column where sitting is normal — a kind: human column waiting on someone
with no deadline — so the flag keeps meaning something when it does appear.
Deleting a board
Refused while the board still has open cards (409 board_has_open_tasks) —
close or delete them first. Deleting a board whose cards are all closed removes
those cards and their history with it.
Not in this version
Guard expressions on moves and approval-gated moves (requires_approval) are
both supported by the engine underneath but are not part of this contract yet;
sending either is a 400, not a silent drop. Outbound events for board activity
are a separate module — poll
GET /v1/projects/{project_id}/tasks for now,
which is also how a card's stalled flag reaches you.
Examples
- CLI
- SDK
- curl
naturali create-board \
--project-id proj_V1StGXR8Z5jdHi6B \
--name "Content pipeline" \
--states '[{"name":"draft","initial":true},{"name":"review","kind":"human"},{"name":"published","terminal":true}]' \
--transitions '[{"name":"submit","from":["draft"],"to":"review"},{"name":"revise","from":["review"],"to":"draft"},{"name":"publish","from":["review"],"to":"published"}]'
const { data: board } = await naturali.boards.createBoard({
path: { project_id: 'proj_V1StGXR8Z5jdHi6B' },
body: {
name: 'Content pipeline',
states: [
{
name: 'draft',
initial: true,
on_enter: {
dispatch: {
kind: 'agent',
agent_id: 'agt_writer',
input_mapping: { theme: { var: 'task.payload.theme' } },
},
on_complete: [{ when: true, transition: 'submit' }],
},
},
{ name: 'review', kind: 'human' },
{ name: 'published', terminal: true },
],
transitions: [
{ name: 'submit', from: ['draft'], to: 'review' },
{ name: 'revise', from: ['review'], to: 'draft' },
{ name: 'publish', from: ['review'], to: 'published' },
],
},
});
curl -X POST https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/boards \
-H "Authorization: Bearer $NATURALI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Content pipeline",
"states": [
{ "name": "draft", "initial": true },
{ "name": "review", "kind": "human" },
{ "name": "published", "terminal": true }
],
"transitions": [
{ "name": "submit", "from": ["draft"], "to": "review" },
{ "name": "revise", "from": ["review"], "to": "draft" },
{ "name": "publish", "from": ["review"], "to": "published" }
]
}'