HTTP API: schemas
The 110 named data shapes the operations take and return, each with its fields.
Agenda
The sections of a day, and what they were computed against.
date and timezone are both reported because "today" is not a fact about the
server (docs/design.md §6.5) — and a client merging several instances resolves the date once,
in its own zone, then asks every connection for that explicit day. Without that, a person
whose work profile says America/New_York and whose personal one says
Europe/London would get two different days merged into one list.
Fields
assigned_elsewhere_total(integer, default0)blocked_by_others(array ofTask)blocked_by_others_total(integer, default0)date(string(date), required)deferred_total(integer, default0)in_progress(array ofTask)later_total(integer, default0)occasions(array ofTask)overdue(array ofTask, required)passed_total(integer, default0)paused_total(integer, default0)timezone(string, required)today(array ofTask, required)unscheduled(array ofTask, required)unscheduled_total(integer, required)upcoming(array ofTask, required)waiting(array ofTask)
Backlink
One piece of prose that refers to an item.
The mention table has been written by every title, description, body and comment since
M1 and read by nothing. domain/mentions.backlinks had no caller and §8.5's
?include=backlinks was honestly refused, so what refers to this? — the question the
whole table exists for — was answerable on no surface at all.
It names something a reader can open, which is what makes the list worth having: a ref
and a title, the same argument that settled a link's far end. A comment has no ref, so
it resolves to the item it is on and says via so nobody goes looking for the sentence
in that item's own prose.
Fields
created_at(string(date-time), required)kind(string, required)ref(integer, required)title(string, required)via(stringornull)
Backup
One copy of the database, described well enough to choose between several.
The name identifies it and the server's path is deliberately not reported. A caller over HTTP is somewhere else: they cannot open the file, and there is no endpoint that takes a path — §12.4 gives restore none on purpose, so the one thing a reader might do with it is the one thing they cannot. What it does say is where this instance keeps its data, to somebody who by definition is not on that machine.
subroutine db backup still prints the path, and should: it runs beside the file.
Fields
name(string, required)profile(stringornull, required)schema_head(string, required)size_bytes(integer, required)taken_at(string(date-time), required)taken_for(stringornull)
Backups
Every backup this instance holds, newest first.
Fields
items(array ofBackup, required)
Beneath
One item in a dependency walk, and how far under the item asked about it sits.
Flat with a depth, not nested. A nested model would need a recursive type on the
wire and a recursive renderer on three surfaces to say the same thing; a flat list in
reading order is a loop everywhere, and the indentation is a multiplication.
The rows are what has to happen first, which is the milestone model read the way somebody asks it: a milestone is an item whose blockers are its contents.
Fields
depth(integer, required)item(LinkEnd, required)stopped(stringornull)
Body_create_backup_v1_admin_backups_post
Fields
keep(integerornull): Afterwards, keep only this many of the newest routine backups. Copies taken before an upgrade or a restore are kept under their own rules and are not counted here.
Calendar
A calendar feed as it can safely be described — docs/design.md §20.3.
Everything but the secret, exactly as :class:Token is: only a hash of it is stored,
so there is nothing here to rebuild one from. prefix is the public half, and is what a
listing prints and what resetting and revoking take.
last_polled_at is why this view is worth having at all. §20.3: a URL nobody has fetched
for six months is one to revoke, and there is no other way to tell — a feed has no login,
so when was this last used is the only signal that it is still wanted.
Fields
audience(string, required)created_at(string(date-time), required)expires_at(string(date-time) ornull, required)id(string(uuid), required)item_type_ids(array ofstring(uuid) ornull, required)item_types(array ofstringornull)last_polled_at(string(date-time) ornull, required)prefix(string, required)project_id(string(uuid) ornull, required)project_key(stringornull)revoked_at(string(date-time) ornull, required)title(string, required)usable(boolean, required)workspace_id(string(uuid), required)
Caller
The account somebody is acting as, told to that somebody.
Not :class:User, and it was written as a subclass of one first. The privacy
line is where the two part: an email is personal data that no caller needs a colleague's,
and everybody is entitled to their own — so this carries one and a directory entry does
not. What a directory carries and this does not is is_active and created_at, facts
about an account as an administrator sees it rather than about the authority being
exercised.
Inheriting looked tidier and was wrong for a reason worth keeping: it would have added two
required fields to a response that has been shipping without them, so a client built from
this tree could not read /v1/me from a server one release behind — and the failure it
produced said the server was "not a Subroutine instance". Found by running the command
against the live instance, which is a release behind, in the first minute of its existence.
Fields
display_name(stringornull, required)email(stringornull, required)id(string(uuid), required)is_service_account(boolean, required)is_superuser(boolean, required)timezone(stringornull, required)username(string, required)
Change
One field moving, said in words a person reads.
The column is kept beside the phrase, deliberately. field is what a program matches
on and said is what a person reads; dropping the first would make this the one place a
caller cannot tell the deadline from when it starts without parsing English, and
dropping the second is the defect this exists to fix.
Fields
after(stringornull)before(stringornull)field(string, required)said(string, required)
Changes
The change feed, and which kinds of thing it is able to tell you about.
Stated positively, and always, which is Simon's refinement of 2026-08-22 on the obvious alternative. Naming what was left out would need the reader to know which kinds exist before the omission means anything, and would say nothing at all to a caller who is not narrowed. This feed covers tasks and documents is a complete sentence either way.
It exists because a feed is not a listing of one kind. A credential narrowed away from one of the three used to be refused the whole feed, because each kind enforces its own read scope — so an agent whose skill tells it to ask what changed first failed on its first call rather than degrading to the two thirds it could see.
covers is a subset of scoping.readable_event_kinds' vocabulary, in a stable order.
Workspace-level events are not listed: they are narrowed by membership rather than by a
read verb, so they are carried for everyone and naming them would imply they could be
withheld.
Fields
Collection_Backlink_
Fields
Collection_Beneath_
Fields
Collection_Calendar_
Fields
Collection_Comment_
Fields
Collection_Document_
Fields
Collection_Event_
Fields
Collection_Governing_
Fields
Collection_LinkType_
Fields
Collection_Link_
Fields
Collection_Member_
Fields
Collection_ProjectMember_
Fields
items(array ofProjectMember, required)page(Page, required)
Collection_Project_
Fields
Collection_Proposal_
Fields
Collection_Status_
Fields
Collection_TagEntry_
Fields
Collection_Task_
Fields
Collection_Token_
Fields
Collection_User_
Fields
Collection_Verification_
Fields
items(array ofVerification, required)page(Page, required)
Collection_WorkspaceOnInstance_
Fields
items(array ofWorkspaceOnInstance, required)page(Page, required)
Collection_Workspace_
Fields
Comment
One entry in an item's record of what happened (docs/design.md §5.10).
No parent_comment_id: comments are flat and chronological by decision, and the column
stays in the schema as the escape hatch rather than as a field anybody can set.
Fields
author(stringornull)author_id(string(uuid) ornull, required)body(string, required)created_at(string(date-time), required)deleted_at(string(date-time) ornull, required)entity_id(string(uuid), required)entity_type(string, required)id(string(uuid), required)updated_at(string(date-time), required)version(integer, required)workspace_id(string(uuid), required)
CreateLinkType
What POST /v1/link-types accepts.
Fields
category(string, required)inverse_title(string, required)is_symmetric(boolean, defaultfalse)key(string, required)title(string, required)
CreateStatus
What POST /v1/statuses accepts.
Fields
category(string, required)entity_type(string, required)is_default(boolean, defaultfalse)key(string, required)label(string, required)position(integerornull)
CreateTag
What POST /v1/tags accepts.
A tag is still made by being used (§5.8) — this is the other door, for declaring one in advance and saying what it means here.
Fields
description(stringornull)name(string, required)
Credential
The credential a caller presented, and how far it narrows their authority.
Not :class:Token, which answers a different question. That one describes a credential
in an inventory — whose it is, whether it still works, when it was revoked — and every one
of those facts is settled here by the fact that this request was answered at all. What is
left is the part a caller acts on: what it lets them do.
Never the secret, and never anything from which one could be rebuilt: prefix is the
public half a token is looked up by and is safe to quote in a log (§7.4).
Fields
expires_at(string(date-time) ornull, required)id(string(uuid), required)kind(string, required)last_used_at(string(date-time) ornull, required)narrows(boolean, required)prefix(string, required)project_scope(array ofstringornull, required)project_scope_keys(array ofstringornull)project_write_scope(array ofstringornull)project_write_scope_keys(array ofstringornull)scopes(array ofstring, required)title(string, required)workspace_id(string(uuid) ornull, required)
Document
A document as the API reports it.
No due_at, starts_at, estimate_minutes or assignee_id, and their
absence is the point (docs/design.md §6.14): a specification is never "done" and nobody is
working on it. A deadline about a document belongs on a task that documents it.
Fields
archived_at(string(date-time) ornull, required)body(stringornull, required)content_updated_at(string(date-time), required)created_at(string(date-time), required)created_by(string(uuid) ornull, required)deleted_at(string(date-time) ornull, required)id(string(uuid), required)owner_id(string(uuid) ornull, required)parent_id(string(uuid) ornull, required)parent_ref(integerornull)parent_title(stringornull)project_colour(stringornull)project_id(string(uuid), required)project_key(string, required)project_path(string, default"")ref(integer, required)relevance(numberornull)revisions(Revisionsornull)size_bytes(integerornull)status(string, required)status_category(string, required)status_id(string(uuid), required)status_is_default(boolean, defaultfalse)status_label(string, default"")sub_documents(integer, default0)tags(array ofstring)title(string, required)type(string, required)type_category(string, default"")type_id(string(uuid), required)type_is_default(boolean, defaultfalse)type_label(string, default"")updated_at(string(date-time), required)updated_by(string(uuid) ornull, required)version(integer, required)workspace(stringornull)workspace_id(string(uuid), required)
Event
One thing that happened, as the history and the change feed both report it (§5.11).
Addressed by seq, not by id. The sequence number is the primary key, and it
is the only field a client can order or resume on; the UUID is carried because every
other entity here has one and a client keying a local cache by id should not have to
special-case this table.
changes is whatever the service that recorded it chose to say — {"status": {"from": …, "to": …}} and similar. Deliberately untyped: the shape belongs to the action, a new
action adds its own without a migration or a schema change here, and a client reads it
after switching on action.
Both actor fields, and both nullable. A system action has no user and a session-authenticated one has no token; recording which is which is what makes an audit trail worth reading (§5.11). Ids rather than names, per §8.5 — an unrequested relation is an id, and resolving every actor on every page is what the compact format exists to avoid.
subject_* is what the event happened on when that differs from the entity, and it is
null for almost everything. A comment's event names the comment and is reported in the
commented-on item's history, so a client rendering that history needs the subject to tell
"somebody edited this task" from "somebody commented on it" without a second call.
Fields
action(string, required)actor_interface(stringornull)actor_token_id(string(uuid) ornull, required)actor_user_id(string(uuid) ornull, required)changes(objectornull, required)created_at(string(date-time), required)entity_id(string(uuid), required)entity_type(string, required)id(string(uuid), required)item_ref(integerornull)item_title(stringornull)seq(integer, required)subject_id(string(uuid) ornull, required)subject_type(stringornull, required)workspace_id(string(uuid), required)
Governing
A document in force that a typed link says binds this item.
subroutine://conventions narrowed to one item: that resource says what binds anybody
working in this workspace, and this says what binds whoever picks this up.
Titles and refs, never bodies, which is what makes it affordable. §6.14 makes a document's title state its conclusion, so the list is readable on its own and a reader fetches only the one they need — a reading list that inlined its reading would be the cost it exists to remove.
Fields
document(LinkEnd, required)link_type(string, required)
Grammar
One of the small closed languages this installation parses.
Fields
description(string, required)examples(array ofstring, required)vocabulary(array ofstring, required)
HTTPValidationError
Fields
detail(array ofValidationError)
InForce
One setting as it applies to one workspace or project, and where its value came from.
Set here and inherited are different answers and read differently. A value chosen on this
entity can be cleared from it; one inherited from further up can only be overridden here or
changed where it was set. Neither the raw settings map nor a resolved value can say which,
so this carries both.
Fields
default(any)inherited_from(Sourceornull)key(string, required)set_here(boolean, required)value(any)
Instance
Which installation this is, and where it thinks it is.
id is the one value in this program that must never change (docs/design.md §13.7). A client
keys its caches on it, notices the same instance configured twice under two names by it,
and labels merged results with it — so an id that moved would silently corrupt all three
at once. name is the server's own label and may be changed freely; neither is the
connection name, which is the nickname in the reader's own configuration.
timezone is here so that a merged view can say what 16:00 on a New York server is
there, which is the difference between a calendar entry a person can act on and one
they have to do arithmetic on.
Fields
id(string(uuid), required)name(string, required)timezone(string, required)
IssuedCalendar
A feed at the one moment its URL exists in readable form.
Returned by creating one and by resetting one, and by nothing else. A separate type rather
than an optional field, for :class:IssuedToken's reason: a field that is usually absent
is one somebody eventually expects to find.
Fields
audience(string, required)created_at(string(date-time), required)expires_at(string(date-time) ornull, required)id(string(uuid), required)item_type_ids(array ofstring(uuid) ornull, required)item_types(array ofstringornull)last_polled_at(string(date-time) ornull, required)prefix(string, required)project_id(string(uuid) ornull, required)project_key(stringornull)revoked_at(string(date-time) ornull, required)title(string, required)url(stringornull)usable(boolean, required)workspace_id(string(uuid), required)
IssuedToken
A credential at the one moment its secret exists in readable form.
Returned by POST /v1/tokens and by nothing else, ever. Nothing recovers the secret
afterwards, including this program — which is why it is a separate type rather than an
optional field on :class:Token: a field that is usually absent is one somebody eventually
expects to find.
Fields
account_created(boolean, defaultfalse)created_at(string(date-time), required)expires_at(string(date-time) ornull, required)id(string(uuid), required)last_used_at(string(date-time) ornull, required)narrows(boolean, required)prefix(string, required)project_scope(array ofstringornull, required)project_scope_keys(array ofstringornull)project_write_scope(array ofstringornull)project_write_scope_keys(array ofstringornull)revoked_at(string(date-time) ornull, required)scopes(array ofstring, required)title(string, required)token(string, required)usable(boolean, required)user_id(string(uuid), required)username(string, required)workspace_id(string(uuid) ornull, required)
ItemType
An item type, with the fixed category a client may branch on.
A sibling of :class:Status rather than a field on :class:Named, and for its reason:
a link type is a Named too and has no category, so putting one on the base would publish
a field that is empty for one of the three vocabularies — §12.2a's column that says nothing,
one layer up.
The category exists for exactly one branch: a client draws by key when it recognises the key and by category when it does not. It is not a second way of asking what a document binds or when it was true.
Fields
category(string, default"")id(string(uuid) ornull)is_default(boolean, defaultfalse)key(string, required)label(string, required)
Join
What POST /v1/workspaces/{id_or_slug}/members accepts.
Fields
role(string, required)username(string, required)
Journal
What happened over a period, joined.
It carries covers for the same reason the feed does. A journal reads the
same store through the same scoping, so a credential narrowed away from a kind gets a
journal of what it may read — and without this, nothing happened on Friday and I am not
shown that are the same sentence. Said positively and always, per Simon's decision of
2026-08-22.
Declared here rather than beside JournalEntry, and that is not tidiness. pydantic's
mypy plugin crashes on Collection[JournalEntry] when the item model is the class
immediately above it — unresolved placeholder type, because the generic base is
serialised before the plugin has finished the model it names. Anything in between is
enough. Changes never met it only because Event is four hundred lines further up.
Fields
covers(array ofstring, required)items(array ofJournalEntry, required)page(Page, required)
JournalEntry
One thing that happened, with who did it and what they said.
The audit log's row is unchanged and this is a second reading of it.
:class:Event answers what changed and is what a client polling should read; this answers
what happened, which needs three joins the feed deliberately does not carry: the comment's
body, the actor's name, and the values inside changes.
Fields
action(string, required)actor(stringornull)changed(array ofChange)created_at(string(date-time), required)entity_type(string, required)id(string(uuid), required)item_ref(integerornull)item_title(stringornull)said(stringornull)seq(integer, required)
Limits
The bounds a request is held to.
Fields
default_page_size(integer, required)max_estimate_minutes(integer, required)max_hierarchy_depth(integer, required)max_page_size(integer, required)max_title_length(integer, required)
Link
One link, seen from the item that was asked about (docs/design.md §5.7).
A link is one stored row displayed from both ends, so label arrives already the right
way round: "Blocks" from one end and "Blocked by" from the other, off the same row. A
client that had to invert it would be a second place the inverse could be got wrong.
Fields
direction(string, required)id(string(uuid), required)label(string, required)link_category(stringornull)link_type(string, required)other(LinkEnd, required)
LinkEnd
What is at the far end of a link, with enough of the row to judge it.
Enough, and no more. A caller looking at an item's links wants to know what it is joined
to, not to receive every field of everything it touches — and an end the caller may not
see is never reported at all, which is :mod:subroutine.domain.links' obligation rather
than this model's.
What changed when this widened is what enough means, not the rule. It was five fields, which identify a thing; Simon, reading a milestone's own links: "I cannot look at a task and see whether all of its blockers are complete, without looking at each blocker individually." Identifying an end is not judging one, and a list of blockers nobody can judge is a list that has to be clicked through one item at a time.
The set is derived rather than chosen. These are the fields the browser's marks
reads — the indicator vocabulary its list, board and agenda rows already share — so a link
line renders through the same function as a row and cannot drift from it.
tests/test_web.py fails if marks grows a read this cannot answer.
And it is a projection of the full rendering rather than a parallel one, which is what
:func:_end is about: sixteen of :class:Task's fifty-nine, resolved by the code that
resolves them for a row. The one field deliberately not taken is description — the
whole body of every item this one touches, which is what the paragraph above refuses and
what measurement made a first-order cost.
Fields
assignee(stringornull)assignee_answers_to(stringornull)assignee_is_agent(boolean, defaultfalse)blocked(boolean, defaultfalse)blocking(boolean, defaultfalse)claim_expires_at(string(date-time) ornull)claimed_by(stringornull)claimed_by_answers_to(stringornull)claimed_by_id(string(uuid) ornull)claimed_by_is_agent(boolean, defaultfalse)deleted_at(string(date-time) ornull)due_at(string(date-time) ornull)due_is_all_day(boolean, defaultfalse)entity_type(string, required)id(string(uuid), required)is_complete(boolean, defaultfalse)project_path(string, default"")recurrence_description(stringornull)ref(integer, required)snoozed_is_all_day(boolean, defaultfalse)snoozed_until(string(date-time) ornull)status(string, default"")status_category(string, default"")status_is_default(boolean, defaultfalse)status_label(string, default"")sub_documents(integer, default0)sub_tasks_done(boolean, defaultfalse)tags(array ofstring)timezone(stringornull)title(string, required)type(string, default"")type_category(string, default"")type_is_default(boolean, defaultfalse)type_label(string, default"")workspace(stringornull)
LinkRequest
What POST /…/links accepts.
target is a ref or an id; target_type says which table to look in and defaults
to a task, which is what most links point at.
A ref is an integer in every response this API sends, so 42 is accepted as well as
"42" — a client should be able to send back what it was given without converting
it. An id arrives as a string, since a UUID is not a number.
Fields
direction(string, default"outgoing")link_type(string, required)target(integerorstring, required)target_type(string, default"task")
LinkType
A link type, and how it reads from each end.
There is no inverse_key, and that is settled rather than missing: the API names
the direction, not the inverse type. A link response carries link_type (this key),
direction (outgoing or incoming) and a label already the right way round.
Deriving an inverse key by lower-casing inverse_title works for the five seeded
types and breaks on the first custom one.
Fields
category(string, default"")id(string(uuid) ornull)inverse_title(string, required)is_symmetric(boolean, required)key(string, required)title(string, required)
Listing
What one collection endpoint accepts.
Reflected from the running application, so it cannot claim a filter that does not exist or omit one that does.
Each key is named after the parameter that consumes it, except path and filters:
filters is a list of parameter names rather than one parameter's values, so there is
no single parameter to name it after. That rule arrived late — the two lists below were
originally called sortable and selectable, after what they contain, so a caller
who read one and reached for ?select= earned a refusal.
sortable and selectable are deprecated and will be removed in 0.9.0. They carry
exactly what order and fields carry; read the new names.
Fields
fields(array ofstring)filters(array ofstring, required)formats(array ofstring, required)order(array ofstring)path(string, required)selectable(array ofstring)sortable(array ofstring)
Me
Who the caller is and exactly what they may do, in one round trip.
The answer :func:me assembles, reported by GET /v1/me and by the local client alike.
An agent should not have to discover its own authority by being refused things (§13.1),
and should not have to infer its own identity from a side effect
either.
Fields
api_version(string, required)credential(Credentialornull, required)instance_permissions(array ofstring, required)instance_version(stringornull)reader_timezone(stringornull)schema_revision(stringornull)user(Caller, required)workspaces(array ofWorkspaceAccess, required)
Member
One person's role in one workspace.
The join is reported as a thing in its own right rather than as a field on either side,
because that is what it is: §7.3a grants sight of a private project to holders of a
project_member row, and membership of a workspace is the same shape one level up.
Fields
created_at(string(date-time), required)role(string, required)user(User, required)workspace(WorkspaceRef, required)
Meta
Everything needed to construct a valid request against this installation.
Fields
api_version(string, required)docs(object ofstring, required)error_codes(array ofstring, required)grammars(object ofGrammar, required)instance(Instanceornull, required)instance_version(stringornull)item_types(object of array ofItemType, required)limits(Limits, required)link_types(array ofLinkType, required)linkable_types(array ofstring, required)listings(object ofListing, required)public_url(stringornull, required)purpose(string, required)search_backend(stringornull)server_time(string(date-time), required)settings(array ofSetting)source_url(string, required)statuses(object of array ofStatus, required)tags(Tags, required)vocabulary_not_shown(stringornull)workspace(string(uuid) ornull, required)workspaces(array ofWorkspaceRef, required)
Occurrences
When a repeating task comes round, over a stretch of time.
§6.7 reserved this for a calendar, and a decision taken since is why it is computed rather than stored: one occurrence is real and the rest are arithmetic. A birthday is one row for ever rather than one row per year since 1974, and show me every occurrence turns out to be a question about a view rather than about the backlog.
Dates and nothing else. An occurrence that has not happened has no status, no assignee and no comments — it is not a row and reporting it in a task's shape would invite a client to act on something that does not exist. What it does carry is the description, so a calendar can say what the rule is beside the dates it produced.
Fields
description(string, required)has_more(boolean, required)occurrences(array ofstring(date-time), required)rule(string, required)
Page
Where a collection response sits in the sequence it came from.
Fields
has_more(boolean, defaultfalse)held_back(integerornull)limit(integerornull)next_cursor(stringornull)total(integerornull)unread(array ofstringornull)
Parse
What POST /v1/recurrence/parse accepts.
Fields
from(string(date-time) ornull)text(string, required)timezone(stringornull)
Project
A project as the API reports it.
Fields
archived_at(string(date-time) ornull, required)created_at(string(date-time), required)deleted_at(string(date-time) ornull, required)depth(integer, required)description(stringornull, required)hidden_statuses(array ofstring, default[])id(string(uuid), required)is_inbox(boolean, required)key(string, required)owner_id(string(uuid) ornull, required)parent_id(string(uuid) ornull, required)path(string, default"")settings(object, required)status(string, required)status_category(string, required)status_id(string(uuid), required)status_is_default(boolean, defaultfalse)status_label(string, default"")template(string, required)title(string, required)updated_at(string(date-time), required)version(integer, required)visibility(string, required)workspace_id(string(uuid), required)
ProjectAccess
One project where the caller holds a role of the project's own, and what they may do there.
A project's own role replaces the workspace's for that project, so these are exactly the
projects where the workspace's permissions stop being the answer. Everywhere else in the
workspace, they are.
Fields
address(string, required)id(string(uuid), required)permissions(array ofstring, required)role(stringornull, required)
ProjectMember
One person who has been shared into a project.
Sight of the project, and nothing else — what somebody may do in it is still their role in
the workspace. There is deliberately no role here, unlike a workspace membership:
project visibility reads whether the row exists and nothing that is in it, so a role would
be a published field no surface can set.
Fields
created_at(string(date-time), required)project(string, required)user(User, required)
Proposal
A link the writing already implies and nobody has confirmed.
Deliberately not a :class:Link. It carries no id because there is no row, and a
client that could not tell the two apart would report a suggestion as a fact. What it is
instead is an offer: everything needed to make the link, plus the evidence, so that a
person or an agent can judge it rather than accept it.
because is the half that makes it judgeable. A citation in prose is written the same
way whether it means this follows that decision or this contradicts it, so a proposal
that could only be accepted or ignored would be asking for a rubber stamp.
Fields
because(string, required)direction(string, required)label(string, required)link_type(string, required)other(LinkEnd, required)
Reading
What a written repeat turned out to mean.
Fields
description(string, required)occurrences(array ofstring(date-time), required)rule(string, required)text(stringornull, required)
RecordVerification
What POST /v1/tasks/{id_or_ref}/verifications accepts.
No ran_at, deliberately. The service stamps it, and a field the route accepted and
no client passed would be an inert control — declared, documented and read by
nothing. What a caller would use it for is backdating a check that ran before the record
was posted, which is seconds in the one producer there is: a post-commit hook runs
immediately after the gate it is recording. When something needs longer, that is a
decision to take with the case in hand.
Fields
commit_sha(stringornull)output_excerpt(stringornull)passed(boolean, required)summary(stringornull)tree_hash(stringornull)
Regrade
What PATCH /v1/workspaces/{id_or_slug}/members/{username} accepts.
Fields
role(string, required)
Revisions
How often an item's body has been rewritten, and who last rewrote it.
A count and a name, never a diff. It answers one question — has what I am reading been replaced? — and the superseded text stays where it is, in the change feed.
Only a replacement of the body counts. A rename or a status change is something a reader already sees; writing prose onto an item that had none is a first draft rather than a revision of nothing.
Null means nobody asked. A single item resolves it and a listing does not, so a row saying nothing here is not saying never.
Fields
count(integer, required)last_at(string(date-time), required)last_by(stringornull)
Setting
One thing this installation may be configured with, as a client needs to know it.
The settings registry, published: each setting's key, where it may be set, the kind of value it takes, its default, what it is for and who may change it — so a settings form is assembled from this rather than written by hand. A form written by hand would be a second list of what settings exist, and the two would agree on the day they were written.
Fields
accepts(string, required)choices(array ofstringornull)default(any)key(string, required)kind(string, required)permission(object ofstring, required)scopes(array ofstring, required)summary(string, required)
SettingsInForce
Every setting one workspace or project may carry, as it applies there.
Fields
scope(string, required)settings(array ofInForce, required)
Share
What POST /v1/projects/{id_or_key}/members accepts.
Fields
username(string, required)
SignInLink
A sign-in link, at the one moment it can be read.
The secret is in the URL and nowhere else in this object. Two fields carrying one credential would be two places for it to be logged, and a caller that wants the parts has the URL to take them from.
Unlike :class:Token, there is no prefix here. Nothing revokes an individual link —
it is spent by being used and gone within the half hour either way — so the public half
would be a field with no question to answer.
Fields
address_assumed(boolean, defaultfalse)expires_at(string(date-time), required)url(string, required)username(string, required)
SignInLinkRequest
Who to issue a sign-in link for.
Fields
username(stringornull)
SignedOut
What signing somebody out of everything actually did.
The count is here because the alternative is a 204 that looks identical whether it ended four sessions or none, and "none" is the answer somebody needs to see when they have revoked the wrong account.
Fields
sessions_ended(integer, required)username(string, required)
Source
Where a value in force was stated, when it was not stated on the entity asked about.
Fields
address(string, required)scope(string, required)title(string, required)
Status
A status, with the fixed category a client may branch on.
Fields
category(string, required)id(string(uuid) ornull)is_default(boolean, defaultfalse)key(string, required)label(string, required)
Tag
A tag, and how much it is used.
Fields
name(string, required)usage(integer, required)
TagEntry
A tag as something to curate, rather than as something being used.
A second view of one row, deliberately. :class:Tag answers what labels are in use
and how much, and its usage count is narrowed to the tasks the caller can see, because a
tag list is a small disclosure. This answers what labels does this workspace have and what
do they mean, which needs an id to change one and a description to read one, and
needs no count at all. Folding the two together would either put a scoped aggregate on
every write response or a meaningless zero.
Fields
description(stringornull)id(string(uuid), required)name(string, required)
Tags
The tag list, and an honest statement of what was left out.
Fields
items(array ofTag, required)total(integer, required)truncated(boolean, required)
Task
A task as the API reports it.
Fields
archived_at(string(date-time) ornull, required)assigned_by_id(string(uuid) ornull)assignee(stringornull)assignee_answers_to(stringornull)assignee_id(string(uuid) ornull, required)assignee_is_agent(boolean, defaultfalse)blocked(boolean, defaultfalse)blocked_by(array ofLinkEndornull)blocking(boolean, defaultfalse)claim_expires_at(string(date-time) ornull)claimed_at(string(date-time) ornull)claimed_by(stringornull)claimed_by_answers_to(stringornull)claimed_by_id(string(uuid) ornull)claimed_by_is_agent(boolean, defaultfalse)completed_at(string(date-time) ornull, required)content_updated_at(string(date-time), required)created_at(string(date-time), required)created_by(string(uuid) ornull, required)deleted_at(string(date-time) ornull, required)description(stringornull, required)due_at(string(date-time) ornull, required)due_is_all_day(boolean, required)ends_at(string(date-time) ornull)estimate_human(stringornull, required)estimate_minutes(integerornull, required)id(string(uuid), required)importance(integerornull, required)is_complete(boolean, defaultfalse)is_template(boolean, defaultfalse)occurrence_at(string(date-time) ornull)parent_ref(integerornull)parent_task_id(string(uuid) ornull, required)parent_title(stringornull)priority_score(integerornull, required)project_colour(stringornull)project_id(string(uuid), required)project_key(string, required)project_path(string, default"")rank(integerornull)recurrence_anchor(stringornull)recurrence_description(stringornull)recurrence_rule(stringornull)recurrence_template_ref(integerornull)recurrence_text(stringornull)recurrence_trigger(stringornull)ref(integer, required)relevance(numberornull)reminder_human(stringornull)reminder_minutes(integerornull)revisions(Revisionsornull)size_bytes(integerornull)snoozed_is_all_day(boolean, defaultfalse)snoozed_until(string(date-time) ornull)starts_at(string(date-time) ornull)starts_is_all_day(boolean, defaultfalse)status(string, required)status_category(string, required)status_id(string(uuid), required)status_is_default(boolean, defaultfalse)status_label(string, default"")sub_tasks_done(boolean, defaultfalse)tags(array ofstring)timezone(stringornull, required)title(string, required)type(string, required)type_category(string, default"")type_id(string(uuid), required)type_is_default(boolean, defaultfalse)type_label(string, default"")updated_at(string(date-time), required)updated_by(string(uuid) ornull, required)urgency(integerornull, required)version(integer, required)workspace(stringornull)workspace_id(string(uuid), required)
Token
A credential as it can safely be described.
Everything but the secret, and nothing from which it could be rebuilt. Only a
sha256 of the secret is stored (§7.4), so there is nothing here to leak; prefix is
the public half a token is looked up by and is what revoking takes.
usable is stated rather than left to be worked out from two nullable columns, for the
reason token list states it: a reader who has to compare expires_at against the
clock is one who eventually reads a dead credential as live, on the day they are checking
whether it is.
Fields
created_at(string(date-time), required)expires_at(string(date-time) ornull, required)id(string(uuid), required)last_used_at(string(date-time) ornull, required)narrows(boolean, required)prefix(string, required)project_scope(array ofstringornull, required)project_scope_keys(array ofstringornull)project_write_scope(array ofstringornull)project_write_scope_keys(array ofstringornull)revoked_at(string(date-time) ornull, required)scopes(array ofstring, required)title(string, required)usable(boolean, required)user_id(string(uuid), required)username(string, required)workspace_id(string(uuid) ornull, required)
UpdateLinkType
What PATCH /v1/link-types/{id} accepts.
Fields
category(stringornull)inverse_title(stringornull)key(stringornull)title(stringornull)
UpdateStatus
What PATCH /v1/statuses/{id} accepts.
No category: it is what every client branches on, so moving a status between
categories would change the meaning of every item already in it rather than its wording.
Fields
is_default(booleanornull)key(stringornull)label(stringornull)position(integerornull)
UpdateTag
What PATCH /v1/tags/{id} accepts.
Fields
description(stringornull)name(stringornull)
User
An account as the API reports it.
No email address, deliberately. Everything here is an identifier or a fact about what the account can do, both of which somebody adding a colleague to a workspace needs. An email is personal data, it is needed for none of that, and a directory that hands one to every authenticated caller is a directory that leaks by default rather than on purpose. Decision The privacy line is the one being followed: identifiers are unique and public, content is neither.
is_service_account is reported because it changes what a name means. A list mixing
people and agents with nothing to tell them apart is one where somebody eventually adds the
robot to the stand-up.
Fields
answers_to(stringornull)created_at(string(date-time), required)display_name(stringornull, required)id(string(uuid), required)is_active(boolean, required)is_service_account(boolean, required)is_superuser(boolean, required)last_login_at(string(date-time) ornull)responsible_user_id(string(uuid) ornull)timezone(stringornull, required)username(string, required)
ValidationError
Fields
ctx(object)input(any)loc(array ofstringorinteger, required)msg(string, required)type(string, required)
Verification
What was checked against a task, and which tree it was checked on.
A record, not a proof. An agent can post an exit code of zero without having run anything, so what this is worth is being durable, attributable and invalidatable — never verified work. That sentence is settled and nothing built on this model may soften it.
is_stale is deliberately absent: it is derived from the tree the reader is standing
on, which is not on this row and is not on the instance either. What is published is
tree_hash, and the comparison belongs to whoever has a checkout.
Fields
commit_sha(stringornull, required)created_at(string(date-time), required)id(string(uuid), required)output_excerpt(stringornull, required)passed(boolean, required)ran_at(string(date-time), required)recorded_by(stringornull, required)summary(stringornull, required)task_ref(integer, required)tree_hash(stringornull, required)
Workspace
A workspace as the API reports it.
Richer than :class:WorkspaceRef, which is the two-field form embedded in other responses.
This is what /v1/workspaces returns, and the difference is that a caller reading this
is administering the workspace rather than resolving an address through it.
next_ref_number is deliberately absent. It is the counter behind the ref sequence, and
publishing it would invite a client to predict the next ref — which is exactly the guess that
breaks the moment two writes race.
Fields
created_at(string(date-time), required)deleted_at(string(date-time) ornull, required)description(stringornull, required)id(string(uuid), required)prioritised_project(stringornull)settings(object, required)slug(string, required)timezone(stringornull, required)title(string, required)updated_at(string(date-time), required)version(integer, required)
WorkspaceAccess
One workspace a caller can reach, and what they may actually do in it.
:class:WorkspaceRef is how a client addresses a workspace; this adds what the caller
may do once they are there. Extending it rather than repeating three fields keeps a slug
meaning one thing in both.
Fields
description(stringornull)id(string(uuid), required)narrowed_by_credential(boolean, required)permissions(array ofstring, required)prioritised_project(stringornull)projects(array ofProjectAccess)reader_timezone(stringornull)role(stringornull, required)slug(string, required)timezone(stringornull, required)title(string, required)
WorkspaceOnInstance
One workspace as the installation sees it — what exists, rather than what is in it.
Deliberately thinner than :class:Workspace, which carries settings and the project a
workspace has prioritised. Those are facts from inside, and the caller here may not be a
member: this answers what is on this installation and nothing more. Reading a workspace's
contents still needs membership, and joined is what says whether the reader has it.
Fields
created_at(string(date-time), required)id(string(uuid), required)joined(boolean, required)members(integer, required)slug(string, required)title(string, required)
WorkspaceRef
One workspace, by both the name it is stored under and the name a person types.
Typed rather than left as a bare mapping because a client resolves acme/42 through
this: the slug comes off a command line and the id goes into a query.
Fields
id(string(uuid), required)prioritised_project(stringornull)reader_timezone(stringornull)slug(string, required)title(string, required)
Create (subroutine.api.calendars)
What POST /v1/calendars accepts.
There is no owner field, deliberately. A feed renders with its owner's sight (§20.1), so naming somebody else would mint a URL that reads their work and hand it to whoever asked — the escalation already found on sign-in links, and worse here because a feed has no session to end and nothing to audit. The owner is the caller, structurally.
Fields
audience(string, default"everything")expires(stringornull)item_types(array ofstringornull)project(stringornull)title(string, required)workspace(stringornull)
Create (subroutine.api.comments)
What POST /v1/{entity}/{ref}/comments accepts.
Only a body. No title, no type, no project — a comment that needed those would be a document, and offering them here would blur the one distinction §5.10 is about.
Fields
body(string, required)
Update (subroutine.api.comments)
What PATCH /v1/comments/{id} accepts.
Fields
body(stringornull)expected_version(integerornull)
Create (subroutine.api.documents)
What POST /v1/documents accepts.
Fields
body(stringornull)owner_id(string(uuid) ornull)parent(integerorstringornull)project(stringornull)status(stringornull)tags(array ofstringornull)title(string, required)type(stringornull)workspace_id(stringornull)
Move (subroutine.api.documents)
Where a document should sit in the tree. parent: null makes it top-level.
Fields
expected_version(integerornull)parent(stringornull)
Update (subroutine.api.documents)
What PATCH /v1/documents/{id_or_ref} accepts.
Omitted is unchanged; null clears (§8.3).
Superseding is a link, not a field here (SR#1684). POST /v1/tasks/{ref}/links and
the document equivalent take a supersedes link, which is the same mechanism a task uses
— where this was a column only a document had, and which no surface ever rendered.
Fields
body(stringornull)expected_version(integerornull)owner_id(string(uuid) ornull)project(stringornull)status(stringornull)tags(array ofstringornull)title(stringornull)type(stringornull)
Update (subroutine.api.instance)
What PATCH /v1/instance accepts.
Fields
name(stringornull)timezone(stringornull)
Create (subroutine.api.projects)
What POST /v1/projects accepts.
Fields
description(stringornull)key(string, required)owner_id(string(uuid) ornull)parent(stringornull)template(string, default"blank")title(string, required)visibility(string, default"public")workspace_id(stringornull)
Move (subroutine.api.projects)
Where a project should sit in the tree.
parent: null makes it a root, which is why this is a body rather than a query
parameter — "no parent" and "unchanged" have to be distinguishable (§8.3).
And they were not, until 2026-07-30. The handler read body.parent directly, so an
omitted parent and an explicit null both meant "move to root" — and
POST /v1/projects/web/move {} silently flattened a project and its whole subtree. This
was the one mutating site in the API that did not use model_fields_set, twenty lines
below a docstring saying it must. A move is not a field being dropped; it rewrites the
materialised path of every descendant, and there is no undo.
Fields
expected_version(integerornull)parent(stringornull)
Update (subroutine.api.projects)
What PATCH /v1/projects/{id_or_key} accepts.
key may be changed. It was absent here on the grounds that it is "the
first half of every ref the project has minted" — which stopped being true on 2026-07-29,
when §6.2 made a ref a bare workspace-scoped integer. A project key is in no ref.
What a rename costs is addresses: this URL, a .subroutine marker in somebody's
checkout, +KEY in a capture line. The old key stops resolving and there is deliberately
no alias — the decision is that retiring a name should retire it. Callers who cached the
old address get a 404 they can act on rather than a redirect they never notice.
Fields
description(stringornull)expected_version(integerornull)key(stringornull)owner_id(string(uuid) ornull)settings(objectornull)status(stringornull)title(stringornull)visibility(stringornull)
Create (subroutine.api.tasks)
What POST /v1/tasks accepts.
Either text — one captured line, parsed per §6.13 — or the structured fields, or
both: anything given explicitly wins over what the text said, so a client that wants
no magic simply sends structured fields and no text.
Fields
assignee(stringornull)description(stringornull)due(stringornull)due_is_all_day(booleanornull)ends(stringornull)estimate(integerorstringornull)importance(integerornull)parent_task_id(integerorstringornull)project(stringornull)recurrence(stringornull)recurrence_anchor(stringornull)recurrence_trigger(stringornull)reminder(integerorstringornull)snooze(stringornull)snoozed_is_all_day(booleanornull)starts(stringornull)starts_is_all_day(booleanornull)status(stringornull)tags(array ofstringornull)text(stringornull)timezone(stringornull)title(stringornull)type(stringornull)urgency(integerornull)workspace_id(stringornull)
Move (subroutine.api.tasks)
Where a task should sit in the tree.
parent: null promotes it to a top-level task, which is why this is a body rather than
a query parameter — "no parent" and "unchanged" have to be distinguishable (§8.3), and
POST /v1/projects/{key}/move learned that the expensive way: an omitted parent read as
"move to root" and flattened whole subtrees.
Fields
expected_version(integerornull)parent(stringornull)
Update (subroutine.api.tasks)
What PATCH /v1/tasks/{id_or_ref} accepts.
A field left out is unchanged; a field sent as null is cleared (§8.3). The two
are told apart by model_fields_set, never by comparing against a default — that is
what makes "clear the due date" expressible at all.
Fields
applies_to(stringornull)assignee(stringornull)description(stringornull)due(stringornull)due_is_all_day(booleanornull)ends(stringornull)estimate(integerorstringornull)expected_version(integerornull)importance(integerornull)project(stringornull)recurrence(stringornull)recurrence_anchor(stringornull)recurrence_trigger(stringornull)reminder(integerorstringornull)snooze(stringornull)snoozed_is_all_day(booleanornull)starts(stringornull)starts_is_all_day(booleanornull)status(stringornull)tags(array ofstringornull)timezone(stringornull)title(stringornull)type(stringornull)urgency(integerornull)
Create (subroutine.api.tokens)
What POST /v1/tokens accepts.
Every field narrows. There is no field that widens, and there could not be: §7.4's whole
least-privilege story rests on a credential staying at most as wide as the one that asked
for it, which issue_token enforces rather than this model.
Fields
expires(stringornull)project_scope(array ofstringornull)project_write_scope(array ofstringornull)scopes(array ofstringornull)service_account(stringornull)title(stringornull)username(stringornull)workspace(stringornull)
Create (subroutine.api.users)
What POST /v1/users accepts.
No password, and that is a decision rather than an omission. Subroutine authenticates
with bearer tokens (§7.4); a password field here would imply a login this build does not
have, and would put a credential in a request body for no one to use. A new account is
given a token with subroutine token create --username.
Fields
display_name(stringornull)email(stringornull)is_service_account(boolean, defaultfalse)is_superuser(boolean, defaultfalse)timezone(stringornull)username(string, required)
Update (subroutine.api.users)
What PATCH /v1/users/{username} accepts.
The two halves of somebody leaving, and they belong together: one records that they have gone, the other keeps the agents that would otherwise stop with them. Either alone is a control people work around — losing an agent is a price nobody pays willingly, so a leaver simply does not get marked as one.
Fields
is_active(booleanornull)responsible(stringornull)timezone(stringornull)
Create (subroutine.api.workspaces)
What POST /v1/workspaces accepts.
Fields
description(stringornull)settings(objectornull)slug(string, required)timezone(stringornull)title(string, required)
Update (subroutine.api.workspaces)
What PATCH /v1/workspaces/{id_or_slug} accepts.
slug may be changed. It was absent on the grounds that it lives "in
other people's notes, in shell history and in config.toml on other machines" — and the
last of those is not true: no connection and no setting names a workspace. What is left is
the same exposure a project key has, which is acceptable when the caller is
told what stops working first.
Validated exactly as creation validates one, so a rename cannot arrive at a short name nobody could have chosen.
Fields
description(stringornull)expected_version(integerornull)prioritised_project(stringornull)settings(objectornull)slug(stringornull)timezone(stringornull)title(stringornull)