Skip to main content

Tasks

The cards on a Board — durable work items that move between columns over time.

Overview

A card lives on one board, moves between its columns (backward included), and closes when it enters a terminal column. Creating one places it in the board's initial column and fires that column's automation, so a board whose first column dispatches an agent starts working on the create call and keeps going, unattended, until a column needs a person.

Cards are not nested under a board in the API: the kanban view is listTasks?board_id=… grouped by state, one call per board, and a cross-board view ("every card assigned to me") stays one call too.

See the OpenAPI spec for the full endpoint and schema reference, or browse it rendered under API Reference → Tasks.

Data Model

Task

FieldTypeDescription
idstringPublic task ID (task_ prefix).
project_idstringThe owning project.
board_idstringThe board the card lives on.
titlestringHuman-readable label.
statestringThe column the card is in. Read-only.
statusopen | closedclosed once the card enters a terminal column.
payloadobjectThe card's working data, entirely caller-owned.
last_resultobject, nullableRead-only. The most recent column's dispatch result — its own field, never inside payload. Null until the card's first dispatch settles.
assigneestring, nullableInformational label; it does not restrict who may move the card.
automation_statusrunning | completed | failed | unrouted, nullableThe current column's dispatch.
active_dispatchobject, nullableThe in-flight dispatch. An agent column reports { kind: "agent", id, status } (id is the generation); a tool or poll column reports { kind: "tool" | "poll", tool_id, status }; a delay column reports { kind: "delay", status }.
stalled_atstring (date-time), nullableWhen the current column considers the card parked too long — entered_state_at plus that column's stalled_after. Null when the column declares no threshold, and null on a closed card.
stalledbooleanWhether stalled_at has passed. Stays true until the card moves; never moves the card itself.
entered_state_atstring (date-time), nullableWhen the card entered its current column.
created_atstring (date-time)
updated_atstring (date-time)

Transition record

FieldTypeDescription
idstringPublic ID (task_tr_ prefix).
task_idstringThe card the move belongs to.
from_statestring, nullableThe column left behind; null on the card's initial placement.
to_statestringThe column entered.
transitionstring, nullableThe move fired; null on the initial placement.
principal_kinduser | api_key | automationWhat kind of principal made the move. There is no principal_id: for a human or API move it would identify naturali's own shared service credential, and for an automation move the cause is already the next two fields.
generation_idstring, nullableThe generation that caused the move, when one did.
orchestration_run_idstring, nullableThe run that caused the move — a tool column's dispatch.
notestring, nullableOptional reason supplied by the caller.
created_atstring (date-time)

Key Concepts

One door for every move

state is not writable. PATCH edits title, assignee and payload; the only thing that moves a card is POST /v1/projects/{project_id}/tasks/{task_id}:transition. A person dragging a card, an integration calling the API, and a column's own routing rule all arrive through that one operation — which is what makes every move atomic, checked against the board definition, and appended to the card's history.

Sending state to PATCH is rejected rather than ignored: a silently dropped move is worse than a refused one.

Illegal moves are conflicts, not bad requests

Naming a move that does not exist, one that is not legal from the card's current column, or any move at all on a closed card all answer 409 task_transition_conflict. The same request body succeeds one column earlier, so it is the card's state that conflicts, not the request that is malformed.

payload is shallow-merged

A PATCH to payload merges over what is there: keys the request omits are preserved. The merged result is validated against the board's payload_schema. last_result lives in its own read-only field, not in payload, so a payload write can never discard or forge it.

Because it is a merge, payload: null is rejected with a 400 rather than accepted and ignored — there is no key set it could describe. Send {} to change nothing. (assignee: null is different: a nullable scalar, where null unambiguously means "clear it", and a board's description: null clears the same way.)

Store references to artifacts in the payload — file and document ids — not the artifacts themselves.

last_result is shaped by the kind of column that wrote it. An agent column writes its generation output; a tool column writes the enclosing run's state, which puts the tool's own result one level down under nodes.tool. So a later column chains off a tool column with {"var": "task.last_result.nodes.tool.<field>"}, not last_result.<field>.

History attribution

automation moves carry their cause: the generation or the run that fired them. A move made through this API reports api_key, because every call naturali makes reaches the platform under one service credential — the record cannot yet distinguish a person from an integration. For the same reason there is no principal_id: it would name that shared credential rather than the caller.

Deleting versus closing

A card that reaches a terminal column closes and keeps its audit trail. DELETE discards the card and its history, so it is for cards that should never have existed.

Examples

TASK_ID=$(naturali create-task \
--project-id proj_V1StGXR8Z5jdHi6B \
--board-id brd_V1StGXR8Z5jdHi6B \
--title "Reel: leftover coffee grounds" \
--payload '{"theme":"leftover coffee grounds"}' | jq -r '.id')

naturali transition-task \
--project-id proj_V1StGXR8Z5jdHi6B \
--task-id "$TASK_ID" \
--transition publish \
--note "Copy reads well, ship it."