HTTP API: schemas
The 124 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 - 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)undated_milestones_total(integer, default0)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 the planned
?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 listing does not report the server's path. A caller over HTTP is somewhere else: they cannot open the file, and there is no endpoint that takes a path - restore deliberately has none, 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.
A failure names the folder it could not use - the backup folder, or the staging folder in the data directory - because putting that folder right is the one thing such a failure asks of whoever runs the installation, and only somebody who administers the installation reaches these routes.
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, and every copy marked unfinished.
Fields
items(array ofBackup, required)unfinished(array ofstring)
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: what blocks an item, and for a milestone what it includes as well, since both have to happen before it is reached.
Fields
depth(integer, required)item(LinkEnd, required)stopped(stringornull)
Body_create_backup_v1_admin_backups_post
Fields
keep(integerornull, at least 1.0): 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.
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: 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
account_parent(stringornull)answers_to(stringornull)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)dated(boolean, defaultfalse)empty(string, default"nothing")field(string, required)quoted(boolean, defaultfalse)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_Edge_
Fields
Collection_Event_
Fields
Collection_Governing_
Fields
Collection_ItemType_
Fields
Collection_JournalEntry_
Fields
items(array ofJournalEntry, required)page(Page, required)
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_SavedView_
Fields
Collection_Status_
Fields
Collection_TagEntry_
Fields
Collection_Task_
Fields
Collection_Token_
Fields
Collection_UnreachableProject_
Fields
items(array ofUnreachableProject, required)page(Page, required)
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.
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 - this is the other door, for declaring one in advance and saying what it means here.
Fields
description(stringornull)name(string, required)
CreateView
What POST /v1/views accepts.
Fields
arrangement(string, required)group_by(stringornull)order(stringornull)q(stringornull)shared(boolean, defaultfalse)title(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.
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: 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)binds(string, default"project")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)
Edge
A link named by both its ends, as a stored fact rather than as one item's view of it.
The counterpart to :class:Link, which is the same row seen from one item. There is no
direction here and no inverted label, because there is no single item to look from -
and an edge that named only "the other end" would mean nothing when both ends are in hand.
Fields
created_at(string(date-time) ornull)created_by(string(uuid) ornull)id(string(uuid), required)label(string, required)link_category(stringornull)link_type(string, required)source(LinkEnd, required)target(LinkEnd, required)
Event
One thing that happened, as the history and the change feed both report it.
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. Ids rather than names - 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. A document's title states 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
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. 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(stringornull)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 - a 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"")entity_type(stringornull)id(string(uuid) ornull)is_default(boolean, defaultfalse)key(string, required)label(string, required)position(integerornull)
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)actor_interface(stringornull)changed(array ofChange)created_at(string(date-time), required)entity_type(string, required)id(string(uuid), required)item_project_path(stringornull)item_ref(integerornull)item_title(stringornull)item_type(stringornull)said(stringornull)said_truncated(boolean, defaultfalse)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.
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
created_at(string(date-time) ornull)created_by(string(uuid) ornull)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)included_count(integer, default0)included_done(boolean, defaultfalse)included_done_count(integer, default0)included_unseen(boolean, defaultfalse)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.
The old names went with the MVP release candidate, which is when they were promised
to go, and the release before it published both and read either. A client of this release
reads order and fields only, so an instance from before the new names, which sends
only the old pair, is outside the range it reads - by decision (Simon, 2026-09-22), with no
such instance known to be running.
Fields
fields(array ofstring)filters(array ofstring, required)formats(array ofstring, required)order(array ofstring)path(string, required)
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,
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)reader_timezone_set_by(stringornull)releases(ReleaseNewsornull)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: sight of a private project is granted 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)setting_sections(array ofSettingSection)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.
Built 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)created_by(string(uuid) ornull)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)updated_by(string(uuid) ornull)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)project_id(string(uuid) ornull)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)
PublishedRelease
One release, as the published record lists it.
schema_revision rather than the record's own schema, which is the name
:class:Me already gives the same fact and which a pydantic model cannot take as a field.
Fields
date(string, required)schema_revision(string, required)version(string, 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)timezone(stringornull)
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)
ReleaseNews
What this instance has found out about releases, so a client asks nothing itself.
Four answers, and three of them used to be one null. An operator who has not agreed to
ask, an instance that has agreed and not yet asked, and a check that could not be made each
leave nothing to report - and a shape that flattened them would let we could not check
read as you are up to date, which the terminal's own check already refuses to say. So
checking says whether anybody agreed, asked_at whether a check has run, and
failure whether it answered.
The whole list rather than a verdict, because the verdict differs by reader. Only the instance knows whether it is behind, but only a client knows which program and plugin it is running, and the record is the order that how far behind is counted in - so each surface works out its own standing from one list the instance fetched on everybody's behalf.
Fields
asked_at(string(date-time) ornull)checking(boolean, required)failure(stringornull)plugins(object ofstring)releases(array ofPublishedRelease)
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)
SavedView
A query and an arrangement, under a name.
Two halves, reported as two halves. q is the whole of what is narrowed, written in
the same search grammar a person types into a search box; arrangement, order and
group_by are the whole of how it is drawn. Nothing else belongs in either half, and a
narrowing reported as an arrangement field would hide a filter inside a display setting.
(No item numbers here, deliberately: this class docstring is published in
/v1/openapi.json, which answers with no credential, and a ref nobody outside can
resolve is a pointer to nothing.)
order here and in the column too: every listing takes order, so that is what a
caller sends and reads, and the column carries the same word although the database has to
quote it, because one name for one fact is worth more than a word left unquoted.
Fields
about_the_reader(boolean, defaultfalse)arrangement(string, required)created_at(string(date-time), required)group_by(stringornull, required)id(string(uuid), required)key(string, required)order(stringornull, required)owner(stringornull)owner_id(string(uuid), required)q(stringornull, required)shared(boolean, required)title(string, required)updated_at(string(date-time), required)version(integer, required)workspace_id(string(uuid), required)
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)section(stringornull)summary(string, required)
SettingSection
Settings drawn together under a heading that says what they are for.
For settings most people will never need: the section explains itself, links to more, and one drawn apart comes last on its page, after a separator. Published so a page draws it without a copy of its words.
Fields
apart(boolean, defaultfalse)explains(string, required)further_label(stringornull)further_url(stringornull)key(string, required)title(string, required)withheld(stringornull)
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)entity_type(stringornull)id(string(uuid) ornull)is_default(boolean, defaultfalse)key(string, required)label(string, required)position(integerornull)
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(stringornull)assigned_by_id(string(uuid) ornull)assignee(stringornull)assignee_answers_to(stringornull)assignee_id(string(uuid) ornull, required)assignee_is_agent(boolean, defaultfalse)beneath(WorkBeneathornull)blocked(boolean, defaultfalse)blocked_by(array ofLinkEndornull)blocking(boolean, defaultfalse)blocks_others(array ofLinkEndornull)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)included_count(integer, default0)included_done(boolean, defaultfalse)included_done_count(integer, default0)included_unseen(boolean, defaultfalse)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, 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(stringornull)workspace_id(string(uuid) ornull, required)
UnreachableProject
A private project nobody who can act here is able to see - what exists, and where.
Nothing from inside it, for :class:WorkspaceOnInstance's reason: the caller is not a
member, and what this answers is that the project is there and needs somebody let back in.
Its description, settings and work are still read through membership.
Fields
created_at(string(date-time), required)id(string(uuid), required)member_of_workspace(boolean, required)members(integer, required)project(string, required)title(string, required)workspace(string, 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)
UpdateView
What PATCH /v1/views/{key} accepts.
Every field here is read from whether it was sent, never from its value, because
null is a value on three of them: clearing a view's grouping and not mentioning it are
different instructions, and a body that could not tell them apart would make ungroup this
unaskable. So omitting a field leaves it alone and sending null clears it.
Fields
arrangement(stringornull)expected_version(integerornull)group_by(stringornull)order(stringornull)q(stringornull)shared(booleanornull)title(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
account_parent(stringornull)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)
WorkBeneath
What the work filed under one task adds up to.
No item reference in this docstring, and that is not an oversight. A response model's
class docstring is published as the OpenAPI description, which /v1/openapi.json
answers with no credential - so a ref here would point a stranger at an instance they
cannot reach. The item behind this is cited on the field below, which is a comment nothing
publishes.
The coverage is reported beside the total and never folded into it. A sum over the rows that carry an estimate is not an estimate of the subtree, and the difference is not small: when this was built, 150 of the 243 tasks with a parent carried no estimate. So this says how many of them it could add up, and a reader can see at a glance whether the number is worth anything - which is also what makes somebody go and fill the gap in.
Null on the item means nothing is filed under it, and on a listing it means nobody asked. Both render as silence, which is the same answer either way.
Fields
estimate_human(string, required)estimate_minutes(integer, required)estimated(integer, required)tasks(integer, 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, 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 between them: a comment is what happened, and a document is what somebody concluded.
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
binds(stringornull)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.
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
binds(stringornull)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.
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 a ref became 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, as subroutine add takes it - 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, 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. 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: the 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; 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)