The board API
GET /api/projects/{projectId}/board
Section titled “GET /api/projects/{projectId}/board”The main read. Everything a board needs to draw itself, in one request.
curl -s "$USHABTI_URL/api/projects/$PROJECT/board" \ -H "Authorization: Bearer $USHABTI_TOKEN"{ "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.
Six things a caller has to know
Section titled “Six things a caller has to know”1. tasks is flat, and the order is global
Section titled “1. tasks is flat, and the order is global”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.
2. values is sparse
Section titled “2. values is sparse”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.
3. Sort ranks by bytes, never by locale
Section titled “3. Sort ranks by bytes, never by locale”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.
5. tasks is live, and archived is lighter
Section titled “5. tasks is live, and archived is lighter”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?”.
6. Reading the board can close a run
Section titled “6. Reading the board can close a run”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.
Reading one task
Section titled “Reading one task”For the checklist, the comments, the activity and the full run, ask for the task:
GET /api/projects/{projectId}/settings
Section titled “GET /api/projects/{projectId}/settings”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.
GET /api/projects/{projectId}/search?q=
Section titled “GET /api/projects/{projectId}/search?q=”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.
{ "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.
GET /api/projects/{projectId}/changelog
Section titled “GET /api/projects/{projectId}/changelog”Every shipped option, newest first, with its note and the tasks that went out with it. An agent writes release notes from it.
{ "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.