Skip to main content

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

FieldTypeDescription
idstringPublic board ID (brd_ prefix).
project_idstringThe owning project.
namestringHuman-readable label.
descriptionstring, nullable
statesobject[]The columns (see below).
transitionsobject[]The moves the board allows.
payload_schemaobject, nullableJSON Schema every card's payload is validated against.
created_atstring (date-time)
updated_atstring (date-time)

Column (states[])

FieldTypeDescription
namestringUnique within the board.
initialbooleanExactly one column must set this — where new cards land.
terminalbooleanEntering a terminal column closes the card.
kindhuman, nullableA parking column: it never dispatches.
stalled_afterinteger, nullableSeconds 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_enterobject, nullableThe column's automation (see below).

Move (transitions[])

FieldTypeDescription
namestringUnique within the board; the name a caller fires.
fromstring[]The columns the move is valid from.
tostringThe 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_mapping is JSON Logic over { task }, so it reads the card's payload (not accepted on delay, which names no resource). payload_writes is JSON Logic over the dispatch's own result (the same context on_complete sees, { 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 the automation principal, through the same single door a person uses. result is 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 an output_schema; set one and every field of it is addressable in a rule. The result is also written to the card's read-only last_result field 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 under nodes.tool. Chain off a tool column with {"var": "task.last_result.nodes.tool.<field>"}. Inside the tool column's own on_complete, result.<field> works as written. last_result also carries input — exactly what that column's own input_mapping resolved — alongside nodes.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, reporting automation_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: poll calls tool_id repeatedly. exit_condition is JSON Logic evaluated after every attempt against { response, attempt } (response is the tool's latest result, attempt a 1-based count); a truthy result stops polling. interval is the wait between attempts (20s, 5m, 2h, …). max_iterations caps the number of attempts (default 10, up to 1000). on_timeout decides what happens if the ceiling is reached with the condition still unmet: fail fails the dispatch (routed via on_failure, same as any failed dispatch); continue (the default) completes the dispatch anyway, so on_complete can branch on the unmet condition.
  • kind: delay just waits duration (5m, 2h, …) before completing — no tool call, no input_mapping. Route it onward with an ordinary on_complete rule, 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 sleeping orchestration 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 a human column does.
  • result for a poll column is the tool's own latest response — the same shape exit_condition's response sees — so on_complete and payload_writes read it as result.<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 under on_timeout: continue still 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:

FieldMeaning
stalled_atentered_state_at plus the column's stalled_after. Null when the column declares no threshold, and null on a closed card.
stalledWhether 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:

naturali list-tasks --project-id proj_V1StGXR8Z5jdHi6B --board-id brd_V1StGXR8Z5jdHi6B

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

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