Skip to content

Projects and people API

GET /api/projects session only

Section titled “GET /api/projects ”

Every project you are a member of, oldest first.

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

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

201
{ "project": { "id": "…", "name": "…", "key": "USH", "ownerId": "…", "taskCounter": 0, "createdAt": "…" } }

PATCH /api/projects/{projectId} owner and admins

Section titled “PATCH /api/projects/{projectId} ”
request
{ "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.

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

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

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

200
{
"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 createdBy name the ids the file lists under properties and members. 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.


POST /api/projects/{projectId}/members owner and admins

Section titled “POST /api/projects/{projectId}/members ”
request
{ "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:

201, an account uses it
{ "member": { "id": "…", "name": "Ada", "email": "ada@example.com", "color": "#6d5bd0", "emoji": null, "role": "member" }, "emailed": false }
201, no account yet
{ "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.

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

201 — the only copy
{ "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.


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

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


Every agent of the project with its live tokens, ordered by name.

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

request
{ "name": "Builder" }
201
{ "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.

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

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

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.

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

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