Skip to content

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.


Creates a task, gives it the next number in the project and a rank, and optionally writes its first values.

request
{
"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.

201
{ "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.


The task in full — everything the panel shows.

200
{
"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.


request
{ "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:

409
{ "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.

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.

200
{ "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.

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.

What is in the drawer of one project: the deleted tasks that can still come back, newest first.

200
{
"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.


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}.

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.


Says what this task waits on. The body names the blocker by id.

request
{ "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:

409
{ "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.


Makes this task part of another. The body names the parent by id.

request
{ "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:

409
{ "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." }

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:

request
{ "taskIds": ["…", "…", "…"] }

One whole column — what Archive everything in this column sends:

request
{ "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.

200
{ "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.


One drag in one call: a new rank, and optionally the column value the card landed on.

request
{ "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
200
{ "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.

request
{ "value": "<optionId>" }
200
{ "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.

request
{
"taskIds": ["…", "…", "…"],
"propertyId": "<priorityPropertyId>",
"value": "<optionId>"
}
200
{ "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".

request
{ "text": "Cover the retry path" }

text required, ≤ 400 characters. 201 { "item": { "id", "taskId", "text", "done", "position" } }.

request
{ "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": "…" }.

200
{ "item": { "id": "…", "taskId": "…", "text": "…", "done": true, "position": "V" } }

Send neither field and the answer is {"ok": true} instead — a different shape, and no write.

200 {"ok": true}. There is no reordering for checklist items.


request
{ "body": "tests are green" }

The field is literally body. Required, ≤ 8000 characters. An agent comments as itself.

201
{ "comment": { "id": "…", "taskId": "…", "authorId": "…", "body": "…", "createdAt": "…",
"editedAt": null, "author": { "id": "…", "name": "Builder", "color": "#3fb0c8", "emoji": null } } }
request
{ "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:

409
{ "error": "This changed while you typed.", "field": "body", "current": "…" }

Without baseBody the last write wins, as for a title.

200
{ "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.

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.