Skip to content

The board API

The main read. Everything a board needs to draw itself, in one request.

Terminal window
curl -s "$USHABTI_URL/api/projects/$PROJECT/board" \
-H "Authorization: Bearer $USHABTI_TOKEN"
200
{
"project": {
"id": "…", "name": "Ushabti roadmap", "key": "USH",
"ownerId": "…",
"role": "owner", // YOUR role — the same board answers differently to a member
"doneWhen": null // the options this project calls done, as { propertyId, optionIds }, or null for archived
},
"invites": [ { "email": "…", "createdAt": "…" } ], // added before they had an account
"members": [ // people AND agents, by name
{ "id": "…", "name": "Ada", "email": "ada@example.com",
"color": "#6d5bd0", "emoji": null, "role": "owner", "kind": "human" },
{ "id": "…", "name": "Builder", "email": null,
"color": "#3fb0c8", "emoji": null, "role": "member", "kind": "agent" }
],
"properties": [ // in the order they are shown
{
"id": "…", "name": "Status", "type": "select",
"position": "V", "config": {},
"options": [ // [] for everything but select, iteration and multi_select
{ "id": "…", "name": "Todo", "color": "#d1913a", "position": "Vf" }
]
}
],
"views": [
// kind is "board", "list" or "roadmap". A list has no columns, so groupById may be
// null — and when it is not, it is remembered rather than read.
// sort is the order the view draws in, or null for the rank every view shares.
// filters is the whole team's. lens is the rules you added to this view, and
// lensSort the order you picked on it, which only you see and which wins over
// sort; an agent always reads none of either.
{ "id": "…", "name": "Board", "kind": "board", "groupById": "…", "position": "V",
"isDefault": true, "filters": { "rules": [] }, "lens": { "rules": [] }, "sort": null,
"lensSort": null }
],
"cardView": { // what every card carries, already read afresh
"rows": { // in a shared place: wordless rows (colour, avatar) first,
// then the order of "properties" — see api/projects
"<propertyId>": { "place": "headerL", "mode": "colour" },
"_title": { "place": "title", "mode": "fixed" }
}
},
"tasks": [ // ONE global order for the project
{
"id": "…", "number": 14, "key": "USH-14",
"title": "Write the offline queue tests", "description": "",
"position": "Vk", "createdAt": "…", "updatedAt": "…",
"archivedAt": null,
"values": { "<propertyId>": "<value>" },
"checklistTotal": 4, "checklistDone": 2, "commentCount": 3,
"blockedBy": ["USH-12"], // the keys of the blockers that are NOT over yet
"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
}
],
"archived": [ // lighter: no values, no counts
{
"id": "…", "number": 9, "key": "USH-9",
"title": "Ship the release image", "description": "",
"position": "Va", "archivedAt": "…"
}
],
"runs": [ // OPEN runs only, at most one per task
{
"id": "…", "taskId": "…", "status": "running",
"goal": "Write the offline queue tests",
"step": "Writing tests/queue.spec.ts",
"control": null,
"startedAt": "…", "updatedAt": "…", "beatAt": "…", "endedAt": null,
"agent": { "id": "…", "name": "Builder", "color": "#3fb0c8", "emoji": null },
"stepsTotal": 4, "stepsDone": 2, "lastLog": "12 cases, 3 failing"
}
]
}

Note it is not wrapped in a board key.

There are no columns in the response. Columns are a client-side construction: group the tasks by the view’s grouping property, and everything holding nothing goes into one sentinel column.

There is also one card order per project, not one per column and not one per view. A column’s order is just the global order, filtered.

A property with no value written has no key at all. Treat a missing key and a null the same.

The shape of each value depends on the property’s type — see Properties and views.

position is a base-62 fractional index — a string like "Vk" that sorts between its neighbours. JavaScript’s plain < on strings is correct. localeCompare and Intl.Collator are not: they fold case, and these ranks depend on "Z" < "a".

The same trap exists on the server, which is why every ordering query asks for COLLATE "C". See Fractional ranks.

4. The filters you get back are not always the filters that were saved

Section titled “4. The filters you get back are not always the filters that were saved”

A rule pointing at a deleted property or option is thrown away on every read. The database may still hold it; you will never see it. So a rule that reaches you is guaranteed to be readable — and you should never write back what you were not given. The same goes for lens, the rules you added to a view yourself, and for sort and lensSort: an order naming a column that is gone reads as null.

An archived task is on no board and in no list, so it is never in tasks — the two lists never overlap. It is in archived because the browser’s search reads every task in the project and a link to one still opens its panel.

archived carries only the id, number, key, title, description, rank and archivedAt. No values, no checklist and no comment counts: nothing draws an archived task, so loading those on every board read would be work nobody sees. Ask GET /api/tasks/{taskId} for the whole of one.

Draw tasks; treat archived as the answer to “does this key still exist?”.

The lease sweep runs on this path. A GET may close any run whose agent has not reported for thirty minutes, and broadcast that it did. That is not a side effect to work around; it is how the lease is enforced without a scheduler.

For the checklist, the comments, the activity and the full run, ask for the task:

What Settings reads when the stream rings: the same answer as the board read, but tasks holds only the three the card view preview draws, and taskCount says how many live tasks there are. Read this when you want the shape of the board — properties, options, views, members — without every value on it. Any member, a person or an agent.

The half of a search the board read cannot answer: the archived tasks whose description holds every word of q. The board carries an archived task without its description, so a key or a title is found in the board you already hold, and this finds the rest. A task whose key and title already match is left out.

200
{ "hits": [ { "task": { "id": "…", "number": 41, "key": "USH-41", "title": "…",
"position": "…", "archivedAt": "…" },
"snippet": "…the line of the description that holds the words…" } ],
"total": 1 }

At most twelve hits, newest archived first; total counts them all. No words is an empty answer, not an error. Any member, a person or an agent.

Every shipped option, newest first, with its note and the tasks that went out with it. An agent writes release notes from it.

200
{ "changelog": [ { "optionId": "…", "propertyId": "…", "propertyName": "Release", "name": "0.20",
"shippedAt": "2026-10-15", "note": "Markdown, or null",
"tasks": [ { "id": "…", "key": "USH-41", "title": "…" } ] } ] }

With releases off it answers an empty list, not 404. Any member, a person or an agent.