Projects and people API
GET /api/projects session only
Section titled “GET /api/projects ”Every project you are a member of, oldest first.
{ "projects": [ { "id": "…", "name": "Ushabti roadmap", "key": "USH", "ownerId": "…", "role": "owner", "createdAt": "2026-08-01T09:00:00.000Z", "taskCount": 40, "memberCount": 3, "waiting": 2 } ]}waiting is how many live tasks of that project have an open run that waits for a person, as the
top bar counts them. It is worked out for every project in the one query.
An agent token cannot enumerate projects — it knows its one board from
GET /api/agent/me, which carries the same waiting number.
POST /api/projects session only
Section titled “POST /api/projects ”Creates a project with the seven default properties and the two default views, with you as owner.
{ "name": "Ushabti roadmap", "key": "USH" }| Field | Rules |
|---|---|
name |
required, ≤ 80 characters |
key |
optional — derived from the name if you leave it out |
The key is uppercased, stripped to A–Z0–9 and cut to six characters. Something that survives none
of that becomes TSK. Keys are not unique across projects.
{ "project": { "id": "…", "name": "…", "key": "USH", "ownerId": "…", "taskCounter": 0, "createdAt": "…" } }PATCH /api/projects/{projectId} owner and admins
Section titled “PATCH /api/projects/{projectId} ”{ "name": "Ushabti", "key": "USH", "doneWhen": { "propertyId": "<propertyId>", "optionIds": ["<optionId>", "<optionId>"] }, "typeBy": "<propertyId>" }All optional. key is length-checked before the non-alphanumerics are stripped, so
a-b-c-d is rejected as too long even though four characters would survive it.
doneWhen is what this project calls done, and it is what makes a blocker stop blocking. It names
any number of options of one select property, such as Done and Won’t do, and a task in any of
them is over; null means archived, which is the answer until somebody says otherwise. A single
optionId is still accepted, and reads as a list of one. An option of another property is a
400. It is read afresh on the way out, so an option that has since been deleted drops out of the
list, and the setting reads as null only when none is left or the property itself is gone. There is no Done
status in Ushabti — Status is a property the owner may rename tomorrow — which is why the project
has to say which option it means.
typeBy names the select whose options are this project’s types, as Settings → Types shows
them. null or "" clears it; a property that is not a single select or sprint property of this project
is a 400. It is read afresh, so the board answer and the export carry typeBy: null once that
select is deleted. Nothing but the Types page reads it yet.
{ "ok": true }PATCH /api/projects/{projectId}/card-view people only
Section titled “PATCH /api/projects/{projectId}/card-view ”Arranges the card every board of this project draws. Any member may; an agent may not, because the card view is the shape of the board rather than anything on it.
{ "cardView": { "rows": { "<propertyId>": { "place": "edge", "mode": "colour" }, "_key": { "place": "headerL", "mode": "text" }, "_title": { "place": "title", "mode": "fixed" }, "_desc": { "place": "body", "mode": "two" } } } }The whole card view goes in one request; there is no per-row route. "cardView": null throws the
saved one away and goes back to the default.
| Field | Rules |
|---|---|
rows[id].place |
edge, headerL, headerR, body, footerL, footerR, off |
rows[id].mode |
colour, both, avatar, text, boxed, one, two, bar, fixed — the row’s kind decides which it may use |
Eight ids are not properties: _key, _title, _desc, _checklist, _parts, _comments,
_created and _updated. The last two are the days the task was made and last changed, in the
project’s time zone, and are off until somebody turns them on.
A card view has no order. Where two rows share a place they follow the order of the properties
(set with afterId on PATCH /api/properties/{id}), after _key,
_title and _desc and before _checklist, _parts, _comments, _created and _updated. A row whose mode is colour or
avatar carries no words and is drawn first in its place. An order in the body is ignored and
never stored.
The body is read the way the board reads it, so what comes back is what was stored, not what was
sent: a row naming a property that is gone is dropped, _title stays in title/fixed, only one
row keeps edge (and only a select, multi-select or person may hold it), and a mode a row cannot
read becomes the first one it can.
{ "cardView": { "rows": { "…": { "place": "headerL", "mode": "colour" } } } }403 Only a person can do this. for a token.
GET /api/projects/{projectId}/export owner and admins
Section titled “GET /api/projects/{projectId}/export ”The whole project as one JSON file to save. It answers Content-Disposition: attachment; filename="ushabti-{KEY}-{YYYY-MM-DD}.json", the day being the project’s time zone, and
Cache-Control: no-store. The read costs the same number of queries whatever the number of tasks.
{ "format": "ushabti-export", "version": 1, "exportedAt": "2026-09-29T10:00:00.000Z", "project": { "name": "Ushabti", "key": "USH", "timeZone": "Europe/Berlin", "cardView": { "rows": {} }, "doneWhen": null, "progressBy": null, "typeBy": null }, "properties": [{ "id": "…", "name": "Status", "type": "select", "position": "a0", "config": {}, "options": [{ "id": "…", "name": "Todo", "color": "#8a8f98", "position": "a0" }] }], "views": [{ "id": "…", "name": "Board", "kind": "board", "groupById": "…", "position": "a0", "isDefault": true, "filters": { "rules": [] }, "sort": null }], "members": [{ "id": "…", "name": "Ada", "email": "ada@example.com", "kind": "human", "role": "owner", "color": "#6d5bd0" }], "invites": [{ "email": "new@example.com" }], "tasks": [{ "id": "…", "key": "USH-14", "number": 14, "title": "…", "description": "…", "rank": "a0", "createdBy": "…", "createdAt": "…", "updatedAt": "…", "archivedAt": null, "values": { "<propertyId>": "<optionId>" }, "checklist": [{ "text": "…", "done": false }], "comments": [{ "authorId": "…", "body": "…", "createdAt": "…", "editedAt": null }], "blockedBy": ["USH-9"] }]}- A value, an author and a
createdByname the ids the file lists underpropertiesandmembers. An id that is not listed is a person who has left the project. - Every live and archived task is in it. A deleted task is not.
- A view carries the team’s filters and sort. Nobody’s own filter is in it.
- Not included: the activity feed and the runs (the feed has no limit; read it with
/activity), passwords, sessions, tokens, reset links and webhooks.
403 for a member, and for a token: the file holds every member’s email.
DELETE /api/projects/{projectId} owner only
Section titled “DELETE /api/projects/{projectId} ”Deletes the project and, by cascade, everything in it. 200 {"ok": true}. The API asks for no
confirmation — the interface does, by making you type the key.
Members
Section titled “Members”POST /api/projects/{projectId}/members owner and admins
Section titled “POST /api/projects/{projectId}/members ”{ "email": "ada@example.com" }If an account uses that email, it becomes a member at once and the answer carries member. If none
does, the email is remembered as an invite and the answer carries invite instead:
{ "member": { "id": "…", "name": "Ada", "email": "ada@example.com", "color": "#6d5bd0", "emoji": null, "role": "member" }, "emailed": false }{ "invite": { "email": "ada@example.com", "createdAt": "…" }, "emailed": true }emailed says whether the server emailed the invite: who invited them, to which project, and the
sign-up link. It is true only when the mail server took it. It is false when mail is off (no
SMTP_URL and MAIL_FROM), when the send failed, when the caller has spent
its ten sends in ten minutes, and always for an account that joined at once. The invite is written before the send, so false never means it is missing: send the
sign-up link (<origin>/register) yourself. The server waits for the send at most 10 seconds.
Whoever registers with that email joins the project as they sign up — through a closed board too.
The role is always member and cannot be chosen. Invites are listed on the board as invites.
| Status | Message |
|---|---|
409 |
That email is already invited. |
409 |
That person is already a member. |
There is no GET here — the member list comes back with the board.
DELETE /api/projects/{projectId}/invites/{email} owner and admins
Section titled “DELETE /api/projects/{projectId}/invites/{email} ”Withdraws an invite. The email is URL-encoded in the path. 404 when no invite uses it.
PATCH /api/projects/{projectId}/members/{userId} owner and admins
Section titled “PATCH /api/projects/{projectId}/members/{userId} ”Changes a person’s role: owner, admin or member.
{ "role": "admin" }An admin may make a member an admin. Only the owner may change an admin’s role, and only the owner
may pick owner, which hands the project over: in one transaction the person becomes the owner,
ownerId on the project moves to them, and the old owner becomes an admin. There is always exactly
one owner. Picking the role a person already has changes nothing.
{ "ok": true }| Status | Message |
|---|---|
400 |
A role is owner, admin or member. |
400 |
An agent is always a member. |
400 |
You cannot change your own role. |
403 |
Only a person can do this. — an agent token, whoever issued it |
403 |
Only the owner or an admin can change a role. |
403 |
Only the owner can hand the project over. |
403 |
Only the owner can change an admin's role. |
403 |
The owner's role changes only when they hand the project over. |
404 |
That person is not in this project. |
DELETE /api/projects/{projectId}/members/{userId}
Section titled “DELETE /api/projects/{projectId}/members/{userId}”Removing somebody else takes a role above theirs: an admin removes a member, and only the owner removes an admin. Removing yourself is anybody’s but the owner’s, and is how you leave.
| Status | Message |
|---|---|
403 |
An agent cannot leave a project. Ask the owner or an admin to remove it. |
403 |
Only the owner or an admin can remove a member. |
403 |
Only the owner can remove an admin. |
403 |
Nobody can remove the owner. |
400 |
The owner cannot leave the project. Delete the project instead. |
404 |
That person is not in this project. |
The membership row goes; the users row stays, so the person’s tasks, comments and activity remain.
POST /api/projects/{projectId}/members/{userId}/reset owner and admins
Section titled “POST /api/projects/{projectId}/members/{userId}/reset ”Makes that member a way back into their account, for a forgotten password. Send no body.
{ "link": "https://your-board.example.com/reset/ushr_…", "emailed": true }emailed says whether the same link also went to the member’s email, with a word that it works
once, for 24 hours. It is false when mail is off (no SMTP_URL and MAIL_FROM), when the
send failed, or when the caller has spent its ten sends in ten minutes; the link is written before the send and works either way, so send it yourself. The
server waits for the send at most 10 seconds.
The link is readable in this answer, and in that one email, and never again: the table keeps a SHA-256 digest of the token,
its account, who made it, when it expires and when it was used. It works once, for 24 hours,
and making a new one for the same person puts the older ones out of use. The person spends it at
POST /api/auth/reset, which ends every session that account
had.
The address in the link is the one the call arrived at (x-forwarded-host, then host), so behind a
proxy it is the address the team uses. The emailed link is the same link.
| Status | Message |
|---|---|
400 |
Change your own password on your account page. — your own row has /account |
400 |
An agent has no password. Issue it a token instead. |
403 |
Only the owner or an admin can make a reset link for a member. |
403 |
Only the owner can make a reset link for an admin. |
403 |
Nobody can make a reset link for the owner. |
404 |
That person is not in this project. |
A line is written to the project’s activity: kind reset, no task, and
data: { "forUserId": "…", "forName": "Ada" }. The link row itself is swept away as soon as the
link is spent or replaced, so the feed is the only lasting record that somebody handed out access.
Every member of the project reads it at GET /api/projects/{projectId}/activity, exactly as they
read any other line. Neither the link nor the password is ever in it.
Activity
Section titled “Activity”GET /api/projects/{projectId}/activity
Section titled “GET /api/projects/{projectId}/activity”What happened in the project after a moment, oldest first. The stream rings; this is what it rang about, and it is the record a watcher reads to catch up after a reconnect.
| Query | Means |
|---|---|
after |
an ISO moment. Without it the answer is empty, and now is where a new reader starts. |
limit |
1 to 500, 200 by default |
afterId |
the id of the last line read. A full page goes on from its last line: its createdAt as after, its id as afterId. One write gives all its lines one moment, so after alone would start a long burst again at its first line. Lines of one moment come in id order. A value that is not an id is refused with 400. |
{ "entries": [ { "id": "…", "kind": "value", "taskId": "…", "taskKey": "USH-14", "data": { "property": "Status", "value": "In Progress" }, "createdAt": "…", "actor": { "id": "…", "name": "Builder", "kind": "agent" } } ], "now": "…"}Any member may read it, a person or an agent: it says nothing the task panels do not already say.
taskId and taskKey are null on a line that is about the project rather than one task.
The deleted line
Section titled “The deleted line”kind is deleted both when a task is deleted and when it is put back, so read the action:
{ "action": "deleted", "key": "USH-14", "title": "Write the queue tests", "goesAt": "2026-10-21T09:13:44.000Z" }action is deleted or restored, and goesAt — the moment the task goes for good — is null on
a put back. A receiver that treated every deleted line as a task going away now hears a task
coming back as well.
taskId is null on both. activity.task_id cascades, so a line naming the task would be swept
away with the task it is the record of; the key is what points at it instead, and the key outlives
the row. See DELETE /api/tasks/{taskId}.
Agents
Section titled “Agents”GET /api/projects/{projectId}/agents
Section titled “GET /api/projects/{projectId}/agents”Every agent of the project with its live tokens, ordered by name.
{ "agents": [{ "id": "…", "name": "Builder", "color": "#3fb0c8", "emoji": null, "createdAt": "…", "tokens": [{ "id": "…", "name": "default", "prefix": "ush_xK3nQ8vB", "createdAt": "…", "lastUsedAt": "2026-08-25T10:02:11.000Z" }] }]}Only the first twelve characters of a token are ever readable, and revoked tokens are not listed. Any member may read this, including an agent — there is no secret in it.
POST /api/projects/{projectId}/agents owner and admins
Section titled “POST /api/projects/{projectId}/agents ”Creates the machine member: a users row with kind = "agent", no email and no password, joined to
the project.
{ "name": "Builder" }{ "agent": { "id": "…", "name": "Builder", "color": "#3fb0c8", "emoji": null, "createdAt": "…", "tokens": [] } }Creating an agent does not create a token. That is the next call, deliberately.
PATCH /api/projects/{projectId}/agents/{agentId} owner and admins
Section titled “PATCH /api/projects/{projectId}/agents/{agentId} ”Changes the colour and the emoji of an agent. Send either one, or both.
{ "color": "#2f9e7a", "emoji": "🦊" }color is one of the eleven avatar colours, the same list a person picks from on /account.
emoji is exactly one emoji, checked as a person’s is, and null clears it so the agent wears ◆
again. An agent with an emoji keeps a small ◆ badge on its face. An agent token cannot call this,
not even for itself. The change is broadcast, so every open board draws it.
{ "agent": { "id": "…", "name": "Builder", "color": "#2f9e7a", "emoji": "🦊" } }| Status | Message |
|---|---|
400 |
That member is a person, not an agent. |
400 |
Pick one of the colours on offer. |
400 |
A face is one emoji, or null for the ◆. |
403 |
Only the owner or an admin can change an agent. |
404 |
That agent is not in this project. |
DELETE /api/projects/{projectId}/agents/{agentId} owner and admins
Section titled “DELETE /api/projects/{projectId}/agents/{agentId} ”Removes the agent from the project, and its tokens with it. Its runs, with their plans and logs, stay on the tasks, and its comments and activity keep its name.
| Status | Message |
|---|---|
400 |
That member is a person, not an agent. |
409 |
That agent still holds a task. Take the task over first. |
Tokens
Section titled “Tokens”POST /api/projects/{projectId}/agents/{agentId}/tokens owner and admins
Section titled “POST /api/projects/{projectId}/agents/{agentId}/tokens ”Mints a bearer token scoped to this project. The only time the plain text is returned.
{ "name": "default" }name is optional and defaults to "Token"; it is ≤ 60 characters. There is no limit on how many
tokens one agent may hold.
{ "token": { "id": "…", "name": "default", "prefix": "ush_xK3nQ8vB", "createdAt": "…", "lastUsedAt": null }, "secret": "ush_xK3nQ8vB2mLp9wZfR4tYsH7cJ0dN6gA1eU5iO…"}DELETE /api/agent-tokens/{tokenId} owner and admins
Section titled “DELETE /api/agent-tokens/{tokenId} ”Revokes one token — the row stays so the list can still show what it was; only revoked_at is
stamped. The agent stops working within one request.
The path is deliberately not nested under a project: the token itself names the project it belongs to.