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
| Field | Type | Description |
|---|---|---|
id | string | Public task ID (task_ prefix). |
project_id | string | The owning project. |
board_id | string | The board the card lives on. |
title | string | Human-readable label. |
state | string | The column the card is in. Read-only. |
status | open | closed | closed once the card enters a terminal column. |
payload | object | The card's working data, entirely caller-owned. |
last_result | object, nullable | Read-only. The most recent column's dispatch result — its own field, never inside payload. Null until the card's first dispatch settles. |
assignee | string, nullable | Informational label; it does not restrict who may move the card. |
automation_status | running | completed | failed | unrouted, nullable | The current column's dispatch. |
active_dispatch | object, nullable | The 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_at | string (date-time), nullable | When 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. |
stalled | boolean | Whether stalled_at has passed. Stays true until the card moves; never moves the card itself. |
entered_state_at | string (date-time), nullable | When the card entered its current column. |
created_at | string (date-time) | |
updated_at | string (date-time) |
Transition record
| Field | Type | Description |
|---|---|---|
id | string | Public ID (task_tr_ prefix). |
task_id | string | The card the move belongs to. |
from_state | string, nullable | The column left behind; null on the card's initial placement. |
to_state | string | The column entered. |
transition | string, nullable | The move fired; null on the initial placement. |
principal_kind | user | api_key | automation | What 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_id | string, nullable | The generation that caused the move, when one did. |
orchestration_run_id | string, nullable | The run that caused the move — a tool column's dispatch. |
note | string, nullable | Optional reason supplied by the caller. |
created_at | string (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
- CLI
- SDK
- curl
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."
const { data: task } = await naturali.tasks.createTask({
path: { project_id: 'proj_V1StGXR8Z5jdHi6B' },
body: {
board_id: 'brd_V1StGXR8Z5jdHi6B',
title: 'Reel: leftover coffee grounds',
payload: { theme: 'leftover coffee grounds' },
},
});
// One kanban column.
const { data: column } = await naturali.tasks.listTasks({
path: { project_id: 'proj_V1StGXR8Z5jdHi6B' },
query: { board_id: 'brd_V1StGXR8Z5jdHi6B', state: 'review' },
});
const { data: moved } = await naturali.tasks.transitionTask({
path: { project_id: 'proj_V1StGXR8Z5jdHi6B', task_id: task.id },
body: { transition: 'publish', note: 'Copy reads well, ship it.' },
});
curl -X POST https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/tasks \
-H "Authorization: Bearer $NATURALI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"board_id": "brd_V1StGXR8Z5jdHi6B",
"title": "Reel: leftover coffee grounds",
"payload": { "theme": "leftover coffee grounds" }
}'
curl -X POST "https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/tasks/task_V1StGXR8Z5jdHi6B:transition" \
-H "Authorization: Bearer $NATURALI_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "transition": "publish", "note": "Copy reads well, ship it." }'