Properties and views API
Adding is any member’s. Deleting is the owner’s or an admin’s, and a person’s.
Properties
Section titled “Properties”POST /api/projects/{projectId}/properties
Section titled “POST /api/projects/{projectId}/properties”{ "name": "Environment", "type": "select", "options": ["local", "staging", "production"] }| Field | Rules |
|---|---|
name |
required, ≤ 40 characters |
type |
required — select, multi_select, iteration, person, text, number, date, checkbox, link |
options |
only read for select, iteration and multi_select; blanks dropped, then capped at 40. Two names that differ only in letter case or spaces answer 400 Environment already has an option named local. and make nothing |
Option colours cycle through the palette. The property is appended last, and joins every card in the
footer, where its type belongs — except a link, which starts off the card.
{ "property": { "id": "…", "projectId": "…", "name": "Environment", "type": "select", "position": "Vm", "config": {}, "createdAt": "…", "options": [{ "id": "…", "name": "local", "color": "#e0574d", "position": "V" }] } }400 That property type does not exist. for anything outside the nine.
PATCH /api/properties/{propertyId}
Section titled “PATCH /api/properties/{propertyId}”{ "name": "Env", "showOnCard": false, "dated": true, "afterId": "<propertyId>" }All optional and applied independently.
showOnCardwrites the project’s card view, not the property:falseputs the property inoff,trueputs it back where its type belongs. SeePATCH /api/projects/{projectId}/card-viewfor the long form.datedsays whether the options of a select carry a start, a target and a note. It is kept inconfig.dated, and is off until somebody turns it on. Only the owner or an admin writes it, and only as a person: a token gets403, a member gets403, and any type but a select gets400. A token reads it fromconfiglike the rest of the property.defaultssays what a new task of each type starts with:{ "<typeOptionId>": value }, where the key is an option of the project’s Type and the value has the shape of the property’s type. Each type is merged on its own;nullclears one. Only a select, a multi-select, a checkbox, a number or text can carry one — never a person, a date, a link or a sprint. Only the owner or an admin writes it. A value that would not read back, a key that is not a type, or a property that cannot carry one gets400. It is read afresh intoconfig.defaults: a deleted type or value, or a project with no Type, reads as no default. Every create writes it.afterIdmoves it.nullmeans the front, and so does an id that names nothing. It is the presence of the key that triggers the move, not its value.
200 {"ok": true}. A body with nothing recognisable in it is a no-op that still answers 200, and
broadcasts nothing.
DELETE /api/properties/{propertyId} owner and admins
Section titled “DELETE /api/properties/{propertyId} ”Removes the property, its options and every value written for it.
| Status | Message |
|---|---|
400 |
The view "Phases" groups by this property. Point it at another property first. |
400 |
These views group by this property: "Phases", "Board". Point them at another property first. |
Only views whose kind is board are counted. A list remembers a grouping property but never
reads one, so a remembered word does not keep a property alive; the column is cleared if it goes.
GET /api/properties/{propertyId}/count
Section titled “GET /api/properties/{propertyId}/count”{ "values": 42 }How many tasks hold a real value for this property — archived tasks included, because the delete
takes their values too. Empty is what the board calls empty: no row, null, "" or [].
It is what the delete row asks before it names the cost, and it is the only way to get the number:
the board answer used to carry a valueCounts map and no longer does.
Options
Section titled “Options”POST /api/properties/{propertyId}/options
Section titled “POST /api/properties/{propertyId}/options”{ "name": "staging", "color": "#3fb0c8" }name required, ≤ 40. color optional and must look like #3fb0c8; anything else is ignored
silently and the next unused palette colour is used instead.
201 { "option": { "id", "propertyId", "name", "color", "position" } }, or
400 Only select properties have options.
A property holds one option of each name. The check ignores letter case and the spaces around the
name, so blocked next to Blocked answers 409 Status already has an option named Blocked. Two
creates at the same moment make one option: the other one gets the same 409. Read the board again
and use the option that is there.
PATCH /api/options/{optionId}
Section titled “PATCH /api/options/{optionId}”{ "name": "prod", "color": "#2f9e7a", "afterId": null }Here a malformed color is an error — 400 The colour must look like #3fb0c8. afterId behaves
as it does for properties: null or unknown means the front.
A new name that another option of the property already carries answers 409 with the same
sentence as the create. An option may change the letter case of its own name.
Reordering options is what column dragging does on the board.
GET /api/options/{optionId}/count
Section titled “GET /api/options/{optionId}/count”{ "tasks": 14 }How many tasks hold this option — a single-select set to it, or a multi-select list that has it — archived tasks included, because the delete clears it from them too. It is what the option delete row asks before it names the cost. Any member, and an agent, may ask.
POST /api/options/{optionId}/ship
Section titled “POST /api/options/{optionId}/ship”Ships a release, or closes a sprint: the option of a dated select or of an iteration.
{ "rest": "next", "strays": false }The tasks in it that are over are archived. rest says what happens to the others, and is required:
next moves them to the next option that is still open, leave leaves them where they are, clear
takes the value off. With strays: true a release also takes along the tasks that are over and in
no release; a sprint takes none. The option then carries the day it shipped. It is one transaction,
one ring on the stream and one webhook, and every feed line it writes carries the same shipId.
{ "archived": 9, "moved": 2, "rest": "next", "shippedAt": "2026-10-15", "taken": 0 }Any member may ship, and so may an agent: the agent that deployed is the one that knows the release
is out. Taking the day back is an admin’s, through PATCH /api/options/{optionId}.
| Status | Message |
|---|---|
400 |
Say what happens to the rest: "next", "leave" or "clear". |
400 |
Only an option with a target date can ship. · {option} is the last option, so there is nothing to move to. |
404 |
Option not found. |
409 |
{option} already shipped on {day}. |
DELETE /api/options/{optionId} owner and admins
Section titled “DELETE /api/options/{optionId} ”Deletes the option and scrubs it out of every value: a single-select holding it becomes null, and a
multi-select drops it from its list.
Saved filter rules are not touched. They do not need to be — a rule naming a dead option is discarded every time the view is read. See Filters.
POST /api/projects/{projectId}/views
Section titled “POST /api/projects/{projectId}/views”{ "name": "By assignee", "kind": "board", "groupById": "<propertyId>" }kind is board, list or roadmap, and defaults to board — which is what every view was before there
was more than one kind.
A board requires groupById: a property of this project whose type is select, iteration,
person or checkbox. A roadmap requires one too, and only a select or an iteration will do,
because it draws a row per dated option. A list groups nothing and needs none, so it can be created on a project that has no
such property at all. A list may still carry one; it is remembered, unread, for the day the view
becomes a board again.
{ "view": { "id": "…", "name": "By assignee", "kind": "board", "groupById": "…", "position": "Vn", "isDefault": false, "filters": { "rules": [] } } }| Status | Message |
|---|---|
400 |
Choose a property to group by. · That property is not in this project. |
400 |
A view can only group by a select, iteration, person or checkbox property. |
400 |
A view is a board, a list or a roadmap. · A roadmap draws the options of a select property. |
PATCH /api/views/{viewId}
Section titled “PATCH /api/views/{viewId}”{ "name": "Board", "kind": "list", "groupById": "…", "filters": { "rules": [ … ] }, "sort": { "columnId": "<propertyId or _key|_title|_checklist|_comments|_created|_updated>", "direction": "asc" } }sort is the order the view draws in — the rows of a list, and the cards inside every column of a
board; null gives back the rank every view shares, which
is the only order a drag can write. columnId names a property or one of the parts a task has of its
own — the same ids the card view is made of. A sort naming a column that is gone is dropped on the
way in, exactly as a filter rule is.
Send "groupById": null to clear it. A view that would end up a board with no property is
refused — 400 A board needs a property to make its columns from. — because the alternative is a
screen with nothing on it. An unknown kind is refused rather than stored.
Rules are read and cleaned on the way in as well as on the way out, so what you send is not necessarily what is stored.
DELETE /api/views/{viewId} owner and admins
Section titled “DELETE /api/views/{viewId} ”400 The main view cannot be deleted. for the project’s first view.
A person’s own rules
Section titled “A person’s own rules”A view carries two sets of rules: filters, which everybody on the board sees, and lens, which is
the rules the caller added to that view and nobody else’s. A board shows the view’s plus the
caller’s; the caller’s only ever narrow further.
An order works the same way. sort is the view’s, for everybody; lensSort is the order the caller
picked on that view. While the caller has one, it wins over the view’s on their screen only — so a
board held still by a sort is held still for the person who sorted it, and nobody else.
Both read the same way and use the same rule shape.
lens on a view
Section titled “lens on a view”Every view in the board answer carries it:
{ "id": "…", "name": "Board", "filters": { "rules": [ … ] }, "lens": { "rules": [ … ] }, "sort": null, "lensSort": { "columnId": "…", "direction": "desc" }, … }PUT /api/views/{viewId}/lens human only
Section titled “PUT /api/views/{viewId}/lens ”{ "filters": { "rules": [ … ] }, "sort": { "columnId": "…", "direction": "asc" } }Replaces the caller’s whole lens on that view: the rules and the order together. Leave sort out,
or send null, and the caller has no order of their own. Both are read and cleaned on the way in,
exactly as a view’s are. No rules and no order removes the lens. Nothing is broadcast: the change moves one screen, so no
other browser is told about it.
403 Only a person can do this. for an agent.
409 The view already filters Priority. Remove it for everyone first. when a rule names a property
the view’s own filters already asks about. One property carries one rule, whoever asked: two rules
about one property empty the board with two chips that fight each other.
POST /api/views/{viewId}/lens/promote human only
Section titled “POST /api/views/{viewId}/lens/promote ”No body. Appends the caller’s rules to the view’s filters, in that order, puts the caller’s order
on the view’s sort if they have one, and empties the caller’s own — one transaction under the
project lock. A lens with no order leaves the view’s sort where it is. Any member may call it. It broadcasts, so everybody’s
board narrows.
409 The view already filters Priority. Remove it for everyone first. when the caller’s rules name a
property the view already filters. A lens written before the view named that property becomes a clash
without anybody writing it, which is why this door says it again and writes nothing.
Value shapes by type
Section titled “Value shapes by type”The one validator, shared by the values PUT, the task-create values bag and the move values bag.
Clearing comes first and applies to every type. null, an omitted key, or "" stores null —
or [] for a multi-select and a link.
| Type | Send | Stored as | Refused with |
|---|---|---|---|
select |
an option id of this property | the id | Priority needs one option. · One of the options does not exist any more. |
multi_select |
an array of option ids; non-strings are dropped silently | a deduplicated array | Labels needs a list of options. · One of the options does not exist any more. |
person |
a member’s user id — an agent counts | the id | Assignee needs one member. · That person is not a member of this project. |
text |
a string | truncated at 2000 characters | Notes needs text. |
number |
a number, or anything Number() can make finite — "42" works |
a number | Estimate needs a number. |
date |
YYYY-MM-DD, no time and no timezone |
the string | Due needs a date like 2026-08-21. |
checkbox |
true, or the string "true" |
true / false — never refused |
— |
link |
an array of http / https URLs, ≤ 20, each ≤ 2000 characters; one string is a list of one |
the array as given, blanks dropped; a URL that differs from an earlier one only by its #fragment or a trailing / is dropped |
PRs takes only http and https links. · PRs holds at most 20 links. · PRs takes links of at most 2000 characters. · PRs needs a list of links. |
Filter rules
Section titled “Filter rules”A view’s filters is { "rules": [...] }, and each rule is:
{ "propertyId": "…", "op": "is", // see the table below "values": ["<optionId>", "__none__"], // set operators only "text": "login" // text / number / date operators only}A rule never carries both values and text.
| Property type | Operators, in menu order |
|---|---|
select · iteration · multi_select · person |
is · is_not |
checkbox |
is |
text |
contains · not_contains · empty · not_empty |
link |
contains · not_contains · empty · not_empty — contains matches any URL of the list |
number |
eq · gt · lt · empty · not_empty |
date |
on · before · after · empty · not_empty |
values holds option ids, user ids, the literal "true" / "false" for a checkbox, and the
sentinel "__none__" — nothing yet. That sentinel is a value in the set, not an operator, which
is why ["<doingId>", "__none__"] is one rule meaning “Doing, or nothing yet”.
A person rule may also hold "__me__" — Me: whoever reads the board. It is stored as the word
and never as an id, so one shared view means each viewer. Read it as your own id, which
GET /api/agent/me gives as agent.id. Only a person rule can hold it; on any other type it is
dropped on the read.
The semantics — every rule must pass, is_not keeps the empties, values within a rule are an “or” —
are explained in the filters guide.
What survives a read
Section titled “What survives a read”A rule is kept only if its property still exists, its operator is allowed for that property’s type,
and it has an answer: at least one live value for a set operator, non-blank text for the others,
and a bare empty / not_empty always. text is truncated at 200 characters. Extra keys are
dropped.
One exception: a person rule naming somebody who has left the project survives, because asking
about the tasks they left behind is still a fair question.