Tasks API
Every route on this page accepts an agent token, except one: the bulk archive,
POST /api/projects/{projectId}/archive, is a person’s. Content is shared; taking a handful of
cards — or a whole column — off the board is a decision about the board.
POST /api/projects/{projectId}/tasks
Section titled “POST /api/projects/{projectId}/tasks”Creates a task, gives it the next number in the project and a rank, and optionally writes its first values.
{ "title": "Write the offline queue tests", "description": "Cover the retry path.", "values": { "<statusPropertyId>": "<optionId>" }, "atTop": true}| Field | Rules |
|---|---|
title |
required, ≤ 400 characters |
description |
optional, ≤ 20000 characters |
values |
optional, { propertyId: value } — see value shapes |
afterId |
optional — place it straight after this task |
atTop |
optional — only read when afterId is absent |
Neither placement field means the end of the board.
A create gets the type’s defaults. When the project has a Type, the server reads the type from
values and writes what that type starts with — each property’s config.defaults — for every
property the request did not send and that the type shows. A value sent explicitly, null
included, always wins. See defaults.
{ "task": { "id": "…", "number": 14, "key": "USH-14", "title": "…", "description": "", "position": "Vk", "projectId": "…", "createdBy": "…", "createdAt": "…", "updatedAt": "…", "archivedAt": null, "values": { "<severityPropertyId>": "<minorOptionId>" } } }values is what the task holds once the create is done: what you sent, the type’s defaults, and
nothing a rule hides. It is the same as a read of the board would say, so you need not read it back.
GET /api/tasks/{taskId}
Section titled “GET /api/tasks/{taskId}”The task in full — everything the panel shows.
{ "task": { "id": "…", "number": 14, "key": "USH-14", "title": "…", "description": "…", "position": "Vk", "createdAt": "…", "updatedAt": "…", "archivedAt": null, // the moment it was archived, or null "values": { "<propertyId>": "<value>" }, "checklistTotal": 4, "checklistDone": 2, "commentCount": 3, "blockedBy": ["USH-12"], // the keys of the blockers that are NOT over "parts": { "done": 3, "total": 5 }, // its children, or null when it has none // done = the project's Done when; an archived child // counts as done; a deleted child is not counted
"links": { // both ends of the chain, over ones included "blockedBy": [{ "id": "…", "key": "USH-12", "title": "…", "over": false }], "blocks": [{ "id": "…", "key": "USH-30", "title": "…", "over": true }] }, "parent": { "id": "…", "key": "USH-9", "title": "…", "over": false }, // or null "children": [], // its parts, in board order, the same rows
"checklist": [ { "id": "…", "text": "Cover the retry path", "done": true, "position": "V" } ],
"comments": [ // oldest first { "id": "…", "body": "tests are green", "createdAt": "…", "author": { "id": "…", "name": "Builder", "color": "#3fb0c8", "emoji": null } } ],
"activity": [ // NEWEST first, at most 60 { "id": "…", "kind": "value", "createdAt": "…", "data": { "property": "Status", "value": "In Progress" }, "actor": { "id": "…", "name": "Ada", "color": "#6d5bd0", "emoji": null } } ],
"run": null, // or the open run, with `steps` and the last 40 `log` lines
"pastRuns": [ // the runs that are over, NEWEST START first, at most 20 { "id": "…", "goal": "Write the queue tests", "step": "…", "status": "done", "startedAt": "…", "endedAt": "…", "agent": { "id": "…", "name": "Builder", "color": "#3fb0c8", "emoji": null } } ] }}A row of pastRuns is a run without its plan and its log, and without the three fields those would
have to be read for: stepsTotal, stepsDone and lastLog are on an open run and not on a
history row. It says who ran this, when, and how it ended. Ask
GET /api/runs/{runId} for one of those in full; it answers
for a closed run exactly as it does for an open one, counts and plan and log and all.
activity.kind is one of created, title, description, value, checklist, comment, run,
archive, link, deleted, import, attachment. The feed also writes reset and rules, but
those are about the project and never reach a task. A comment entry carries data.commentId, and
data.action: "edited" when the words of a comment changed rather than a new one arrived, or
data.action: "deleted" with data.authorId and data.byProject when somebody took it down. An archive entry carries data.action, which is archived
or restored, and so does a deleted one — see
the activity feed. An import entry says the task came
in from Trello and names the card it came from in data.sourceId; the webhook behind it rings once
for the whole import, off the line about the project. An attachment entry carries data.action,
added or removed, with data.attachmentId and data.name.
author and actor are null when the user row has been deleted.
This route answers for an archived task exactly as it does for a live one, which is what makes a link to one still open it.
PATCH /api/tasks/{taskId}
Section titled “PATCH /api/tasks/{taskId}”{ "title": "…", "description": "…", "baseTitle": "…", "baseDescription": "…" }All optional. title ≤ 400 and description ≤ 20000 characters. "" clears the description.
200 {"ok": true}.
baseTitle and baseDescription are the words you started from. With one, the field is written
only if it still holds those words — the compare and the write are one statement. If somebody changed
it in between, nothing is written and the answer is:
{ "error": "This changed while you typed.", "field": "description", "current": "…" }current is the text saved now. Send again with it as the base to keep your words, or leave it.
Words that already match what you send are not a clash, and a property value or a checklist tick never
causes one. Without a base the last write wins, as it always did. The panel always sends the base.
DELETE /api/tasks/{taskId}
Section titled “DELETE /api/tasks/{taskId}”Deletes the task. It leaves every board, list, search and count at once, and every route about it
answers 404 from that moment — the task, its values, its checklist, its comments and its runs.
That is what delete means to whoever holds the id.
What it does not mean is that the work is gone. The task can come back whole for thirty days, and the answer says when that stops being true.
{ "ok": true, "goesAt": "2026-10-21T09:13:44.000Z" }After goesAt a sweep takes the row away for good, with its values, its checklist, its comments,
its runs and its history. Thirty days is the window for every project; it is not a setting.
It writes a deleted entry to the activity log and rings the stream with board.
POST /api/tasks/{taskId}/restore
Section titled “POST /api/tasks/{taskId}/restore”Undoes a delete. The body is empty. 200 {"ok": true}.
This is the one route that may still see a deleted task; every other one answers 404 for it.
The task comes back with the key it had, the rank it had, and its values, checklist, comments, runs
and history. One deleted while it was archived comes back archived — a put back undoes the delete
and nothing else.
It is a member call, an agent’s as much as a person’s, exactly as the delete is.
It is idempotent, like the archive pair: restoring a task that is not deleted answers
200 {"ok": true}, writes no activity line and rings nothing. A task past its thirty days is gone,
so it answers 404.
GET /api/projects/{projectId}/deleted
Section titled “GET /api/projects/{projectId}/deleted”What is in the drawer of one project: the deleted tasks that can still come back, newest first.
{ "deleted": [ { "id": "…", "number": 14, "key": "USH-14", "title": "Write the queue tests", "position": "Vk", "deletedAt": "2026-09-21T09:13:44.000Z", "goesAt": "2026-10-21T09:13:44.000Z" } ], "windowDays": 30}A row is carried light, as an archived one is: no values, no counts and no description. Nothing draws a deleted task — the row says what it was and how long is left, and the way back is the only thing you can do with it.
Any member may read it, a person or an agent. It is a read of its own and not part of the board answer, because a deleted task must reach no board.
POST /api/tasks/{taskId}/archive
Section titled “POST /api/tasks/{taskId}/archive”Archives the task. It leaves every board and every list, and keeps its values, its checklist, its
comments, its history and its link. The body is empty. 200 {"ok": true}.
DELETE /api/tasks/{taskId}/archive
Section titled “DELETE /api/tasks/{taskId}/archive”Puts an archived task back. It returns to the rank it never lost. 200 {"ok": true}.
Both are member calls, an agent’s as much as a person’s: an agent may archive the task it finished,
as it may close its own run. Both write an archive entry to the activity log and ring the stream.
Both are idempotent. Each says what the task should be, so a call that changes nothing answers
200 {"ok": true}, writes no activity line and rings nothing: archiving an archived task keeps the
moment it first went, and putting a live task back is a no-op. Retry either as often as you like.
POST /api/tasks/{taskId}/blockers
Section titled “POST /api/tasks/{taskId}/blockers”Says what this task waits on. The body names the blocker by id.
{ "blockerId": "<taskId>" }200 {"ok": true}. Both tasks have to be on the same board: a link across two projects is refused
with 404 That task is not on this board., and so is a deleted one.
DELETE /api/tasks/{taskId}/blockers/{blockerId}
Section titled “DELETE /api/tasks/{taskId}/blockers/{blockerId}”Takes the link away again. 200 {"ok": true}.
Both are member calls, an agent’s as much as a person’s: a link is content, like a value or a
comment, and not the shape of the board. Both write a link entry — data.action is linked or
unlinked, beside data.blockerKey — on the task that gained the blocker, and both ring the
stream.
Both are idempotent. A link that is already there, and one that is already gone, answer
200 {"ok": true}, write no activity line and ring nothing.
A link that would close a circle is refused with one sentence:
{ "error": "USH-12 already waits on USH-71, so this would be a circle." }A task told to wait on itself is refused with 409 A task cannot wait on itself. The check reads
every link of the project inside the transaction that writes the new one, under the project lock,
so two links that each close the circle the other left open cannot both pass.
PUT /api/tasks/{taskId}/parent
Section titled “PUT /api/tasks/{taskId}/parent”Makes this task part of another. The body names the parent by id.
{ "parentId": "<taskId>" }200 {"ok": true, "left": null}. One level deep: a task has one parent at most, so a task that has
one moves to the new parent, and left names the key of the parent it left. The parent it already
has answers 200 and writes nothing. Both tasks have to be on the same board, and a deleted one is
refused with 404, as for a blocker. Each of these is refused with 409 and one sentence:
{ "error": "A task cannot be part of itself." }{ "error": "USH-12 is part of USH-9, so it cannot have parts of its own." }{ "error": "USH-9 has parts of its own, so it cannot be part of another task." }DELETE /api/tasks/{taskId}/parent
Section titled “DELETE /api/tasks/{taskId}/parent”Takes the task out of its parent. 200 {"ok": true}, and a task with no parent writes nothing.
Both are member calls, an agent’s as much as a person’s: splitting work is content. Both write a
link entry on the part — data.action is parented or unparented, beside data.parentKey —
and both ring the stream. A part is never a blocker: it is not in blockedBy and puts no chain on a
card.
POST /api/projects/{projectId}/archive person only
Section titled “POST /api/projects/{projectId}/archive ”Archives several tasks at once. One route, two bodies, because it is one act.
The tasks somebody selected — what Archive in the selection bar sends:
{ "taskIds": ["…", "…", "…"] }One whole column — what Archive everything in this column sends:
{ "propertyId": "<statusPropertyId>", "value": "<optionId>" }The column body names a property and a value rather than a column, because a column is one value of
the property the view groups by and the server has no idea what a column is. "value": null is the
column of the tasks that hold nothing there. A body that carries taskIds names tasks, whatever
else is on it.
{ "archived": 42 }| Field | Rules |
|---|---|
taskIds |
a list of ids, 200 at most. The same id twice counts once. |
propertyId |
read only when there is no taskIds; a property of this project |
value |
as for one task. Absent means the column with no value at all. |
| Status | Message |
|---|---|
400 |
Name the tasks, or the property the columns come from. |
400 |
Name the tasks as a list of ids. — not a list, or something in it that is not a UUID |
400 |
Name at least one task. · That is more than 200 tasks at once. Name fewer. |
400 |
That is not a property id. — see ids |
400 |
That property is not in this project. |
400 |
One of those tasks is not on this board. |
The answer is how many tasks really went. One call can clear a whole column, so this one is a
person’s: an agent gets 403.
POST /api/tasks/{taskId}/move
Section titled “POST /api/tasks/{taskId}/move”One drag in one call: a new rank, and optionally the column value the card landed on.
{ "beforeId": "<taskId>", "values": { "<statusPropertyId>": "<optionId>" } }| Field | Means |
|---|---|
beforeId |
land in front of this task. Wins over afterId. |
afterId |
land after this task. Only read when beforeId is absent. |
values |
the column change, written in the same call |
| neither id | the end of the board |
{ "position": "Vkm", "dropped": [] }The answer is the authoritative new rank. dropped lists the values the column took away, as
the value route below says.
Concurrent moves are serialised behind a per-project lock, so two people dragging at once cannot compute the same rank.
PUT /api/tasks/{taskId}/values/{propertyId}
Section titled “PUT /api/tasks/{taskId}/values/{propertyId}”Writes one value. This is the main agent write.
{ "value": "<optionId>" }{ "value": "<optionId>", "dropped": [{ "taskId": "…", "propertyId": "…", "name": "Severity" }] }A value can hide others. A property with a when rule shows only while a select holds one of
its options. When a write leaves a property not shown for its task, the task’s value of it is
deleted in the same transaction, and dropped names each one; it is [] when nothing went. A
hidden value never stays behind to close a blocker or pass a filter. The task gets one value line
in its activity whose data carries dropped (the names), propertyIds and hidBy (the option
that hid them, or null). A write to a property the task does not show is accepted and dropped at
once, so read dropped.
Read the answer rather than assuming: values are normalised on the way in — a multi-select is
deduplicated, "42" becomes the number 42, and text is truncated at 2000 characters.
To clear a value, send null, "", or omit the key entirely. That stores null, or [] for a
multi-select.
The accepted shape depends on the property’s type — see value shapes.
| Status | Message |
|---|---|
400 |
That is not a task id. · That is not a property id. — see ids |
400 |
That property is not in this project. |
400 |
Priority needs one option. · One of the options does not exist any more. · Due needs a date like 2026-08-21. |
POST /api/projects/{projectId}/tasks/values
Section titled “POST /api/projects/{projectId}/tasks/values”Writes one property on many tasks, in one call. It is what the board’s Set… does with the cards you selected.
{ "taskIds": ["…", "…", "…"], "propertyId": "<priorityPropertyId>", "value": "<optionId>"}{ "set": 3, "value": "<optionId>", "dropped": [{ "taskId": "…", "propertyId": "…", "name": "Severity" }] }dropped lists the values the change hid and took away, as the
value route above says.
value takes exactly the shapes the single-value route takes, and is checked once for all of
them — so the value that reaches one task is the value that reaches every task. One line of
activity is written for each task, the same value line the single route writes, and the board
rings once.
A multi-select is a list each task has of its own, and a plain value replaces the whole list on
every task. To add one label and keep the rest, send "change": "add" with that one option as
value; "remove" takes it off. A task the change leaves as it was is not written, and set
counts only the tasks that changed. This is what Set… does with a multi-select.
It takes an agent token. A value is content, and an agent may already write ten of them with ten calls; this is the same work in one call. There is no project lock, because the lock serialises the writes that compute a rank and this touches none.
| Field | Rules |
|---|---|
taskIds |
required, a list of ids, 200 at most. The same id twice counts once. |
propertyId |
required, a property of this project |
value |
as for one task — null or "" clears it. With change, the one option id |
change |
optional, a multi-select only: "add" puts one option on each task, "remove" takes it off |
| Status | Message |
|---|---|
400 |
Name the tasks as a list of ids. — not a list, or something in it that is not a UUID |
400 |
Name at least one task. · That is more than 200 tasks at once. Name fewer. |
400 |
Name the property to set. · That property is not in this project. |
400 |
One of those tasks is not on this board. |
400 |
One of those tasks is archived. Put it back first. |
400 |
Only a multi-select adds or takes off one option. · A change is "add" or "remove". |
Checklist
Section titled “Checklist”POST /api/tasks/{taskId}/checklist
Section titled “POST /api/tasks/{taskId}/checklist”{ "text": "Cover the retry path" }text required, ≤ 400 characters. 201 { "item": { "id", "taskId", "text", "done", "position" } }.
PATCH /api/checklist/{itemId}
Section titled “PATCH /api/checklist/{itemId}”{ "text": "…", "done": true, "baseText": "…" }All optional. done is coerced, so any truthy JSON works. baseText guards text exactly as
baseDescription guards a description: if the item no longer holds those words, nothing is written —
the tick included — and the answer is 409 { "error": "…", "current": "…" }.
{ "item": { "id": "…", "taskId": "…", "text": "…", "done": true, "position": "V" } }Send neither field and the answer is {"ok": true} instead — a different shape, and no write.
DELETE /api/checklist/{itemId}
Section titled “DELETE /api/checklist/{itemId}”200 {"ok": true}. There is no reordering for checklist items.
Comments
Section titled “Comments”POST /api/tasks/{taskId}/comments
Section titled “POST /api/tasks/{taskId}/comments”{ "body": "tests are green" }The field is literally body. Required, ≤ 8000 characters. An agent comments as itself.
{ "comment": { "id": "…", "taskId": "…", "authorId": "…", "body": "…", "createdAt": "…", "editedAt": null, "author": { "id": "…", "name": "Builder", "color": "#3fb0c8", "emoji": null } } }PATCH /api/comments/{commentId}
Section titled “PATCH /api/comments/{commentId}”{ "body": "tests are green", "baseBody": "tests are gren" }Only the author may edit a comment, person or agent, with a session or a token. Anybody else gets
403 You can only edit your own comments. — the owner and an admin too: they may delete another
person’s comment, but never put words in it.
body follows the create rules: required, ≤ 8000 characters, trimmed. An empty body is refused with
400 A comment cannot be empty. Delete it instead.
baseBody is the words you started from, and works as baseDescription does on a task. With it, the
body is written only if the comment still holds those words. If a newer save came first, nothing is
written and the answer is:
{ "error": "This changed while you typed.", "field": "body", "current": "…" }Without baseBody the last write wins, as for a title.
{ "comment": { "id": "…", "taskId": "…", "authorId": "…", "body": "…", "createdAt": "…", "editedAt": "…" } }An edit that changes the words sets editedAt, writes one comment line with
data.action: "edited" and rings a comment webhook. The comment keeps its createdAt and its
place in the thread. The same words write nothing: no editedAt, no line, no ring. Nothing keeps the
old words.
DELETE /api/comments/{commentId}
Section titled “DELETE /api/comments/{commentId}”Your own comment, or anybody’s if you are a person who is the owner or an admin.
403 You can only delete your own comments. otherwise. A delete writes one comment line with
data.action: "deleted", data.authorId and data.byProject, never the words.
An agent may delete its own comments — this is the one owner check written without the human gate.