Collections and cards
Lists, the cards in them, the two identifiers every card carries, and the difference between where a card lives and where it appears.
A collection is a list. A card (an item, in the API) is a row in it. That is the whole shape, and almost everything else in the product is a way of looking at those two.
A card
Structurally, a card carries very little in its own right:
- a title;
- an icon — an emoji, or nothing;
- a cover image (
coverFileId) — an uploaded image file, shown on the card page as a landscape banner above the title (the default) or as a square to its left — a workspace setting,coverLayout— and at the top of the card on a board; or nothing; - a body — a block document;
- a status — one of the workspace's statuses;
- its place in a tree — a card can have a parent card, so sub-tasks and sub-pages are cards too;
- its rank in the manual order;
- a readable key, like
DOC-12; - who created it (
createdBy), frozen at creation.
Everything else — assignee, due date, priority, tags — is a property, declared in the workspace registry and stored on the card as JSON.
Why so little is built in
An assignee and a due date look universal until you use the product for something other than task tracking. A content catalogue has neither; a CRM wants different ones. Columns that only some workspaces fill are a schema pretending to be a domain. So the rule is a card hard-codes what carries structure — what the tree, the ordering or the key depend on — and nothing else. Being common is not a reason for promotion.
This is not theoretical: assignee_id, due_date and start_date were once
real columns on the card, and were removed. They now come back as ordinary
declared properties, through the same compiler, the same cursor and the same
casts.
The icon and the author pass the same test, for different reasons. An icon does not describe a card, it names it — it shows up everywhere the title shows up and nowhere else, so a declared property would have forced every one of those places to resolve a registry key to draw two characters. The author is a column because the event log is partitioned by month and gets pruned: an author you read from a journal disappears with the first retention policy, on the oldest cards first — exactly the ones whose origin you end up wondering about.
The cover follows the icon: it presents the card rather than describing it, and
a board needs to know which image to draw without guessing which file property
plays that role.
What the icon is not
It is not sortable, filterable or groupable. A view that names it is rejected, rather than silently ordering cards by Unicode code point — which is an order nobody can predict or explain.
createdBy is null on cards created by an import, a seed or a background job,
and on every card that predates the column: it was not backfilled, because there
was nothing true to put there. The UI hides the row rather than showing
"unknown".
Two identifiers
Every card has two, and they are not interchangeable:
id is a UUIDv7. It is what the API takes in paths, and it is time-ordered,
which is what makes keyset pagination cheap.
key is the readable one — DOC-12. It is composed by the API from the key
prefix of the card's home collection and its number.
Never compose a key on the client. A view can include cards from other lists, so
the prefix in front of a number is not necessarily the prefix of the list you
are looking at. And numbers are never recycled: delete DOC-12 and the next
card is DOC-13.
Home list, and the lists a card appears in
A card belongs to exactly one home collection — that is where it was created and where its key comes from.
It can also be attached to other collections. Attachment does not move or copy the card; it makes the same card show up in a second list. One card, one set of values, several places it can be found.
Views decide whether they follow those attachments, through
includeSecondaryLists in the view definition. The
default view — what you get for a list with no saved views — does include them.
A real bug this caused
That default used to be re-implemented in each client rather than published by
the API. The web app included attachments; the iOS app did not. A list whose
cards all arrive by attachment therefore looked full on the web and empty on
the phone, with nothing wrong anywhere. The API now publishes it as
CollectionDetail.defaultView. A copied default doesn't break — it diverges.
Reading a whole list in one call
GET /workspaces/{workspaceId}/collections/{collectionId} returns everything a
screen needs at once: the collection, the property registry, the system fields,
the statuses, the saved views, the default view, the members and your role.
The cards are not in it. They come from
POST …/query, because which cards you want depends on
which view you are looking at.
Statuses
Statuses belong to the workspace, not to a list, so a card keeps its status when it moves. Each one has:
- a label — whatever you call it;
- a category —
todo,doingordone; - a position — the workspace's default ordering;
- a colour — one of eight closed palette tokens.
The category is the part that means something. It is what lets a view say "not
finished yet" and stay true in a workspace whose statuses are called something
else entirely. The colour means nothing at all and compiles nowhere — it exists
so two doing statuses can be told apart at a glance, and so changing a
category doesn't repaint the board.
The palette is closed (gray, red, orange, yellow, green, blue,
purple, pink) on purpose. A free-form colour would be unreadable a third of
the time and would force every surface that renders a status to invent a
contrast.
Groups in the sidebar
Collections can be filed into groups, and groups into one level of sub-groups. Two levels, not three.
That bound is what keeps the sidebar a flat loop over a flat list, and a move a one-row update — no materialised paths, no subtree rewrites, no recursive queries.
A group is a label, not a container: deleting one detaches its lists in the same transaction and deletes nothing. It has no page of its own, which is why nothing in the API addresses a group as a location.
A card does carry its filing, though: every entry of ItemDetail.collections
has a groupPath — the groups that file that list, outermost first, [] for a
list at the root. The server resolves it with two left joins rather than letting
clients reassemble a hierarchy from the flat group list, for the same reason it
composes key: the two-level bound is a service rule, and a client that
reconstructs it reconstructs it wrong the day it changes.
Archiving versus deleting
PATCH { "archived": true } takes a card out of every view and out of search,
and is reversible.
DELETE is permanent and takes the card's whole subtree with it.
The interface offers the first. The second exists for cases where you mean it.
Both live in the same ⋯ menu, in the top right of an open card, next to the
one gesture there that adds rather than removes:
duplicating it.