The Work Tracker
Crewlet ships its own tracker, and it is the default. This page is the product guide: what a company can record, what a seat can do with it, what a person can do with it, and what each screen answers.
The architecture — one ordered log, N identical copies — is in The Tracker and Replication. Nothing here needs it.
Projects and keys
Section titled “Projects and keys”Work is filed into projects. A project has a key (ENG, OPS), a name,
and a unit of the org chart that owns it. Every task filed into it gets a key
of the form ENG-412, minted from the project’s own counter.
Projects come from the org chart. Declaring a unit with a project key in the company config creates the project on the next apply, on every node, with no gesture from anybody. That is what lets a fresh company file its first task in its first minute.
A project’s name, purpose and unit follow the configuration that was
activated last, whichever node applies what when: each project is stamped
with the instant its configuration was activated, and a chart activated
earlier never overwrites one activated later — so a node restarting on a
revision the fleet has since replaced, or applying an older one late during a
rollout, leaves the newer names alone. Re-applying an activation that has
already landed writes nothing, which is every restart of every node on a
company nobody has edited; when several nodes apply one activation at once,
they contend per project and exactly one of them writes it. Re-activating an
unchanged revision (the
credential-rotation gesture) is a new
activation, and records one quiet “org chart re-applied” change per project to
say so. The instant is the activating node’s clock, except that an
activation is never published at an instant no later (to the millisecond)
than the one it replaces: the activation pointer moves it to a millisecond
after, so an activation made on a node whose clock runs behind the last
activator’s — or a node republishing an older activated_at at boot — is
still applied to every project, where it used to be applied to none. Keep the
fleet’s clocks synchronised anyway: the rule orders activations against each
other, and the stamps a chart carries are still read off real clocks. A node
that boots on a company file it has not imported yet applies no chart until
the control plane activates that file, which happens before the node claims
any seat.
A numbering gap is normal and permanent. ENG-7 exists, ENG-8 never did,
ENG-9 is next: the counter moves before the task lands, so a crash between
the two costs a number rather than risking two tasks sharing a key. A key is
what people paste into chat, so it can never be ambiguous.
What a project row carries about its work
Section titled “What a project row carries about its work”Beside its own settings, every project carries two facts the engine maintains from the work filed into it — so a directory of projects answers “how much” and “how recently” without reading a task:
| What it is | |
|---|---|
| task counts | todo, active, done and closed: how many of the project’s tasks are in each of the four status groups — todo is work nobody has started, active work somebody has. They are two numbers because they are two situations: forty items waiting is a queue to triage, forty in progress is a team at full stretch. Moved by the commit that moves a task between groups, that files one, that moves one to another project, or that removes or purges one. A surface wanting every unfinished item adds the first two. |
| last change | when the project’s work last changed and who changed it — a handle and which of the four author kinds it is (agent, human, operator, system), so a seat’s write and a person’s read differently. |
Both are maintained, never counted on the fly: they are written by the same commit that changes the work, so drawing thirty projects costs thirty rows rather than a pass over every task and every change the company has ever made.
The last change is the newest entry of that project’s own activity feed
(task_activity) — the same commit, the same instant, the same actor — so the
two can never disagree. That decides what counts:
- Every commit about a task in the project moves it: filing one, changing any field, commenting, archiving, removing, restoring, purging — loud or quiet.
- An agent’s turn does not. A turn records what the work cost, not a change to it; counting it would mark every project a seat is thinking in as changing continuously.
- Re-ordering the board does not. The manual order is not a change to any task, and the feed does not carry it either.
- Editing the project itself does not — a new default assignee, a field declaration, an archive. The project’s settings changing is not its work changing.
- A task moved between projects counts against the project it moved to, which is where a reader following it up will look. Both projects’ counts move.
A move between projects — move_work_item, the item’s project lead’s or a
person’s own — carries the task’s whole subtree: only a root task moves,
and every task beneath it follows, re-keyed in the new project with its old key
still resolving. The tags the subtree carries are declared in the new project
as its old one declares them, and the people on the root are told. A tag the
new project already has under the same slug is simply used. One whose label
the new project already gives a different tag — ENG’s api and OPS’s
backend-api, both labelled “API” — refuses the move before anything is
written, naming both tags, because declaring it would make two tags nobody
could tell apart; so does a new project already holding its 512 tags. The move
is never rewritten onto a tag it did not carry: put the items under the target’s
tag first if they mean the same thing, or declare the slug in the target under
a label of its own (write_project’s tags_add), and move again. A task in the trash anywhere in that subtree refuses
the move until it is restored or purged, because a removed task is frozen and
the move could not carry it. A move that stops part-way — its node died, or the
stream refused an append — leaves its root marked mid-move, and the
tracker duty finishes it on its first pass (every 15 minutes) after the
move’s claim has lapsed, which is a minute after its last heartbeat and never
while it is still running: the tasks still in the old project follow on fresh
keys, which leaves a gap in the numbering like any other interrupted write. A
task removed while the move was running is waited for — the root stays marked
until it is restored, and the next pass carries it. Until the walk is finished,
every task still in the old project is in the attention queue as
flag=inconsistent_project: a subtask filed in another project than its root
is not drawn under that root on either board. A merge never leaves one: a fold
that would re-parent a duplicate’s subtasks onto an item in another project is
refused before it starts, naming the move that makes it possible.
The same call made again — what a move answered unknown, or stopped part-way,
tells you to do — is answered by the move itself even once its first attempt has
carried the item into the new project: it reports the move that landed, under the
key the item left (moved_from), and finishes whatever of the subtree has not
followed. The lead check is on the move out of a project, so the lead of the
old project finishing their own move is not sent to the new project’s lead about
a move that already happened; and a move into the project an item is already in
that no earlier attempt of that same call made is refused, as somebody else’s.
A project nobody has filed work into reports no last change at all, rather than an instant borrowed from its own creation: “nothing has ever been filed here” is the answer, and a made-up date would make an untouched project look freshly active.
A project’s target date
Section titled “A project’s target date”A project may carry a target date: the day its lead means it to be
finished. It is a date, not an instant — 2026-12-18, a day on the
company’s own clock (timezone) — because a target is a day somebody named in
a planning meeting, and an instant would have to invent the hour and the zone
that day ends in.
- It is the lead’s, set with
write_project(target_date: "2026-12-18")by the project’s lead or a person acting through their own credential, and refused for any other seat. It is not chart-owned: a config apply that renames the project carries the target through untouched. - Given an instant instead, the write stores the day that instant falls on
on the company’s clock and says so in the answer’s
warnings. Anything that is neither is refused, naming both spellings. nullor""clears it; a project with none reports notarget_date, andsort=targetputs it last in both directions — “no target” is not the earliest one.
Which team an item belongs to
Section titled “Which team an item belongs to”Every item carries two units, and they answer different questions.
- Filed into — the team the work belongs to. It is set once, at the
create, and nothing rewrites it: it is a record of what was true, so it may
name a team the chart has since renamed or dropped. It is what
unit=filters on and what a board’sunitaxis groups by, and a board heads that column with the team’s current name. - Routes to — whose lead hears about the item now. It starts equal to
the filed unit and moves when somebody re-routes the item
(
update_work_itemwithrouting_unit).
Re-routing is the project lead’s, or a person’s own. Pointing somebody
else’s work at another team is a decision about who owns it, so a seat may
re-route only the work in a project it leads, or one an ancestor of it leads —
every other seat is refused, and the refusal names the project whose lead to
ask. A person acting through their own credential may re-route it: a human
seat writing as themselves, or an API token on
the operator surface. That is the same authority that
declares a project’s fields and orders somebody’s queue, and an unbound token
holds it too — an operator outside the org chart is still the person running
the company. Where a token IS bound to a human seat with
contact.crewlet_operator_id, the lead relation is resolved for that
person, so a founder’s own assistant re-routes as the founder rather than
falling back on the credential’s own authority.
You rarely state either. An item filed with no unit is filed into the
team that owns its project — the one the org chart gave the project — so a
task in ENG belongs to whichever unit declared project: ENG, whoever filed
it: an agent in that team, an agent in another, a person on the board, or
your own AI assistant, which holds no seat and
therefore no team of its own. Name unit only when the work belongs to a
different team than the project it sits in.
A project the chart gave no unit files work into no unit, which is honest rather than a default: the project’s lead is then the only lead fallback the item has.
Naming a team: its id or its name
Section titled “Naming a team: its id or its name”A unit is named by its id if the org chart gave it one, and by its name
otherwise — see
the org chart’s unit ids.
Both spellings work everywhere a unit is named, in any case: the unit
argument on create_work_item, routing_unit on update_work_item, the
unit= and routing_unit= filters, the unit a project listing is narrowed
by, and GET /work/workload?unit=. unit: engineering reaches the team
called Engineering.
What the engine stores is always the unit’s id where it has one, so that renaming a team does not move the work filed into it. What a screen shows is always the team’s current name, resolved back through the chart when the answer is built: an item’s Filed into and Routes to read as the team’s name and link to that team’s work, a board’s unit column is headed with it, and a project’s row in the directory carries it. A reference the chart no longer has is marked on each of those rather than printed as a name — it is a team that has left the chart, which is something to correct.
A team that was given an id after it already had work filed into it has both spellings in its history, because nothing rewrites what was filed. That is invisible: a team is one column, whichever spelling each item was written under, with one count over all of it — see what a board groups on.
Adding an id to a team that already has work does not rewrite that work,
and does not need to. A task’s filed unit is a record of what was true and
nothing in the engine rewrites one — these rows are derived from the ordered
log every node replays, so a repair would have to publish a record per task
claiming the team was called something it was not. Instead, every filter
matches the set of a team’s spellings: work filed under Engineering
before the id was added and work filed under eng after it are both found by
unit=eng and by unit=Engineering. Project rows carry the unit too, and
those are chart-owned — the next config apply rewrites each of them to the
current id on its own.
A unit reference that names no team in the chart is refused at a write (naming the team, so a typo is visible) and matches nothing at a read, never an error: a filter naming a team nobody has is answerable, and the answer is that there is no such work.
| Field | What it is |
|---|---|
| key | ENG-412. Unique in practice, never reused. |
| type | from the workspace catalogue — see below. A type the company has not declared is REFUSED at the create. |
| title, body | the body is versioned; every save writes an immutable revision. |
| status | one of todo, in_progress, in_review, done, cancelled, closed. |
| priority | none, low, normal, high, urgent. |
| assignee | one seat or person. |
| reporter | who filed it. |
| unit | the team the work belongs to, and the team it routes to — see Which team an item belongs to. |
| collaborators, watchers | who is on the thread and who is listening. |
| parent, subtasks | a tree, with a depth cap. A query filters ROOTS by default and lets their subtrees ride along; subtasks=separate filters every task on its own. |
| start / due, estimate, points | scheduling and sizing. |
| tags | from a per-project tag set — see Tags. A tag the project has not declared is REFUSED at the write. |
| custom fields | declared per project and at the workspace, typed, with option lists — see the catalogue. Filter on one with f.<slug>, and see the operators. |
| checklists | up to 16 named lists holding up to 64 items each and 256 between them, each item with its own assignee — see Checklists. |
| relations, dependencies | links between tasks, and blocking edges. |
| linked pages | knowledge-base pages the task names, set with linked_pages on update_work_item. linked_page=<page id> lists the tasks that name a page. |
| references | other tasks this task’s description mentions by key (ENG-12), derived by the applier on every node from the first 64 distinct keys the description names, where a task’s former key resolves like its current one. references=<key> lists the tasks whose description mentions it. A comment’s mentions are not references. |
| spend | turns, rounds, tokens, cache, wall-clock, delegated workers and review send-backs this task has cost — see Spend is on the task. |
| reopens | how many times the task left a finished status for an unfinished one. |
| updated | when the task last changed: the fleet-agreed instant of the newest record that changed it — the same instant its newest history entry carries — never a writer’s clock. It is what sort=-updated, updated= and every list’s Updated column read. |
Status groups
Section titled “Status groups”Six statuses, four groups: not_started, active, done, closed. Filters
and boards work on the group, so a company that adds a status does not have to
re-teach every query what “finished” means.
done and cancelled both stamp the task finished — the group decides, not
the slug. A cancelled task is finished work that produced nothing, which is a
different fact from an open one and the same fact for anything counting.
A task that leaves done or closed for an unfinished status is reopened,
and the task counts it (reopens). Done → closed is not a reopen — the work
stayed finished — and neither is todo → in_progress. It is counted by
group, so a status a company adds or renames does not change what a reopen is.
Sort by it with sort=reopens, and total it with totals=reopens:sum.
Spend is on the task
Section titled “Spend is on the task”Every turn an agent spends on a task adds to that task’s own counters. That is what makes “what did this cost” a question about a piece of work rather than about a seat’s month, and it is the number a founder actually wants when a task has been reopened four times.
One turn, one task. A turn is charged to the one work item it is on, or to nothing — never split. The item is the one its wake was about (a task notification, or a Jira, GitHub or GitLab event about one issue), the one the colleague who asked for help was on, or the one a coding run it resumes was launched on; failing all three, the one task its own writes touched, if they touched exactly one. A chat message that leads to no task write is charged to nothing. Only the engine’s own tasks carry counters: a turn on a Jira issue is attributed on its events and adds to no row here.
What a turn’s tokens include. Its own phases, the workers it delegated to,
the round-cap judge, the coding runs it detached, and the auxiliary model
calls it made along the way — the turn-start memory filter, knowledge query and
episode summary, every rewrite its ledgers and tools needed, and the
condensing of its card — in input and output, with the prompt cache’s share of
the input beside them. That includes what the engine spends on a coding run
while no part of the turn is running: an agent-mode run’s calls through the
tool bridge (the rewrites its tools ask for, the workers it delegates to) and
the condensing of a collected run’s report, failure or question, which the part
of the turn that resumes from the run pays. Learning afterwards (reflection,
diary, skills, the conversation ledger’s account of the turn) is the seat’s own
and is not charged to the task; it is still on the seat’s day in the spend
history.
Beside the tokens, two counts say why a task was expensive: spend_workers,
how many delegated tasks its turns ran, and spend_sent_back, how many reviews
returned the work for another pass.
A turn that parks is charged per segment. A turn that launches a coding run completes once when it parks and again each time a run it launched is collected — often minutes later, on another node. Each segment adds what it spent, and only the first counts as a turn, so a turn that parked twice is still one turn on the task. The segment that collects a run pays for that run. A turn charged only because of what it wrote is charged when it ends, for every segment before it too.
Counted once, whichever node runs the turn. Each segment is recorded under
an id of its own, and the counters move only when that record’s row is new — so
a segment retried after a failed resume, or a record delivered twice, adds
nothing. A seat on a node without the data role records its charge through a
data node like every other write, under the same id, so a charge asked again
of the next data node after one did not answer is still counted once.
Removed versus purged. A task in the trash is still charged: removing a task hides it and destroys nothing, and the work was done on it. A purged task is not — the charge is refused, and a charge that was already on its way when the purge landed is dropped on every node rather than stopping them. The tokens are still on the seat’s own counters and in the spend history; only the task’s share is gone, with the task.
A task lists its own turns. Every charged segment is a row the task keeps
for good — in the replicated estate, beside the counters it added to — so the
task’s page lists its turns long after the thirty days a node keeps its own
event history, on any node, including turns that ran on a node that has since
left. The list is work_item_turns: one entry per TURN, its segments folded
(tokens, rounds and wall time summed; the phases in the order each first ran;
the outcome, summary and review of its newest segment), newest first, paged by
the position of each turn’s first segment so a turn that gains a segment while
you read never moves between pages. Each entry is numbered “Turn n” by the
task: the position of its counted segment among every counted segment on the
task, so the newest turn’s number IS spend_turns and a card reading “Turn 3”
sits beside a cost reading “3 turns”. A turn whose segments on this task
counted none — more of a turn charged elsewhere — carries no number.
What a turn did rides on its record. Beside its spend, a segment’s record
carries the turn’s own account of what it did (the reviewer’s summary of what
landed, or the executor’s artifact), the notes of the newest review that sent
the work back for another pass, and each tool its executor called with how
many times — each account at most 600 bytes and at most sixteen tools, bounds
the writer refuses to exceed. An account longer than that is condensed for
the card by the seat’s auxiliary model and marked (condensed), never cut; where
no rewrite can be had the card says how long the account is and to open the
turn for it — and, for a segment that failed because one of its phases did,
which phase that was. The arguments and results of every call stay on the
turn’s trace, which a task’s turn card links to.
The counters are spend_turns, spend_rounds, spend_input, spend_output,
spend_cache_read, spend_cache_write, spend_wall_ms, spend_tokens
(input plus output — the sort=spend_tokens key, named after the column
like every total), spend_workers and
spend_sent_back. Every one can be totalled over a board — including as a
median or p90, which answer the value a task actually holds rather than an
average of two.
The catalogue
Section titled “The catalogue”Two declarations, and they are the company’s own vocabulary: what a task may be, and what it may carry.
Types. Seven ship with the engine — task, bug, epic, story,
spike, chore and milestone — and every company has them before it declares anything, which is what
lets a fresh company file its first task in its first minute. A declared
catalogue adds to them; a declaration sharing a builtin’s slug renames
it, so a company can call a bug a defect without losing the tasks already filed
under bug.
A type is refused at the create if the company does not declare it. That is
what stops Bug, bugfix and BUG from filing three types beside bug that
every board then groups and filters on as if they were real. An archived
type takes no new work and leaves the tasks already under it alone — which is
the whole reason a type is archived rather than deleted.
Fields. Custom fields are declared at the workspace and on a project, and a task’s effective set is the union. A value is keyed by the field’s id, not its slug, which is what lets a field move between the two keeping every stored value.
A field’s values are filterable. Each one is a row keyed on the field’s id,
in the column its declared type says — a number in the numeric column, an
instant in the date one, a choice’s id in the reference one — which is what
makes f.effort=gt:9 a numeric comparison rather than a lexical one, and what
keeps every task that chose an option when somebody renames it. A multi-valued
field is one row per member, so f.areas=api is a seek rather than a scan.
A required field is required of the tasks it applies to. applies_to
names the types that carry a field, and a field that does not apply to a task
cannot be missing from it — its value would be hidden the moment it was set. A
field required at the workspace is required in every project, and one
required on a project only there; a subtask is judged by the second toggle
(required_in_subtasks, off by default) so one required field does not block
every small piece of work somebody splits off a task.
A name is a resolution key, not a label. A type, a field and an option are
each resolvable three ways — by id, by slug, and by name — which is what
lets a model write severity: High after reading “High” off a board. So two
declarations whose names differ only in case or spacing are refused: they are
two rows one lookup cannot tell apart, and the resolution would pick whichever
was read first. A type’s name is checked against the shipped types too,
because a catalogue adds to them — though renaming a builtin by declaring its
own slug is exactly what the override is for.
A field’s configuration is checked against its type. A precision on a checkbox, a time flag on a number, a rollup on a text field: each is a setting that would be stored, replicated and read by nothing, so the declaration is refused naming which types use it. A minimum above its maximum is refused at the declaration rather than at every write that then fails against it.
These are the settings, and which types read each. They live under config on
the declaration — where the catalogue read hands them back, and where the
catalogue tools take them:
| Setting | Type | The types that read it | What it does |
|---|---|---|---|
options | list | dropdown, labels, relationship | The choices. A value stores the option’s id, so renaming one keeps every task that chose it |
unit | string | number, progress, rollup | Rendered inline beside every value — 8 h — so at most 16 bytes |
precision | integer, 0–6 | number, progress, rollup | How many decimal places a value may carry. Default 0, whole numbers only. A value carrying more is refused naming the rule, never rounded |
min / max | number | number, progress, rollup | The range a value must fall in. Each is optional on its own, and min: 0 is a floor — leaving it out is what means “no floor”. A minimum above its maximum is refused |
time | boolean | date | True holds a time of day as well as a day, and then a bare date is refused rather than given an invented midnight. False truncates a timestamp to its date and says so |
progress | manual | progress | How the bar is filled. auto, and the tracking list it would count, are refused — see below |
multi | boolean | dropdown | Lets one task carry more than one value, each its own filterable row. labels, people and relationship already hold several, so they need no flag |
Beside them on the declaration itself, applies_to names the type slugs
that carry the field; empty means every type. A task of a type a field does not
apply to cannot hold a value for it, and cannot be required to.
A field records who declared it. created_by and created_at are the
write’s, never the document’s — the same two columns a tag carries — so a
later edit of a field keeps the name of whoever added it. That matters because
these writes are whole post-states: a declaration that could carry its own
provenance would let the next person to touch the list re-attribute somebody
else’s field, and nothing downstream could tell.
Three settings are refused because nothing fills them. A rollup: block,
progress: auto and a tracking: list all say a value keeps itself up to
date, and this build computes none of it — a rollup is a correlated aggregate
over a relation, automatic progress is a per-task count of subtasks, checklist
items or asked comments, and tracking is what that count would read. All of
it is read-time work nothing does yet. Accepted, the field would hold whatever
somebody last typed under a name saying otherwise, which is worse than a plain
number because nobody knows to maintain it. tracking is refused on a
progress field too, not only on the types that have no progress at all:
auto is the only mode that would ever count the sources, so a list beside
progress: manual is counted by nothing. The field TYPES still work: a
progress or rollup field with progress: manual (or no configuration at
all) is a number somebody writes, and every filter on this page applies to
it.
An option is one value however it is written. A choice field stores the option’s id, and a write naming the option by its slug or its name resolves to that id before it is stored — the same three spellings a filter accepts. A value that stored the word instead would be invisible to every filter, grouping and total on the field it had just set.
What a field value may be
Section titled “What a field value may be”Every value is checked against its own declaration at the write, and refused naming the rule. The check is at the write because that is where it can be refused: by the time a record is applied the value is durable, and the applier deliberately salvages — a value it cannot decode writes no row and the change carries on, because refusing it would let one malformed field stop that task’s every later edit on every node.
| Type | Accepts | Refuses |
|---|---|---|
number, progress, rollup | a number, or a string that is one | text that is not a number, and anything outside min/max or carrying more decimals than precision |
checkbox | true or false | a string — every rule for reading one disagrees about "false" |
date | a date; a timestamp when the field holds no time, truncated with a warning to the day it falls on on the company’s clock (timezone) — the evening of the 4th in Los Angeles is the 4th, not the UTC 5th | a value that is not a date, and a bare date on a field that holds a time |
dropdown, labels | the option’s slug, name or id — stored as the id | an option the field does not declare |
relationship | a key, a former key or an id — stored as the id | an item that does not exist |
people | anything that resolves to exactly one colleague | a spelling that names nobody, or more than one |
url | an absolute url | one with no scheme or no host |
email | a parseable address — the address, not the display name | anything Ana <[email protected]> cannot be read as |
text, textarea | text within the type’s byte cap | anything longer, named rather than cut |
null clears any field. Nothing is rounded to fit: a field declared exact
to two places refuses 3.14159 rather than storing 3.14, because the stored
value would be a number nobody typed under a declaration that says the field is
exact.
The one change the engine makes for you is the date truncation, and it says so
in the result’s warnings — a value the engine altered is one the writer has
to be told about, or the board shows something they did not write.
Required fields are enforced at every write, not only at the create: an update may not clear one. ClickUp enforces them at creation only; this is the difference, and there is no toggle.
Archiving a field is one-way. Its values leave the filterable set and stay on the task, so a field that came back under its old id would silently re-admit them against a definition nobody has seen for a year. Bringing one back means declaring a new field — a new id, and the same slug, because the slug is the word the company uses.
Read it at GET /work/catalogue, or with get_work_catalogue, which every
seat holds: a model that cannot read the catalogue can only guess at a type.
Writing it is an operator gesture — write_work_catalogue — because a seat
adding a type to make its own create succeed is a seat editing the rules it is
judged by, and the refusal it was working around is the signal a person needs
to see.
A write is the read, edited. The fields list REPLACES the declared set,
and every declaration in it is whole: a key left out is cleared, config and
its options included. So read the catalogue, change what you mean to change,
and send it back — a field composed from scratch loses whatever it is not
carrying. That is the same rule the list itself follows, and it is why the
settings are spelled here exactly as the read spells them: the object you are
handed is the object you send.
A project’s own field declarations are the project lead’s, written with
write_project(fields: [...]). That list REPLACES the project’s declarations
and leaves the workspace’s alone — the two are separate scopes and a task’s
effective set is their union — so send the whole set, and read
describe_project first. A project declaration sharing a workspace field’s id
shadows it, which describe_project names so a reader can see which
definition is in force.
Tags are the one catalogue any seat may add to, and they live per project — a tag is how work is grouped for a week, and a company whose tags could only be declared by a person would be a company whose tags were never declared.
A tag has a slug and a label. The slug is what every task’s row holds
and what a filter compares, so it is normalised on the way in — Regression,
regression and needs design arrive as regression and needs-design — and
it never changes. The label is what a person reads, and renaming a tag moves
only that.
A tag is declared before it is used. A write naming a label the project does
not have is refused, listing the ones it does and the nearest match, because
the alternative is what happened to the task-type catalogue before its own
check existed: any string a model invented became a type, and Bug, bugfix
and BUG came to sit beside bug on every board. There are two ways past the
refusal, and both are deliberate rather than automatic:
write_project(tags_add: [...])declares one, which any seat may do.labels_create_missing: trueoncreate_work_itemorupdate_work_itemdeclares what that write is about to use, in one append before the task’s own — and only once every other argument has been checked, so a write refused over its ask, a blocker or a re-route declares nothing. The answer lists what it created underlabels_created, so a caller that set the flag out of habit still sees a typo now rather than on a board three weeks later. A declaration refused, or one whose outcome is unknown, stops the write before the task’s own append, and the answer says so under the tool that was called: the item was not filed, or the change not made, by that call. The same call made again — a seat’s with the same arguments, your assistant’s with theop_idthe answer carried — answers the declaration and then makes the write, once. Where the node’s ledger cannot vouch for the declaration, that repeat stops at the same step until the declaration reaches the node, so the answer says to declare the tags withwrite_projectfirst, which is harmless if they already landed. The repeat after it skips the declaration — but the write itself dates from the same instant, so the node cannot vouch for that either: it answers with the item an earlier attempt filed, or with the change where the node still holds its record, and otherwise answersunknownagain. That secondunknownmeans the write may exist where this node cannot see it: look for the item (list_work_items), or read it (get_work_item), rather than making it any other way — or, from your assistant, make the same call with itsop_idthrough another node, whose ledger may reach back that far.
A slug within a typo of an existing one is accepted with a warning naming
the nearest three — advisory, never a refusal, because a lead can merge two
tags and a refusal with no override would block apis behind api for ever.
A label that collides with another tag’s label or slug, case-insensitively,
is refused: two tags a person cannot tell apart split the work between them
at random.
Renaming and archiving are the lead’s. A rename changes the word on every task already filed under the tag, and an archive takes a filter off everybody’s board — both are decisions about how the company groups its work rather than about one task. An archive is one-way, like a field’s: the tasks keep the tag and every filter on it still answers, and what the archive buys is a tag that takes no new work. Bringing one back means declaring it again under its own name.
A task carries at most 40 tags, and a project declares at most 512.
A view is a saved query with a shape. Five shapes:
-
list— rows, sorted and grouped, which is what you want when the question is “what is there”. -
board— columns by status (or by status group, priority, when the work is due, or any field), which is what you want when the question is “what is moving”. A board isgroup_by=, and what comes back is columns: each one’s count is over the whole set, never over the rows it carries, so a column of four hundred says four hundred and hands you twenty. Loading one further isgroup=<value>, which narrows the whole query — including its totals — and the column holding everything nobody filled in is loaded the same way, by naminggroupand leaving it empty. On a closed axis — status, status group, priority and the due bands — every column the query admits is drawn, empty ones included: a board is the shape of the process, so a young company’s open work is three lanes with one card rather than one lane, and a Done lane appears only when finished work was asked for. An open axis — an assignee, a tag — draws only the values present. -
calendar— by date, which is what you want when the question is “what is due”. Its axis IS theduekey, so the grid’s own window spends the one key the grammar has for it: the fetch is bounded to the days on screen, the Overdue chip is not offered (pressed, it could narrow nothing at all), and the toolbar’s count says what it counted — “6 items due in this window” where every other shape under the same filters says “17 items”. -
timeline— bars down a date axis, which is what you want when the question is “how does this lay out”. It is the one shape that can show a task spanning time rather than sitting on a day, and the only one that draws the dependencies between two tasks as a line from one to the other. -
table— one field per column, which is what you want when the question is about a field rather than about a task: “which of these is the biggest”, “who holds the overdue ones”, “what is unestimated”. A list draws each task as a compact row you read one at a time, so comparing one field down it means finding the same badge at a different place on every row; a table puts every value of a column at one place and sorts at its head. The sort it writes is the query’s ownsort=, so it orders the whole set rather than the page that happens to be loaded.The list and the table are the same grid drawn with two column sets. Both sort at their heads, both draw the bands a grouping puts them in, both become one labelled card per row on a phone — the difference is which columns are on, the order they are in, and how a value is drawn (the list’s priority is the mark that opens its row, the table’s is the word in a sortable column). Display → Columns offers the active set’s own choices on either, and the choice is remembered per shape, so arranging the table does not rearrange the list.
Every container has six views without anybody saving one: one per shape,
plus trash — a table carrying removed=true and show_closed=true. The
trash is a builtin view rather than a sixth shape because what makes it the
trash is that parameter, not a way of drawing: a renderer keyed on the shape
would have a shape whose meaning depended on a parameter it could be saved
without. The builtins carry no id, so they are defaults rather than
destinations — a client that offers the shapes as an arrangement (the
dashboard does) shows only what somebody actually saved in its view strip.
Recent work, a task’s place, and a card’s facts
Section titled “Recent work, a task’s place, and a card’s facts”Three keys exist for the screens that draw a board rather than for the question it asks:
closed_since=<date>is the open work plus whatever finished — done or cancelled — at or after a date. It takes every date token thedue=filters take (sow,som,-7d,2031-04-14, a timestamp) and resolves it on the company’s clock, soclosed_since=sowbegins at Monday midnight in the company’s zone — the same weekdue=range:sow..eowand thedue:bucketbands mean — and a Done lane built on it empties itself when that week ends rather than dropping one task an hour. It is the dashboard’s Recent scope. It is the third way to say which finished work is in an answer, besideshow_closed=trueandshow_closed=recent:<duration>, so naming it besideshow_closedis refused, and anany=branch may not carry it.around=<task>asks where one task — its key, a former key or its id — sits in this answer, and the answer carriesaround: {position, prev, next, total_hint}: its 1-based place, the keys of the tasks drawn before and after it, and how long the order is. The order is the drawing order, over the whole answer rather than the page: a list’s own sort, or a board read column by column — and lane by lane inside a column — in the order the board draws them, with each column’s rows in the row order. So the task after the last card in To do is the first card in In progress, even when each column only carried twenty rows. On a label board, where one task is on several columns, the order counts cards and a task is placed at the first column it is on. A task the answer does not hold — filtered out, finished, or in a column the board did not draw — isaround: null, never a refusal, andpositionis null past the 10 000 the total stops counting at. A saved view cannot carryaround: which task somebody is standing on is theirs.fields=tags,dependents_count,open_asks,spendputs facts a board CARD draws on each row, and only on an answer that asked: its labels; how many live tasks wait on it (the authoredwaiting_onedgesblocking=reads); how many of its questions are still unanswered (the same testhas_open_asks=makes); andspend: {tokens, turns, workers, sent_back, reopens}. A fact that was asked for is always present,0and[]included. They are opt-in because the same row is what an agent reads throughlist_work_items, where every byte is context on every listing — andlist_work_itemsnever asks for them, even through a saved view that does.sort=-spend_tokenswithfields=spendis the most expensive work.
A priority list is paged in its own order. priorities=<person> answers
the tasks on somebody’s list in the order they arranged, and a page smaller
than the list now carries on in that order: the whole list (at most 32 tasks)
is read on every page and the cursor is a place in it.
What a board groups on
Section titled “What a board groups on”Any of these, as group_by= — and a second one as group_by2=, which splits
each column into swimlanes:
| Axis | Columns |
|---|---|
status · status_group · priority | The closed sets, in the order they mean rather than by size |
assignee · type · tag · project · unit · routing_unit · parent | A column per distinct value, biggest first |
f.<slug> | A custom field’s own values |
due:day · due:week · start:week | The calendar day or week a date falls in, headed 2031-04-16 or 2031-W16 |
due:bucket | When the work is due, relative to today |
Every axis draws the absent value as its own labelled column — “nobody is assigned” is a question a board answers, not a row it hides.
unit and routing_unit group on the team, not on the string. A unit
answers to two spellings and a filed unit is a record of what was true when it
was written, so a team given an id partway through its life has work stored
under its name and work stored under its id. It is still one column, headed
with the team’s current name and counting all of it. Loading that column
further takes either spelling — group=eng and group=Engineering reach the
same one. A stored unit the chart no longer has keeps its own column under the
literal the items hold, marked as a team that has left the chart.
due:bucket is the one that reads a calendar rather than a column. Its six
bands are the question somebody opens their own work to ask:
| Band | What is in it |
|---|---|
| Overdue | Still open, and past its due date |
| Earlier | Past its due date and finished |
| Today | Due at any hour of today |
| This week | Due after today, through the end of this week |
| Later | Due after that |
| No due date | Nobody set one |
Two of those need a word. Earlier exists because “overdue” means open and
past its date: work somebody delivered late is past its date and is not
overdue, and filing it under Overdue would claim they still owe it. It is
empty on the open-only answer a list gives you by default, because nothing
there can be in it — it appears when you ask for finished work as well.
And This week ends where the week does, Monday-anchored, which is the same
week due=range:sow..eow means: on a Sunday it holds nothing, rather than
rolling seven days forward into next week.
The day these are cut on is the company’s own midnight, on the company’s
clock — the
top-level timezone, UTC when absent — the same instant a row’s overdue mark is
derived from, the same one every due= filter is resolved against, and the same
one a person’s own day and the workload’s overdue counts are cut on, whether a
seat, an operator’s assistant or the dashboard asked. That is the whole reason
the bands are the engine’s rather than each screen’s: cut in a browser they were
cut on that reader’s midnight and that reader’s week, so for anybody whose
local day differs from the company’s, a task sat under Earlier on a row the same
answer marked as due today and not overdue.
“Midnight” means the first moment of the company’s date, and in a few zones that is not 00:00. Chile, Cuba, Lebanon and Egypt start summer time by moving the clock from 00:00 straight to 01:00, so on that day the day — and “today”, and a task’s all-day due date — begins at 01:00; where a zone sets its clock back from 01:00 to 00:00, as Cuba does, midnight happens twice and the day begins at the first. The week is the ISO week, Monday to Monday.
The timeline
Section titled “The timeline”A task’s start and due are its bar. A task carrying only a due is a
milestone — a filled diamond on its day, in its status’s tone — and one
carrying only a start is a one-day marker with a dashed edge, because “due
on the 20th”, “began on the 20th” and “a day’s work on the 20th” are different
facts and drawing them the same way would claim knowledge nobody entered. A task carrying neither sits in an
Unscheduled band under the axis with a count — never placed on today,
which would invent a deadline nobody set.
Its window is derived from the rows it is drawing, padded to a fortnight so a
single task has something to be read against and capped at a year and a
fortnight so one task due in 2031 cannot compress a fortnight into four pixels.
Grouping applies (group_by=), and each band gets its own axis: a team
planning six months out does not squeeze every other team into the corner of
its window.
Dependencies are drawn as arrows from the blocker to the task that waits. An arrow is dashed when the edge is one-sided — the blocker does not list the waiting task back, which the repair duty is still working on. An edge whose blocker is not on the axis, because a filter excluded it or because it carries no dates, is counted beneath the chart rather than dropped silently: a reader who cannot see the omission reads the arrows as every dependency there is.
It is read-only. There is no dragging and no resizing: what a drag would mean for a task with a dependency, owned by somebody else, is a question with no good answer, and the tracker’s own edit surfaces already say those things properly. A bar is a link.
Views belong to a container — the workspace, a project, a unit, a person —
written the query grammar’s own way (workspace, project:ENG,
unit:engineering, person:ana). A container is an address, so each is
stored the way the engine keys it: a project key upper-cased, and a unit under
its id where it has one — a team’s strip
is one strip whichever of its two spellings you ask for it by. A personal view
is private to its owner. One per container can be the
default, and the applier settles that in the same transaction as the write, so
two views can never both claim it. A protected view cannot be edited by anyone
but its owner, which is what stops a shared board being rearranged under
everybody.
Saving a view and pinning one are both operator gestures, so both are
recorded under the token that made them. If your token is bound to a seat, the
strip is still yours: viewer= matches your seat handle or that token id,
so your own views and your own pins are on the strip you ask for under your
seat’s name. Naming nobody is the shared strip, which is what the sidebar
and the board ask for before anybody is known.
Six of them exist without anybody saving one. Every container has a list, a board, a calendar, a timeline, a table and a trash, and none of the six is an object: a fresh project needs no setup gesture, a container can never be left without a way to look at it, and nothing has to guard against somebody deleting the last view.
The trash is a query, not a shape. It is a table carrying removed=true
and show_closed=true, which is why it is a builtin view rather than a sixth
rendering: what makes a listing the trash is the parameter, so any view you
save with removed=true is one too and is read the same way. show_closed
travels with it because a removed task is very often a finished one, and
without it the one tab whose job is “what did my assistant delete” would hide
every deletion of anything already done. It is ordered by when work was
removed, newest first, and sort=removed asks for the other end of it — a
removed task’s rank is its position on a board it has left, so ordering the
trash by rank orders it by a stale number. What a removal, a deletion and a
purge each mean is below.
A view is a set of defaults, never a lock. Opening one loads its parameters and every key you then set overrides them, so picking a different assignee on a saved board gives you that board with one key changed. A preset is the same mechanism for a question people ask often enough that a screen puts it on a tab — and a view beats a preset, because somebody saved the view. There are five:
| Preset | What it answers |
|---|---|
my_queue | what can I pick up — a disjunction of the work you hold and the work in your own project nobody holds, open and unblocked, most important first. Both arms matter: written as “assigned to me” alone, a seat with an empty queue reads the company as having nothing for it while its project’s unclaimed backlog sits there |
priorities | your own ordered list, open tasks only, in the order somebody arranged it. That order is the answer — it is what was decided — so there is no sort to override it, and a finished task drops out of the answer without the list being rewritten |
triage | the open work nobody has picked up. With one fixed status set there is no intake status to filter on, so this is the honest definition of “needs somebody to decide” |
blocked | open work that cannot move — the list a lead reads before a stand-up |
overdue | open work past its due date. Defined on the status groups rather than on show_closed, so a task delivered yesterday is not reported late for ever |
my_queue and priorities both need to know who is asking, and the surface
supplies that from its own credential — never the query. That is also what
makes f.<slug>=me mean the reader rather than whoever saved the view: me
is resolved after the view’s saved parameters and yours are merged, so a view
saved with f.reviewers=me shows each person who opens it their own reviews.
Filtering a custom field
Section titled “Filtering a custom field”f.<slug>=<value> compares a field, and the comparison a field admits is a
property of its type:
| Type | Operators |
|---|---|
text, url, email | eq ne contains startswith in |
textarea | contains startswith |
number, progress | eq ne lt lte gt gte range |
date | eq lt lte gt gte range |
dropdown | eq ne any not_any |
labels | any all not_any not_all |
checkbox | eq |
relationship | any all not_any |
people | any all not_any me |
rollup | lt gt range, applied after the rows are read |
null and not_null are on every type, because “is this set” is a question
about the row rather than about the value. An operator a type does not
admit is refused naming the ones it does — the failure that replaces is
silent: eq on a labels field produced a clause that matched nothing, and a
board that came back empty reads as “no task has this label”.
A bare f.areas=api is the type’s natural comparison — any on a set,
because naming a value is not claiming the set is it, and eq everywhere
else. Written explicitly, f.<slug>=<op>:<value>; a set operator takes a
comma-separated list (any:api,ui) and range takes both ends
(range:3..8), because a range with one end is gte or lte. A value whose
own text begins <scheme>:// is a value rather than an operator call, so a
url field can be filtered by what it holds.
A saved view’s query is parsed when it is saved, not when it is opened. A
view that cannot be run is otherwise discovered by whoever opens it, weeks
later, with no way to tell a typo from a grammar change — so a save runs the
parameters through the same grammar list_work_items uses and refuses what
does not parse, naming the key.
A view’s parameters are the query grammar’s own, which is what
list_work_items compiles its arguments into rather than what those arguments
are called. Four are spelled differently: project is container and takes
project:ENG or workspace, text is q, label is tag, and open_only
is status_group=not_started,active. Everything else is the same word, and a
custom field is f.<ref>. A view may not carry view, cursor,
read_level, max_lag_seconds, max_lag_seq or min_position at all: those
are about the caller’s own read — where it resumes and how fresh it must be —
rather than about the rows.
A saved view is run by its id, passed as view to list_work_items or as
?view= on the REST route. Anything else the caller passes overrides the
view’s own, so a seat can open somebody’s board and narrow it without editing
it.
Views are read at GET /work/views?container=project:ENG, and written through
the operator MCP surface with save_work_view — not by a seat. A view is
furniture, and a seat’s job is the work rather than the furniture around it.
Manual order
Section titled “Manual order”A board is drag-ordered, and the order is a real value on the task rather than
a position in a list. A person drags a card with place_work_item — through the
dashboard or their own assistant; no seat holds it, because where a card sits
is a person’s arrangement and a seat moves work between lanes with
update_work_item. The call names the card it was dropped beside
(before or after), never a position: the engine mints the new key inside
its own write, between that card and the one next to it as the board stands
when the move lands. So two people dragging in one project at once both land
where they dropped, rather than between two keys that no longer bound anything.
The key is part of the task from then on — the task’s next edit keeps it, and
reading the task back returns it.
A drag within a lane writes one record, on the project’s order, and wakes
nobody — where a card sits says nothing about what the work is. It never
changes the card’s version, so the board’s next if_match on it still holds.
A drag across lanes also changes the card’s status, and that half is an
ordinary status change: history, and a wake for the people on the card — and
it is written first, as its own record, before the place. It is refused
stale_version before anything lands if the card changed since the board was
drawn. If somebody changes the card between the two halves, the new lane stands
and the answer says placed: false, naming why in unplaced: the card is in
the lane it was dropped into, at the place it already held there, and can be
dragged again.
A drag is retried as the same call. Each half is a step of the call’s one
operation, so a retry is answered from the ledger step by step, the lane change
the first attempt already made included. That is why a drag always sends the
lane it was dropped into, whatever the card reads now: a drop into the lane
the card is already in, on a card nobody changed since, writes nothing on the
task, so sending it is always safe. A drag is never a move to another project —
that re-keys what it carries, and is move_work_item.
On the dashboard a drag is made as the person your token is bound to, and
only in the manual order — a project’s default, or Sort → Manual order —
because in any other a dropped card would jump straight back to where its date
or priority puts it; a drag tried in another order says so. The card is drawn where you dropped it until the engine
answers: a refusal puts it back and says why, and a placed: false answer
leaves it in the lane it went to and says the place was not taken. Alt with
an arrow key moves a focused card the same way — up and down past its
neighbours of the same project, left and right to the top of the next lane.
Repeated insertion at the same point makes keys grow, and a drop that would mint a key past 64 characters re-spreads the cards around it in the same record instead — widening the window until the keys are short again, up to 256 cards. Past that the drag still lands and the engine re-spreads the project in the background, in batches, order-preserving. Every intermediate state is the original order, so an interrupted re-spread leaves a correct board. A card dragged to the very bottom never takes the key the project’s next new task will be filed at, so the two can never share a place.
What a seat can do
Section titled “What a seat can do”Eighteen tools, and they are deliberately few — nine that act on a task, three that read the container it is filed into, one that writes the one part of that container a seat owns, one about CHANGE rather than about state, and four over the project’s files:
| Tool | What it does |
|---|---|
list_work_items | the query surface above, filtered any way a view can be — and every row filtered on its own, whatever a named view’s own shape says: the grammar’s collapsed default makes the filter a predicate on the ROOT and lets its whole subtree ride along unfiltered, which draws a board and misreports a list. An item’s subtasks are asked for with parent, which that mode never applied to — including preset=my_queue, which is the seat’s own open work. Beside the obvious filters it takes type, priority, parent (an item’s subtasks), reporter, watcher, unit, the three date keys (due, updated, created), sort, cursor for the next page, and field_filters keyed by field slug — which is how a seat reaches the custom fields its company declares |
get_work_item | one task with its recent comments, history, links and custom fields. include narrows to the parts you need; comments_cursor pages back through a long thread; comment answers one comment by id and nothing else. Nothing in the answer is cut: every comment on a thread page is whole — a page holds as many as fit 16 KiB, at least one, and comments_cursor continues exactly where it stopped — and the description is whole too. The description is one of the include parts (body, comments, history, links, fields; all five by default), so that each part on its own always fits one answer: a read that names other parts says the description was left out and how large it is, rather than handing over an item that reads as having none. Each field value comes back with the slug, name and type that explain it, and says when it is hidden (its declaration was archived), foreign (mirrored in from another tracker) or undeclared (a value this company explains nowhere) |
create_work_item | file a task or a subtask. It lands in todo unless status names another — a board lane’s + files into that lane in the one record, and an unknown status is refused by name. Left without an assignee it goes to the project’s default assignee (the lead’s write_project setting, read inside the create itself), and lands in triage — where the project’s lead is told — only when the project names none, or names a seat that has since left the chart, which the answer’s warnings say; the answer’s assignee is who it was filed to. fields sets custom fields by slug, and the create is refused naming any the project requires and this call leaves out. It also takes the four scheduling arguments below, and ask (with an optional decision) to file the item as a question — see Asking for a decision |
update_work_item | change any field, with an optional if_match. routing_unit points the item at another team and is the project lead’s or a person’s own — see Which team an item belongs to. watch: true/false is a gesture about the CALLER and nobody else — the engine resolves it against the item’s current watchers inside its own transaction, so following a task never removes whoever was already following it. Its waiting_on, blocking, linked and linked_pages arguments are set-valued — see below — and fields sets custom fields by slug, checked against each field’s own declaration. It takes the four scheduling arguments too, where null on any of them CLEARS it. An assignee may carry a reason — one line, at most 500 characters, that the new assignee is woken with and the item’s history shows beside the hand-off; a reason without an assignee is refused, because the explanation of any other change is a comment. checklist makes one change to the item’s checklists — see Checklists |
create_work_item and update_work_item | both take fields, keyed by field slug — see “What a field value may be” above |
comment_on_work_item | add to the thread, optionally as a question somebody owes an answer to (ask, with a decision when they have to choose) or as the answer that closes one (answers, with a choice naming an option — body is then optional) |
search_work_items | find an item by what it says — ranked over every item’s title and description, which no filter reaches. list_work_items’ own text is a substring of the key or title and cannot see a description at all, so the two are different questions: one narrows a board, the other ranks a corpus. It ranks hybrid — the words and, where the company has an embeddings provider, the meaning, fused (see Search). Its text is at most 400 bytes, the bound every search shares, and a longer one is refused naming the limit rather than cut. A node still building its index says so rather than answering empty, because “there is nothing” is what gets a duplicate filed, and an answer over part of the fleet carries a partial sentence for the same reason |
merge_work_item | fold a duplicate into the item that survives: the duplicate is linked to it, its subtasks are re-parented onto it (move_subtasks, true unless you say otherwise), and the duplicate is closed as cancelled. Nothing is destroyed and both histories stay readable. Closing a duplicate by hand instead leaves its subtasks under a closed parent, where nobody finds them. A subtask in the trash stays under the duplicate, where a restore finds it — a removed item is frozen. A merge that stops part-way (its node died, or the stream refused an append) is finished by the tracker duty on its first pass (every 15 minutes) after the merge’s claim has lapsed — a minute after its holder’s last heartbeat — so never beside a merge that is still running; a duplicate that is itself in the trash waits for its restore. A merge that would re-parent subtasks onto an item in another project is refused before it starts — a subtask under an item in another project is drawn under it on neither board — so move the duplicate first with move_work_item (its subtasks go with it), or merge with move_subtasks: false |
move_work_item | move a top-level item, with everything under it, to another project: each task in the subtree is re-keyed there (ENG-7 becomes OPS-3) with its old key still resolving, the tags the subtree carries are declared in the new project (a label the new project already gives a different tag refuses the move, naming both, before anything is written), and the people on the item are told. The item’s project lead’s or a person’s own, as routing_unit is. A subtask does not move on its own — move its root. A move that stops part-way is finished by the same call, or by the tracker duty (see how a move is carried out) |
get_work_catalogue | the types a task may be and the fields it may carry |
list_projects | every project work is filed into, with how much work each holds — task_counts is {todo, active, done, closed}, so waiting work and started work are two numbers — when its work last changed and who changed it, its target_date, and who leads it. archived picks the set — false (the default) for the live ones, only for the retired ones alone, true for both — and sort orders the whole company before the page is taken (key, name, unit, todo, active, done, closed, last_change, target, each with an optional leading -), so -todo is where the pile actually is rather than the biggest of the fifty keys that sort first. A seat’s answer carries 50 and says total beside truncated — narrow with q or unit — because a tool answer is read out of the turn’s own context window |
describe_project | one project in full: the six statuses with what each means, the types it files, the fields grouped by which type they apply to (required first, with their options), its tags and its lead. Omitting the project means the seat’s own |
write_project | a project’s own settings. Declaring a tag is open to every seat; renaming or archiving one, declaring project fields, setting the default assignee and setting the target date are the project lead’s or a person’s own; archiving the project takes a person specifically |
task_activity | what HAPPENED, in the order the log made it happen: every change to one task or one project, with who made it and exactly which fields moved |
my_work | everything this seat is expected to look at, in one call — see below |
list_project_files | a project’s files in path order, a page at a time; folder narrows to the paths under one |
read_project_file | one file’s text, at most 48 KiB from offset with next_offset to continue; a file that is not text is described unless encoding: base64 asks for its bytes |
write_project_file | put a file at a path — created, or its content replaced — with if_version to refuse the write if somebody changed it since you read it |
remove_project_file | take a file out of a project; its content is deleted from storage |
When a task is due and how big it is
Section titled “When a task is due and how big it is”Both write tools take four scheduling arguments, and every one of them is the input to something the tracker already reports:
| Argument | What it sets |
|---|---|
due | when the task is due. A date (2031-04-16), an instant, or one of the relative words the due= FILTER reads — today, tomorrow, eow (the week’s end — midnight ending Sunday, since a week starts on Monday), eom (midnight ending the month), or an offset like +7d (refused if it lands outside the years 0000–9999, which is all a date can be written in). One grammar for both, because a seat that can ask for “everything due this week” must be able to say “due this week” about one task. A token that named a DAY sets the all-day flag, so a renderer shows “16 April” rather than “16 April, 00:00” for a date nobody gave a time to. |
start | when work on it should start, in the same spellings. |
estimate_minutes | how long it is expected to take. |
points | how big it is on the team’s own scale. |
estimate_minutes and points are the two halves of a size, both carried
rather than one chosen: which one a team uses is a team’s own habit, and the
totals (totals=points:sum, totals=estimate_min:sum) and the workload
answer both.
On an update, passing null clears the value: “leave the due date alone”
and “this task has no due date any more” are different edits, and a tool that
could only express the first would make a date impossible to take back off. On
a create there is nothing to clear, so null is ignored.
A value this grammar cannot read is refused naming the argument, never
dropped. Silently ignoring a due date is the worst of the three outcomes: the
write succeeds, the answer says applied, and the task simply has no due date
— which a model reads as having set one.
my_work is the call a turn opens with, and it answers seven lists rather
than one: the seat’s priorities in the order somebody put them, the work it
holds, the questions waiting on its answer (each with the literal call that
answers it), the checklist items it claimed on other people’s tasks, the
work it was brought onto without owning, what moved on what it follows, and
what just became workable. Each is a different claim on the reader’s
attention, and a seat that saw only its assignments would miss six of them.
One call rather than seven because assembled separately a seat could see a
task in assigned that had already moved out of it by the time priorities
was read — and spend its turn on work somebody else had taken. It takes no
handle: the seat is the turn’s own, because a tool that named whose day to
read could read a colleague’s queue.
Each list is a page of at most 20, and beside the seven lists the answer
carries totals — one {total, capped} per list, counted by the same
predicate in the same read. The page is twenty; the claim is not, and a count
taken from a list’s length says 20 for the seat holding two hundred. capped
means the count stopped at 10,000, so the number is a floor. priorities is
cut after finished and removed entries are dropped, so a list whose head is
done work still shows its first twenty open entries in the order they were put.
task_activity is the only way to ask what CHANGED. A board is about what is
there now, and a task that was reassigned twice and back looks exactly like
one nobody touched. Its order is the log’s, not a clock’s, so its since
and its cursor are log positions — which is what lets a cursor span a reanchor
with no gap and no repeat. Its q needs either a task, or a project and a
since inside 90 days: an unscoped text search reads every change the company
has ever made, and it has no cheaper mode to fall back to.
Every change carries its own before and after. A row says what KIND of
change it was (status, view_saved, project_updated, …) and, in fields,
which values moved and from what to what — {"status": {"from": "todo", "to": "in_progress"}} — and, for a set (the watchers, a kind of link, the tags,
a catalogue’s declarations), which members joined and which left:
{"watchers": {"from": "", "to": "", "added": ["ada"], "removed": ["bo"]}},
each list sorted and every member whole. That holds for every kind, not only the ones about a task: a
project reconciled from the org chart names the purpose or the unit that moved,
a saved view names the query parameters that changed, a re-ordered priority
list carries the order before and after, and a dependency names the item it
now waits on, or no longer does. It holds for quiet changes too — most
project, view, catalogue and tag edits wake nobody, and the row still says
what they did.
What a task’s own row can carry, whatever kind it was filed under — one commit may move several of these, and the row names each one it moved:
| Field | What it says |
|---|---|
title status assignee priority project type tags | the columns a board is read by |
due due_all_day start estimate points | the schedule and the sizing. The flag is recorded beside the instant because an all-day date is stored as the company’s own midnight, so making a midnight due date all-day moves the flag and leaves the instant where it was |
reporter watchers muted collaborators | who filed it, who follows it, who opted out of hearing, who is doing it with you — the last three as the handles that joined and left |
parent routing_unit archived removed_with | where it sits, whose lead hears about it, whether it is filed away, and — when a removal cascaded — the item it went with |
waiting_on linked duplicates page blocking | the four kinds of link it authors, and the mirror a blocker carries — each as the items that joined and left it |
checklists | one entry per named list, as Setup: 3 of 5 done, plus (1 promoted) where an item became a sub-item. A list that arrived is on the to side alone and one that was deleted on the from side alone |
fields | the custom values that moved, by slug, as severity=high — a choice by its option’s slug, a multi-valued field’s members joined with /, a value past 150 bytes as its size (severity=4096 bytes), and a count of any whose field this project no longer declares |
body | that the description changed and how big it now is (980 bytes → 1204 bytes, or — → 1204 bytes written and 980 bytes → — cleared) — never the prose |
Three things the values are deliberately not. They are the stored form — a status slug, a whole timestamp, an item’s id — rather than what a screen shows, because the same row is written identically by every node in a fleet and a rendering would depend on the reader’s time zone and on the company’s current vocabulary; the dashboard resolves them. Nothing is cut: a row is a line in a log rather than a copy of the object, so a set records only what joined and left it — small even when the set is not, where carrying both sides once cut the member a commit added off the end of each — an ordered list (a priority queue, the checklists) is carried whole because its order is the change, a list whose members a person does not read — an inbox, say — is recorded as its size, and free text past 600 bytes (a project’s purpose, say) is described by its size rather than quoted in part. And the two largest things a task holds are marked rather than carried: a description can be 32 KiB and a checklist tree 256 items, so the row says that they moved and by how much, and the change record it stores beside them holds the rest. A row records every field a change moved; only a notification card stops at 32, and says how many more there were.
A notification card carries all of that but the custom fields. The card is built at the write, from the item as it stood and as it will stand; naming a custom field takes the project’s catalogue, which only the node applying the change has open. So a wake for a custom-field edit says a field was edited and the history row says which one and what it became. Everything else — a watcher added, a checklist ticked off, a description rewritten — reads the same on both.
And the woken seat reads them. A change wake’s prompt carries a What
changed block: one field: from → to line per delta — field: added …; removed … for a set — in the engine’s own field names, with an em dash for a
side that was empty, a closing line counting any fields the card had no room
for, and the change’s own
excerpt under it where there is one. Without it a wake named the kind and
nothing else — “The status changed by ana.” — and the seat had to read the
task to learn what the status now was, which still left the value it moved
from unrecoverable, because that side is nowhere on the task. A comment is
the exception: its body is the comment, and the opener has already quoted it —
whole. The card a change leaves in an inbox carries a 600-byte excerpt,
marked where it was cut, because an inbox is a list read to choose what to
open; the wake is the one message a seat acts on, so it is given the comment
(or a new task’s description) whole from the change record itself, and never
the card’s excerpt of it. An excerpt somebody stated — a hand-off’s reason, a
purge’s line — is shown as the text it is.
A dependency is where the stored form would otherwise show, and so are a
re-parent and a cascade removal: each records the other item by its id,
because its key belongs to that item’s own row and a history row is written
once and corrected by nothing. So the answer carries a keys map naming
the items its deltas point at — every link a task authors except page, which
names a knowledge-base page rather than an item, plus the blocking mirror, a
person’s priority queue, and the parent and removed_with scalars — resolved
when the question is asked rather than when the change was made. That is what
lets a screen draw “Waiting on: added ENG-2”, or “Parent: — → ENG-2”, from a row
that stored a uuid. An item this node has not applied is simply missing from
the map, and a reader falls back to the id.
The three project reads are a seat’s for the same reason the catalogue read is: a create refuses a project the company does not have, a type it has not declared and a required field left empty, and a model that cannot read any of that can only guess. A refusal that names the valid values is only half an answer if there was no way to look them up first.
write_project is the one project write a seat holds, and it holds it for
one facet: declaring a tag. A project’s name, purpose and owning unit are
chart-owned — written by the epoch apply from the org chart and by nothing
else — so nothing writes them here at all; its field declarations, its
default assignee and its target date are the lead’s, and archiving the project
itself takes a person’s own credential. Every one of those is gated inside the
verb, and each refusal names who can.
The default assignee is who a task filed into the project with nobody named goes to — every create reads it inside its own snapshot, beside the archived flag and the required fields, so a person’s sheet, a seat’s tool and an operator’s assistant all land the same task in the same place. Empty is the project’s own setting for triage, where the lead is told. A default naming a seat the chart no longer holds is not applied: the task goes to triage and the create’s answer warns that the setting wants changing, since a task filed to a seat nobody runs would wake nobody.
An archived project stays reachable, and it is SELECTED rather than let
through. Archiving stops a project taking new items and keeps every one it
already holds, so it is not what anybody means by “the projects” — and a
directory that hid half the company would not be a directory. Every reader of
the listing therefore names one of three sets: the live projects, the retired
ones alone, or both (archived=false|only|true on the API, list_projects’
archived argument for a seat, and the Active / Archived / All segment on
the dashboard’s Projects screen, which is what those three spell). The middle
one is the one a two-valued flag could not express: a reader who wanted the
retired projects had to ask for both sets and narrow what came back, which is
a filter over a PAGE — the answer stops at 200 rows — so on a company with
more live projects than that, the page held no archived row at all and the
screen reported a company that had retired dozens as having archived nothing.
The listing’s total counts whichever set was asked for, never a wider one.
Selecting a set leaves one question a caller cannot answer from the rows it
got back — an empty answer says the set is empty, never whether the
company is — so the listing also carries a census: active and
archived, counted under the same q and unit as the rows and without the
archival term. That is what lets a screen tell “no projects yet” from “every
project archived” without asking twice, and it is why the Projects directory
can say a company has filed nothing while sitting on its Active segment, and
can tell a reader whose company wound a programme down exactly how many
projects are waiting under Archived.
An operator holds the same fourteen and more that no seat does, including
place_work_item — see Manual order — and the two below. remove_work_item puts an item
in the trash and restore_work_item takes it out again, at any age. A
removal hides an item from every list and board and destroys nothing — its
history is untouched and list_work_items with removed: true is the only
thing that shows it. No seat holds either, because a seat that could hide work
it did not want to do would be marking its own homework in the one way that
leaves no trace: the board simply has one fewer item on it. Removing an item
with subtree: true takes its children with it, and each child’s tombstone
names the removal that took it — so restoring the parent brings back exactly
what that gesture removed, and never a child that was already in the trash for
its own reasons.
Both are one commit per item, parent first, so a subtree gesture can stop part
of the way through — a step whose outcome is unknown, or the stream refusing an
append. It then stops there rather than carrying on over it, and the answer
says so: the item you named is removed (or restored), with
subtree_followed and subtree_total counting how many of the items that go
with it followed, and subtree_stopped saying why and what finishes it. What
finishes it is the same call again — a task already where the gesture leaves it
is nothing to do, so a second removal takes what the first did not reach, and a
second restore brings back whatever is still in the trash with a parent that
is already back. A restore of an item that is not in the trash and has nothing
in the trash with it says there is nothing to restore.
Neither is purge, which destroys every row on every node and has no inverse.
That one is crewlet work purge, with a typed confirmation and a required
reason, and it is deliberately not a tool at all.
Five of those count as a delivery: create, update, comment, merge and move. A turn woken by an assignment answers by moving the task, commenting on it, or filing the follow-up — and the delivery gate knows that, so such a turn is not corrected and looped for “having done nothing”. Reading is not delivering, which is exactly the turn the gate exists to catch.
Seat tools read at the linearizable level: a seat that files a task and
then lists its project sees the task it just filed, because every read
establishes the log’s end before answering. Every write’s answer also carries
the position its record landed at, which a client outside the engine — the
dashboard, an operator’s assistant — hands back as min_position to read the
write back at a cheaper level. See Read consistency.
Optimistic concurrency
Section titled “Optimistic concurrency”update_work_item takes an optional if_match carrying the version the caller
read. Without it, the last write wins — which is right for a field an agent is
setting from its own work. With it, a concurrent edit is refused and the
refusal carries the current version, so the caller can re-read and decide.
The body is different: a save must state the version it edited, always. There is no per-field merge that makes overwriting prose safe.
Watching is not a field a caller sets either. The watcher list is a set, and a
tool that could write it whole would have to know every name already on it —
so watch: true says only “add me” and the engine resolves it against the
task’s current watchers in the same transaction that writes them. An item with
more than 64 watchers refuses the next one: past that it is an announcement
rather than something people follow, and a comment on it wakes the company.
Commenting makes you a watcher, and never fails because of it. The commenter’s watch is the same one-handle gesture, resolved against the current row — a comment does not write the watcher set whole, which would silently discard somebody who watched or unwatched while the comment was being written. And at the cap the comment lands and the watch is skipped: an explicit sixty-fifth watch is refused, because the person asked for it and can be told; an automatic one is not, because refusing it would fail somebody’s comment for a reason that has nothing to do with what they wrote.
Leaving is never refused, whatever the set holds. Only joining is capped — the check is about growth, and applying it to an unwatch would leave a task that had somehow grown past the cap as one nobody could leave.
Who is carrying how much
Section titled “Who is carrying how much”The workload answers, for everybody at once, what they are holding. It counts open work — every task assigned to them, whatever its dates — because “who is carrying the most” is a question about a whole queue.
Both sizes are carried, never one chosen. Which of points and
estimate_min a team uses is that team’s own habit, so an answer that picked
one would be wrong for everybody sizing in the other.
Two things it will not do:
- It does not invent a ceiling. There is no capacity to be over, because there is no number that is “full”: a queue of thirty is heavy in one company and a quiet week in another. The dashboard’s bar is drawn against the heaviest queue on screen, which is a comparison between people rather than against a target nobody set.
- It does not re-rank. The engine answers heaviest first and a screen keeps that order, because a second ordering would make two screens reading one answer disagree about who is at the top.
Beside the totals it carries the three shapes of work that is not simply in progress — blocked, overdue and unscheduled — because a person whose whole queue is blocked has a different problem from one who is simply busy, and a total alone cannot tell them apart.
GET /work/workload, optionally narrowed to one unit — the people whose open
work sits in projects that unit owns.
How the work has moved
Section titled “How the work has moved”The flow (GET /work/flow, work_flow) is the company’s work as a series:
for each of the last N days or weeks on the company’s clock, how many tasks sat
in each status group at the window’s end, and how many were completed in it
— a change that took a task from not delivered to delivered, so a cancellation
is not a completion and moving done work to closed is not a second one. It is
computed by walking the task history BACKWARD from today’s census, undoing each
status change, create, removal, restore, purge and project move, so what it
costs is the window’s changes rather than the company’s whole history. Home’s
“Tasks in progress”, “Completed” and “Tasks completed per day” all read it.
Blocked has no history. Whether a task is blocked depends on its blockers’
rows, and nothing records the moment it became so; the answer says
blocked_history: false and carries today’s blocked and overdue counts only.
What the company did
Section titled “What the company did”The company feed (GET /feed, company_feed) is one feed of what the
company did, newest first: work completed (with the task’s tokens and
turns, and whether a reviewer ever sent it back), work filed, work handed
on (the task’s hand-off count against its budget), pages published and
schedules run. A create says where it was filed from — its origin, the chat
surface and conversation the filing turn was woken on (“from Slack”) — which
the create record states for itself. One cursor resumes every source exactly
where the last page stopped.
A schedule’s entries are its runs — the ticks the scheduler dispatched. A
tick it deliberately skipped (a missed run outside the catch-up window, or one
that came due while its seat was paused) is not something the company did, so
the feed leaves it out; it stays in the schedule’s own ledger
(GET /schedules/{scope_type}/{scope_id}/{name}/runs, Agents › Schedules),
which is where “why did the standup not run” is answered. And runs of one schedule for one runner that nothing else
falls between are one row (“ran 12 times since 02:40”), so a sweep every
ten minutes is a line rather than every line of the page.
What a person can do
Section titled “What a person can do”The dashboard renders the same queries a seat’s tools use, against this node’s own copy, and every answer says how far behind that copy is. It is four screens rather than one:
-
Tasks (
#/work) is the list, and it opens as one: a container nobody has saved a default view for lands on the list shape, because a board’s information is the comparison across its lanes — the best shape once work is moving and the worst on a company with three items in one status. The board is one press away. The first row is what is drawn: the five shapes (List, Board, Timeline, Calendar, Table), then the views you pinned — the strip holds what somebody SAVED, never the shapes — + View, which saves the query on screen under a name (shared, or kept to you), and a link to the whole inventory; then the search box (/focuses it) and Display, which holds what you set once: a list’s second grouping, the columns of the list and the table (one grid with a column set each), and the board lanes you put away. The second row is how the answer is cut, and the work starts right under it: a removable chip per narrowing, ending in + Filter — priority by is, is not or ≥ a step, labels by any of, all of or not — and at the far end Show — Open, Recent (open work and what finished this week, the board’s own default, so its Done lane holds the week’s deliveries), Closed and All — Group by and Sort, each showing what it is set to, and on a board the lanes past its edge. Grouped on a status, a status group or a priority, the board and the list draw every value the company declares and say which of them are empty, because those three are closed sets whose order means something; grouped on an assignee, a tag or a label they draw only the values work is actually in. The trash is a filter here rather than a tab, which is what the engine says it is: a listing carryingremoved=true. A list with nothing on it says which of three things emptied it — a narrowing that matched nothing, a scope with nothing in it, or a tracker nothing has been filed into — and a complete one closes by saying so; one with more ends in Load more and how many of how many are loaded, so no task is past the end of a page. A card says what it has cost in tokens, what it blocks, its labels and — while a seat’s turn is on it — who is doing what (“SWE · executing · round 7 of 20”), matched on the task the engine charges that turn to. A card’s labels give way to a “+N” before its mark row wraps, so the token count keeps its place.What you change here is made as you. Drag a card — in the manual order, which is a project’s default — to reorder it or move it to another lane (
Altwith an arrow does the same from the keyboard); set a row’s status, priority or holder from the value itself on the list and the table. Each is a tool call under your name, conditional on the version you were looking at, so a change somebody made a moment ago is refused and the sentence names them. A task opened from a list carries that list with it, and its page says where it sits — “3 of 18”, with the tasks above and below it a press (orkandj) away — in the whole list rather than in the page that was loaded, whether you opened it from its row or through the peek’s Open. -
A task (
#/work/{KEY}) is one page: the task in the order a person reads one — its title and description, its checklists, its sub-tasks — then its activity, and a rail of its fields beside it. The activity is All, Comments, Agent turns or Changes: who filed it and who handed it to whom (with the reason they gave), what was said, and a card for every agent turn charged to it — “SWE ran turn 2”, the phases it ran (and, for a turn that failed, which phase it failed in), whether a reviewer sent it back and what the reviewer asked for, the tools it called, how long it took and how many tokens, and a link to its trace. While a seat’s turn is running on the task, the foot of the activity says so (“SWE is on turn 3 · executing · round 7 of 25”) with a way to watch it live. Each announced change has Who this reached: the people it woke and why. The three histories are read a page at a time, and Earlier activity reads further back — the merged list only ever shows a stretch of time that is complete in all three. The rail ends with what this task has cost — turns, tokens and agent time, from the task’s own counters — and its hand-offs against the engine’s budget.Everything on it is changed as you: a field from its value in the rail (status, priority, labels, dates, the estimate, a custom field), the title and description in place, a checklist item by its box, a sub-task from the sub-tasks’ +, a hand-off with its reason, following it with Watch, and a comment — or, with Ask…, a question put to one seat, with the options to choose between where they are to choose. A field edit is conditional on the version you were looking at, and a race you lost names who won it; a checklist tick and following are gestures applied to the task as it is when they land, so they never lose a race that was not one. A reader who cannot act sees every control, disabled with the sentence that says why.
-
New task — on the sidebar’s head, beside the Projects heading, at the end of every work screen’s bar and on a board lane — opens one sheet that files a whole task in one
create_work_item: its project, title and description, type, the status it starts in, who holds it, its priority, due date and labels. A lane’s + files into that lane: it presets the lane’s status, type, priority, project, holder or label (a status-group lane, the group’s first status), and a lane no one value files into — a due band other than No due date, a unit, and the Cancelled and Closed lanes nobody files new work into — has no +. Only what you set is sent, so a field you leave alone is the engine’s default rather than the sheet’s guess of it; left without an assignee, the sheet says where the task lands (the project’s default assignee, else triage for its lead). The Assignee field completes against the org chart, and when the engine’s own colleague match names exactly one seat that seat leads the list as the best match — why it matched (“part of the name matches”) is said under the field once you take it. A name typed there and not chosen from the list holds Create task until you choose the seat or clear it, so it is never dropped from the task. Every write form sends one press at a time: Enter or ⌘Enter while the first answer is still out sends nothing more, so a task, a comment or a sub-task is never filed twice by a second key. The sheet opens the new task once the engine has applied it, and a refusal — a label the project does not declare, a field it requires — is said in the sheet, naming the argument. A title past 256 bytes or a description past 32 KiB is refused rather than cut, so the sheet counts both in bytes as the engine does, marks the field that is over and holds Create task with the reason written beside it. The engine’s own refusal of a text past its cap names the argument and the sizes (`title` is 600 bytes and a task's title holds at most 256) — for a model and for a person alike, and without quoting the id minted for a task that was never filed. -
Projects (
#/work/projects) is the directory: every project with its lead, the unit that owns it, its maintained counts, how far along its work is — one bar split into done, active and still to do, over exactly those counts — its lead’s target date, and when that work last changed. A row opens the project beside the list rather than leaving it, and that panel’sOpen ↗is the way to the project’s own page (⌘-click or middle-click goes straight there). The sentence over the grid is the company’s own total, and when the engine answered fewer projects than the company has it says so rather than quoting the page as the company. -
History (
#/work/history) is the change log over a window you choose, narrowed from one bar — the window, then Kind, By and Project pickers whose options are what the pages loaded hold, with how many of each (said once: the counts are over the changes loaded) — and it says “Showing the latest N” beside Load older. A config activation re-declaring the org chart’s projects is engine bookkeeping, not somebody’s change: a run of them is one quiet line (“Org chart re-applied to 3 projects”, by the engine), and the chart epoch they move is never printed. The window bounds what the engine is asked for and a page bounds what one ask answers, so the two are different limits: Load older changes fetches the next page back rather than asking you to move the window, and the pages you have loaded are held still while you page through them — change the window or a facet to pick up what has landed since. A project facet narrows to one project’s changes, which is the same narrowing a project’s own History lens is. -
A project (
#/work/{KEY}) opens on its work — the Items lens, which says how many are open, is the page’s first content, with the lenses on the page bar beside the project’s name — with an About lens for the container itself (who leads it, the unit that owns it, its target, its census and what it declares: its statuses, its types, its labels and its fields) and a History lens narrowed to it. About’s first line is the project’s purpose, which is thepurposeof the unit that declared itsprojectkey; a project declared on a seat rather than on a unit has none, so the line says which unit owns it instead. A project nothing has been filed into says so and offers New task, rather than showing an empty list. Edit project sets or clears the target date (write_project{target_date}) — the project’s lead may, and so may a person acting as themselves; a seat that does not lead the project is refused. -
Search (
#/work/search) ranks the company’s work against a phrase — Hybrid (the default: words and meaning, each ranked and then fused), Keyword (the words, BM25) or Meaning (semantic: what the text is about); each mode’s tooltip says what it matches. A company with no embeddings provider asking Hybrid is served Keyword, and the screen — and the command palette’s mode pill — says so in the same words (“Asked for Hybrid, served Keyword — …”). -
Saved views (
#/work/views) is the inventory of what somebody saved, in every project and team as well as company-wide — each row says where the view lives, and each has a Pin. A view you pinned is in your sidebar, and that row runs it; the view running on a board can be pinned or unpinned from its own strip too.
Your own AI assistant can reach the same tracker over MCP, at
/operator/mcp. It serves the same work tools above, eleven more no seat is
given — list_work_views, save_work_view,
write_work_catalogue, get_person, work_inbox, mark_inbox, set_pins,
set_priorities, remove_work_item, restore_work_item and
place_work_item
— the five page tools beside them and knowledge
search — the seat’s own implementations, with one
field different: a write carries the token’s own name as its author and the
author kind operator. There is deliberately no way for the caller to name a seat to act
as — a tracker whose author field is chosen by the writer is not an audit
trail.
A token the chart binds to a human seat acts as that person wherever a
tool asks who the caller is rather than who wrote: which project a create
with no project files into, whose day my_work and list_work_items
answer for, whose watch a watch: true records, and which projects it may
re-route work out of. See
A person’s own state.
The dashboard writes through the same tools, at /operator/act/{tool} —
one tool per request, as the person your token is bound to. It admits a bound
token and nobody else: an unbound token and a disabled guard’s caller are
refused unbound, and keep the MCP surface. The record is the same either way,
so a change you make on screen and one your assistant makes read alike in
every history. See the
API reference.
The REST API serves the read side at /work, /work/{id} and
/work/views. Writes go through a seat’s tools or the operator MCP, both of
which are attributed to somebody.
A handle is checked before it is stored
Section titled “A handle is checked before it is stored”Every argument that names a colleague — a task’s assignee, a project’s default assignee, whose priority list is being written — is resolved against the company’s own roster before the write, and a name nobody has is refused, listing the seats.
That is not tidiness. An unknown handle fails silently and permanently: it is
stored, it rides the change’s routing snapshot, it becomes a candidate — and
the wake path drops it against the live roster with no error and no log. The
write answers applied, and the person it named never hears anything. A
misspelling is indistinguishable from a colleague who is simply quiet.
A name is resolved, not merely checked — so a caller that typed a role’s name rather than its handle gets the handle back rather than a refusal.
Dependencies, and the set-valued arguments
Section titled “Dependencies, and the set-valued arguments”A dependency is the one relation with two ends. waiting_on is authored on
the task that is blocked; the blocker carries the dependent’s id, so closing it
can say who it unblocks without scanning every task in the company.
Both ends are written, and in that order: the blocker’s row is read first, so a
blocker that is gone, tombstoned or already at its 64 dependents refuses the
edge before anything is published. Then the authored edge lands, then the
mirror. The mirror is best effort — the edge is already durable without it
— so a mirror that lost its race leaves a one-sided edge, and the tracker
duty writes the missing commit 30 seconds later. That repair is the only wake
the blocker’s side gets: the authored commit routes to the dependent’s
watchers, so “the wake went out with the other commit” was never true.
When the mirror can never be written — the blocker is gone, was removed since,
or is full — the edge is stamped permanently one-sided and never retried.
Both states are in the attention queue: flag=one_sided is the repair still
pending, flag=one_sided_final the one a person has to resolve. The flag
filter takes any number of values and matches a task carrying any of them.
The duty logs under component=tracker: tracker_one_sided_repaired with how
many edges a pass settled (edges), split into those it mirrored (mirrored)
and those it stamped permanently one-sided (final);
tracker_one_sided_final for each edge it stamped, with the reason; and
tracker_one_sided_repair_failed (a warning) for an edge whose repair failed
and is retried on the next pass.
Every row says what it waits on. A listed task carries blocked — one bit,
“something is holding this up” — and waiting_on, the same edges carrying
which task, whether that blocker is still open, and whether the edge is
one_sided or one_sided_final. The two are computed from one set of rows in
one statement, so blocked is exactly “some entry in waiting_on is open” and
a screen can never show a blocked badge beside no dependencies. A blocker your
own filter excluded is an id you hold no row for — the honest answer, since the
edge exists and that page cannot draw it. Cleared edges stay on the row rather
than disappearing when the blocker finishes: what a plan looked like once it
was executed is the thing a timeline is for.
waiting_on, blocking, linked and linked_pages on update_work_item
take one of two explicit shapes and never a bare list:
{"waiting_on": {"add": ["ENG-7"], "remove": ["ENG-2"]}}{"waiting_on": {"set": ["ENG-7", "ENG-9"]}}A bare list is refused naming both, because the two readings of it are
opposite: as a delta it adds one edge, and as a set it silently drops every
edge not repeated. create_work_item keeps a plain waiting_on list — a new
item has no dependencies to replace.
A question on a task
Section titled “A question on a task”A comment can carry an ask: a colleague’s handle, meaning this comment is a
question that person owes an answer to. They are woken asking for one,
rather than told about activity, and they start following the item. An ask does
not hand the item over and does not block a close — a question nobody
has answered is not a reason to hold delivered work open.
The answer closes it. answers names the question’s comment id, and is
inferred when exactly one open question on the item is addressed to the
caller; with several it is required, and the refusal lists them. Answering
wakes the person who asked — not the person who just replied, which is what
routing off the answering comment’s author would have done.
A question has one answer. An answers naming a question that already has
one is refused already_answered, naming who answered it and when — the move
is to read that answer. The check is made in the write’s own snapshot rather
than only in the read before it, so of two answers sent at once exactly one
lands; the other is refused rather than posted beside it claiming to answer a
question it did not close.
my_work reads both sides: asked_of_me is the questions waiting on this
seat, and the has_open_asks filter finds the items carrying any. The board
asks the other two ways round: asked_of= is whose answer an item’s open ask
is waiting on, and asked_by= is whose question it is — and for your own
handle asked_by also matches the token bound to you, because the asks you put
through your own assistant are authored by the token. Each row
carries answer_with, the literal call that answers it, and open.
Asking for a decision
Section titled “Asking for a decision”A question that needs somebody to choose carries a decision on the ask:
the question in one sentence, two to six options, the option the asker
recommends and why, the evidence it looked at, and the role the person is asked
in. The answer names the option it chose — choice, by the option’s id — and
may say why in its body.
| Part | Rule |
|---|---|
question | required, at most 300 bytes — one sentence on a card; the context goes in the comment’s body |
options | 2 to 6, each {id, label, detail?}. An id is 1–32 of a-z 0-9 _ - because it is typed back in choice; a label is at most 80 bytes and a detail 500. One option is an approval — ask yes or no |
recommended | optional; one of the option ids |
rationale | optional, at most 1,500 bytes |
evidence | at most 8, each {kind, ref, label?}: task (by key or id, stored as the task’s id, since a key moves), page (by id or CONTAINER/Title; it must exist and not be in the trash, and is stored as its id, since a rename moves the title — a company with no native knowledge base cites a page as a url), turn, run, or url (an absolute https:// address) |
role | approver — the answer is the decision — or contributor — it is an input to one somebody else makes |
inform | optional {surface, channel}: the chat channel the asker will report the outcome in, and a promise the engine keeps (see below). Only an agent seat may ask with one — a person has no turn to hold to it, so it is refused forbidden for an operator and invalid at the tracker for any author that is not an agent. surface is mattermost or slack, and must be one the company runs and the asking seat holds a bot on; channel must be one a unit in the org chart declares (units[].channel). A leading # is accepted and the chart’s own spelling is stored |
What the engine enforces is deliberately small: that a decision is well formed,
that it rides only the comment that asks (a remark cannot carry one, and it
is never changed after — an answer names an option by id, and options edited
under it would change what that answer meant), and that a choice names an
option of the ask it answers, checked against that ask’s own row. There is
no approval chain, quorum or state machine: a decision is still one question to
one person, answered once, and what happens next is the asker’s turn.
The asker is woken with the choice by its label — the card reads
Chose “Ship Friday”: <the body> — and the ask’s row in asked_of_me carries
the decision, with the recommendation already filled in as the choice of its
answer_with call, so a reader who agrees sends it as written.
Asking. comment_on_work_item takes ask and decision together — a
decision without ask is refused, since options put to nobody wake nobody —
and a comment either asks a decision or answers one, never both.
create_work_item takes the same two to file an item as a question: the
title is the question, the body its context, and the task and the ask on it
land in one record, so a crash can never leave an item with nobody asked on
it. Whoever a question asks starts following the item, on either tool —
unless they muted it, which is the one gesture that says they chose not to; the
question still reaches them under asked. An edit of the question does not
re-watch anybody.
Answering. comment_on_work_item with answers and choice (the option’s
id) answers it, and body is then optional — the choice is the answer, and
the body its reason. Leave choice out to answer in prose when none of the
options is right. A choice with no question to answer — nothing named, and no
open ask on the item addressed to you — is refused.
The wakes. The person asked is woken with the options listed by id, the
recommendation marked, the evidence, and the answering call written out whole —
item, the ask’s comment id and a choice — so a seat edits one value and sends
it rather than reading the thread to find the ids. The asker is woken with the
question, the option chosen by its label, and — when the decision named an
inform channel — where it said it would report the outcome.
An inform is enforced. The asker’s answered wake owes the chat
surface its decision named: the notification carries owes (slack or
mattermost), and the turn it wakes is held to a delivery on that surface
rather than on the tracker the wake came from. A round that only comments on
the item is sent back with a correction naming the surface, and the turn ends
once a tool there has posted — because the person who answered was told the
outcome would be posted, and a note on the item is not that. Only the asker’s
copy owes it; a watcher woken by the same answer owes nothing. An answer that
owes a surface is its own inbox partition of the item’s conversation (the
conversation key is unchanged), so two answers promising two different
surfaces are never merged into one turn that could keep only one of them. If
the seat has since lost every tool on that surface, there is nothing it could
post with and the obligation falls back to any delivery, like every other
surface the seat cannot reach.
A comment from somebody who is not the assignee, naming nobody and asking
nobody, still wakes the assignee — unaddressed, which a turn may absorb without
replying. The result says so in a warnings line, because a commenter
expecting an answer otherwise gets silence with nothing to explain it.
What waits on a person is one question (GET /work/decisions,
decisions): the open asks put to any of their identities — their seat and the
token bound to it — beside the coding runs parked on a question to them, with
how many there are in all and when the longest one began. The dashboard’s Home
answers them in place: each option of a structured ask is a button that sends
the choice as the answer, and a parked run is answered by its turn.
What an answer may weigh
Section titled “What an answer may weigh”A tool answer is read by a model: every byte lands in a context window beside the system prompt, the conversation and whatever the turn has already accumulated. So one answer is capped at 64 KiB — about a quarter of the smallest context the shipped models offer, spent on a single call.
What keeps answers under it is that every collection which grows is paged:
- Comments come back newest page first, every one whole: a page holds
as many as fit 16 KiB (at most twenty, at least one), and
comments_cursorreads the page before. They used to come back twenty at a time with each body cut to 2 KiB — and a review cut there was a review whose “but” never arrived. A long thread is now more pages, never shorter comments. - The description comes back whole, and is a part of
include(body) like the collections beside it. A description and one comment are each capped at 32 KiB, so together they are a full answer before anything else is said; with the description a part of its own, every part asked for alone fits. It used to come back cut to 4 KiB, with a separate argument to get the rest. - Open asks in
my_workcome back whole until the block holds 16 KiB of them, the first always; the ones after that carry their decision, their author and the call that answers them, plusbody_not_includednaming the body’s size and theget_work_itemread that returns it — never a body cut to fit. - History is the fifty most recent changes.
work_activityis what pages properly, with a cursor that survives a reanchor. - Options page by whole fields: a field whose list does not fit comes back
with none of its options rather than some, because half a list is worse
than none — a reader would choose from it and believe it was the set.
options_totalbesideoptions_shownis what says otherwise.
When an answer still exceeds the ceiling — a task with a long body, a full thread and sixty-four links — it is refused naming what would narrow it, rather than truncated. A truncated JSON answer is not an answer: a model handed half an object either fails to parse it or reads the half it got as the whole.
What wakes a seat
Section titled “What wakes a seat”A change that concerns somebody becomes a wake — a turn on that seat, with a prompt written for the reason it reached them. Most wakes are about a task: you were assigned it, mentioned on it, watching it, blocked by it. One is not, and it exists because the thing that moved is not a row on a board:
One wake per person per change, whatever number of reasons name them: the first reason in the precedence order wins and the rest are dropped. Somebody mentioned on a task they are watching hears that they were mentioned.
And nothing wakes you for your own write — under either of your names. The
person who made a change is dropped from its own wake: being told what you just
did is a turn spent on nothing. The comparison is against both identities a
person can write under, which matters for exactly one of them — a write you
make through your own token is authored by the token, while the watch it
leaves on the item is your seat’s, so a founder filing work through their
assistant would otherwise be woken by every item they filed and every comment
they left. The record carries the seat beside the author for that reader, and
the author field is untouched: an operator’s change is still authored by the
token, which is the audit trail. What a notice or a feed row draws is the
seat — actor_seat beside actor — so a founder’s own change reads as the
founder rather than as a credential’s id. It is stored on the history row
whenever the change carries one — every change written through an operator’s
token that a seat claims — and where a change carries none (an agent’s, an
unbound token’s or the system’s) a screen falls back to the author.
The single exception is unblocked, and it is the exception because it is
about a different task: closing a blocker is exactly the moment to be told
that your own other work became workable.
The order puts what this change did to you ahead of the role you hold. Being @-mentioned, being asked a question, having your question answered, and learning that somebody’s work now waits on yours all outrank being the assignee — because each says something the standing role does not, about this change. Below those come assignee, then reporter, then the following reasons — collaborator, watcher, and last the lead fallback, which reaches a lead only when the change named nobody else at all.
| Wake | Who hears it | What it asks |
|---|---|---|
prioritised | the person whose queue somebody else wrote | an answer: take it up, or say why you cannot |
A second is about a task and is listed apart because of what it says rather than what it is about:
| Wake | Who hears it | What it asks |
|---|---|---|
purged | the lead of the project the task was filed in | nothing — the task is gone from every node and nothing restores it |
purge_task is the one operation in this engine with no inverse, and for a
long time it told nobody: a task, its comments and its revisions were
destroyed on every node and the person accountable for that project heard
nothing. The wake names the key, who ran it and their
stated reason — and nothing else. It quotes neither the title nor the body,
because the record outlives the rows: an excerpt of what was purged would keep
a copy of exactly that, on the log, for its whole retention window.
prioritised addresses its recipient: being told what to do next by
somebody above you is an instruction, and silence on it is indistinguishable
from a message that was lost. It names the tool that answers its own question —
my_work — rather than pointing at the object it is written on, because a
person’s handle is not a task key and a pointer at one costs a round and a
failed tool call to discover. A purged wake sends you nowhere for the sharper
version of the same reason: the row is gone from every node, so the tool would
answer not_found.
A person’s own state
Section titled “A person’s own state”A human has a record of their own beside the work: an inbox, a queue and their pins. Three parts of one document, with three different authorities over them — which is why they are three separate writes.
| Part | Who may write it |
|---|---|
| inbox — read, unread, snoozed, and how far you have read | only on behalf of the person whose it is |
| pins — pinned views and starred things | the same |
| queue — the order you mean to work in | yours, or a lead’s for somebody in their line, a human’s, or an operator’s |
Somebody else marking your work read is the one thing an inbox must never allow: the item is then gone from the only place you would have looked for it, and nothing anywhere says who removed it. A pin is the same rule for the plainer reason — one somebody else can set is one that moves under you.
The queue is the exception, and deliberately: telling somebody what to do next is what a lead is for. A seat is the one party that may not write somebody else’s — an agent re-ordering a colleague’s list is a hand-off in disguise, and it bypasses the guarded take and the reassignment budget that a real hand-off goes through.
Somebody else’s write is stamped with who made it, so a person who starts the day on work they did not choose can see who chose it, and their own next change clears the stamp — taking your queue back is the gesture that says you have seen it. The stamp names the person: a lead reordering a report’s queue through their own token (from the dashboard, or their assistant) is stamped as their seat, never as the credential’s id, which is nobody the report knows — the history row that write leaves still names the token, because attribution is the audit trail’s question and “who decided this order” is the queue’s. The lead relation is any ancestor in the management chain, not just the direct manager: a founder leads everybody.
It also wakes the seat, and that is the other half of the same authority: a stamp is seen by somebody who opens a screen, and a seat has no screen. The wake names the task now at the top of the list and the person who put it there, and it is the one notification in this section that asks for an answer — take it up, or say why you cannot. Going silent on it looks exactly like a message that was lost.
A mark is one move, and it moves nothing else. mark_inbox takes what a
person actually says about their inbox, naming each notice by the record_id
work_inbox gave it:
| Argument | What it does |
|---|---|
read | Marks notices read — the inbox’s Done. A snooze on one is lifted, because a notice you have dealt with is not one to bring back. |
unread | Marks notices unread again, including one you have already read past. |
snooze | [{record_id, until}] — puts notices off until an instant: in the future, and at most a year away, because a snooze past the horizon is a delete that does not say so. |
unsnooze | Brings snoozed notices back now. |
read_through | Reads everything up to a log position, <stream>@<generation>:<sequence> — the newest notice you have seen. It only ever moves forward: a position at or behind the one you have read to changes nothing, so “mark all read” from yesterday’s tab cannot un-read what today’s read. |
primary_reasons | Which wake reasons are yours to act on — see below. Omitted, your choice stands; an empty list takes the default back. |
Everything you do not name stays exactly as it was — every other mark, every snooze, and how far you have read. The engine applies the move to your record as the write finds it, so two screens marking two notices at the same moment both land; it used to take all three lists and replace them, and one “mark this read” from a tab opened an hour earlier erased every mark made since. It also reads where each notice sits from the notice’s own record rather than asking you, which is why a mark names nothing but the notice.
In the dashboard’s Inbox these are the row’s own controls. Done marks
the notices the open row stands for read — an ask’s, when the row is a
decision — and nothing else. Snooze offers an hour, tomorrow at 09:00 and next
Monday at 09:00 on the company’s clock, or a moment you pick, and only the ones
inside the engine’s bound: the person answer serves max_snooze_ahead, so a
preset the write would refuse is never shown. Mark all read is read_through
at the newest notice the screen LOADED, so a notice that arrived after you
looked is still unread. A row that is not a notice — a parked coding run, a
stopped seat, a condition — has nothing to mark; it leaves the list when it is
answered or cleared, and its Done says so rather than disappearing.
Your read position is the rule, and the lists are the exceptions. Everything
at or below it reads as read and everything above it as unread, so your record
only keeps what differs: notices above it you marked read out of order, and
notices at or below it you marked unread again. Anything the position already
answers is dropped on the next write, which is what keeps the record small
without a cap that discards — and moving the position forward clears the
unread exceptions, because “I have read everything up to here” is what that
gesture says. A list that would pass 256 entries is refused inbox_full, naming
read_through: the lists hold only what the position does not answer, so a
full one is notices marked one at a time that one read-through would cover.
The position is a triple — stream, generation and sequence — because a number from a recreated stream compares as current, and an inbox that read “nothing unread” for ever is not a bug anybody reports. The generation is kept on the record rather than just the sequence, so an inbox read past a reanchor stays read past it.
Pins are moves too. set_pins takes views and favorites each as a
change — {add: [...]} and {remove: [...]} against what is there now, or
{set: [...]} for the whole list, never both — so starring a project from one
screen never drops a view pinned from another. The caps (32 pinned views, 64
starred things) are held against the list the move would leave.
A pin can carry its count. Asked for a strip with a viewer and
counts=true, every view pinned for that viewer carries count — how many
tasks the view selects when it is run: its saved parameters, in the container
it was saved in, with me as the viewer, on the company’s clock. It is the
same expansion and the same statement that answer the board’s own total for
that view, so the number beside a pin is the number on the board it opens —
and it counts every task on its own (subtasks=separate), which is how every
surface that runs a view runs it: the dashboard’s shapes and list_work_items
both overrule a view’s subtask mode to it. Counted in the grammar’s default,
where a root’s subtree rides along unfiltered, an open-work view counted the
finished subtasks of every open epic, and the pin said 39 over a list of 36. At most 32 counts, in the
strip’s one read. A view that no longer compiles — it filters on a field that
was since archived, say — carries count_refused naming why, rather than no
count at all.
A queue is the one list written whole, because an order is a statement
about every entry at once. set_priorities takes the whole list, and
if_match — the version get_person answered — makes it conditional: a
reorder made from a screen that read an older record is refused
stale_version rather than putting back an order somebody has since replaced.
The dashboard’s reorder is exactly this call: the list it sends is the
person’s stored one with the moved entry at its new place, so the entries
a screen does not draw — finished ones, and open ones past the twentieth —
keep theirs.
A due snooze is reported, never promoted. Putting one back in the unread list is a write, and a read that performed one would change the fleet’s state from a path with no operation id, no arbitration and no record. So a read says which snoozes are due and your next inbox write is what moves them.
The feed and the marks are two reads. work_inbox is what the company
asked of you: one entry per routed change, newest first, carrying the single
reason it reached you under, the subject, who made it, and an excerpt. A
change you made is never one of them — under either of your names, so the
work your assistant files on your own seat is not waiting for you in your own
inbox — except for the one reason that is news to its author: unblocked, a
blocker of yours that you finished yourself. It is
written by the applier when the change lands — so it is there whether or not
anybody was online, and a person who has marked nothing still has an inbox.
get_person is your own marks over that feed: what you have read, what you
snoozed, how far you have got. Neither is derived from the other, which is why
an inbox entry carries a record id and a position and no content at all.
What a page narrows, it narrows in the scan. work_inbox takes unread,
primary_only, reasons and snoozed — exclude by default, include to
keep what you put off, only for just that — and every one of them is a
predicate on the rows the engine reads, not a filter over the page it read.
So a page holds fifty notices whenever the scope does: a person whose newest
fifty notices are all snoozed sees the fifty after them, not an empty page with
a cursor behind it. A snooze whose time has come counts as awake under every
scope. Each notice also carries what it is about: the comment and the turn
the change came from, and — for an asked or answered notice — the ask
itself as it stands now, with its decision, whether it is still open, and who
answered it with which option.
The primary half is yours to declare. mark_inbox takes primary_reasons
— which wake reasons are yours to act on — and work_inbox labels every notice
with it, returning the rest as context rather than hiding it. Saying nothing
takes the shipped default: mention, asked, answered, assignee,
unassigned, reporter, unblocked and prioritised, which is every reason
that changes what you should do next. An empty list means you have not said,
never nothing is primary — the other reading gives a fresh company an inbox
whose primary half is blank.
A person reads theirs at #/inbox, one click from the landing screen:
the notices work_inbox returns, each labelled with the one reason of
eighteen that routed it, beside what is waiting on a decision. Which person is
decided by the API token — it is matched against every seat’s
contact.crewlet_operator_id, so the queue is theirs rather than the
alphabetically first seat’s — and #/me is that same person’s own work: one
section per claim on their attention (the Queue, Asked of me, Asked by me,
Unblocked, Collaborating, Watching, Checklist), each carrying the engine’s own
total on its tab so an unanswered question is visible without opening it. A
section shows at most twenty rows of its claim, and one that holds fewer than
its total says which twenty (“the 20 most recently changed of 130”). The
Queue is read by due date or in the order somebody put it
(order=due|priorities). Above the list a band says whose day is on screen,
links to that person’s seat, and carries the one thing here that asks to be
answered — a queue somebody else put in order, named by the person who did,
with a flag on the Priorities choice. See
Humans in the org for the binding.
Asked of me is answered where it stands. Each question put to the person
is the same row the landing screen’s “Needs your decision” draws — who asked,
the role they are asked in, what the asker recommends — with its options as
buttons that send the choice (comment_on_work_item{answers, choice}), or a
Reply for a question with no options. Asked by me is the other end: the
work this person asked a question on that is still waiting for its answer
(asked_by=, every task status, since an open question on finished work is
still one somebody is waiting on). It asks under both of the person’s names —
viewer= is the person themselves — so the questions they put through their
own assistant are there beside the ones they asked from the dashboard.
Somebody else’s day is read, not worked. An operator can open anybody’s day with the whose-day picker, and there every change on the page is held, disabled with a sentence saying whose day it is: the questions put to them are theirs to answer — the engine refuses anybody else — and their work is changed from the work screens. The one exception is the authority the tracker grants across people: somebody above them in the chart may reorder their queue, and the page says before the press that the reorder is stamped with the reader’s name and tells them what is now first.
The Queue is the work list, narrowed to one person. It is the same
screen #/work is — the five shapes, the Filter and Display menus, the
scope switch, Group by and Sort, the columns, the count line — with the assignee fixed
and every other choice yours and in the address. What is fixed is not a filter:
there is no chip to take off and no assignee= on the URL, because that is
what the section is rather than something you narrowed it to. Whose day it is
stays handle=.
It opens grouped by due:bucket, soonest first,
over unfinished work — “what have I missed, what is today, what is this week”
is the question somebody opens their own day to ask, and every task they hold
is in progress or about to be. All three are defaults: group it by status, sort
it by priority or look at the week as a board and the address keeps what you
chose.
Those bands are the engine’s, cut on the company’s own midnight rather than
your browser’s, so the band a task sits under and the overdue mark beside it
can never disagree. Six of them, and the two worth a word are Earlier —
work that was finished late, which is past its date and not overdue, empty
until you ask for finished work — and No due date, which is exactly what it
says: nobody set one. It is a band rather than an omission, because a task
nobody has scheduled is the one most likely to be forgotten; and it is the one
band no due= filter can reach, since every date comparison in this grammar is
written over a date that exists.
The Priorities reading is numbered, in the order it was stored. That order
is the content — it is what somebody decided — so nothing re-sorts it, and the
place is drawn rather than left for a reader to count. It is a list somebody
wrote, and it can hold a task a colleague is assigned as well as your own, so
it is not what the Queue’s tab counts: its first line says how many open tasks
it holds and how many of them are assigned to you, and the Queue’s count — the
same figure the sidebar’s My work row carries — is only the work assigned to
you. Drag a row to a new
place, or press Alt with the up or down arrow on a focused row, and the
dashboard sends set_priorities as you, conditional on the version of the
record it drew: the row is drawn at its new place with a pending mark until the
engine answers, and a refusal — somebody changed the list since you looked —
puts it back and says so. Nothing moves before the engine does.
Your own writes count as yours, under either name. A write you make
through your token is attributed to the token, with author kind
operator — never to your seat handle — because a tracker whose author field
is chosen by the writer is not an audit trail. So the item you file through
your assistant records founder as its reporter, while the watch that create
puts on it is your seat’s — a reporter is attribution and a watcher is an
address. Your colleagues assign work to jane-founder, and every personal
question matches both: every section of My work, the inbox, and your own
record. You are one party with two names, and the answer always comes back
under your seat’s.
Two consequences worth knowing:
- A change that concerned you under both names — assigned to your seat, reported by your credential — is one notice in your inbox, under the stronger of the two reasons. That is the same rule that already gives one handle one reason: being mentioned outranks watching, and you are told the fact you will act on.
- Your own record — your marks, pins and queue — is one document, and it is read from your seat’s if you have one and from your credential’s otherwise. It is never two merged: two priority lists joined is an order nobody chose.
An operator reading somebody else’s day gets that person’s two names, resolved from the org chart — never the credential in their own hand.
And your own marks and pins are written under your seat, not your token.
Whose state a record holds and who wrote it are two different questions, and
they have two different answers. mark_inbox, set_pins and set_priorities
write your record — the seat your token is bound to — while the history row
they leave still names the token with author kind operator. That is not
an inconsistency: attribution answers who did this, and it stays the
credential because a tracker whose author field is chosen by the writer is not
an audit trail. The record’s subject answers whose inbox is this, and the
answer there is the person.
Keyed on the credential instead, a bound founder grew a second record called
founder: the marks their assistant made were invisible on the screen that
asks under their seat, and their queue came back empty on the one tab that is
entirely about it. Leaving a token unbound is unchanged and ordinary — an
operator outside the org chart, a pipeline — and it writes its own record under
its own id. Records written before a company bound the token are still read,
because the seat’s record is preferred and the credential’s is the fallback.
Every other gesture about a person follows that same rule. The watch a
watch: true, a comment or a create leaves is your seat’s — a watcher is an
address, and the roster it is resolved against holds seats and not credentials,
so a token in the set is a colleague nobody can reach. The ask your comment
answers is the one addressed to your seat. The project your work is filed into
when you name none is your team’s. And the questions list_work_items asks
about you — preset=my_queue, preset=priorities — are asked under both of
your names, exactly as my_work is.
The marks are the PERSON’S, written as that person. Every write here is
attributed to somebody, and a write as “the dashboard” would be attributed to
nobody — so mark_inbox is called either by your assistant over
/operator/mcp, or through /operator/act, which admits only a token bound
to a seat and records the write exactly as your assistant’s would be. The
screen shows what the engine recorded.
And the same rows answer the other way round. work_inbox reads them by
recipient — one person, every change. work_routing reads them by record —
one change, every person — which is the question “did my comment reach the
person I meant”, and it has never been answerable anywhere else. The table’s
primary key is (record_id, recipient), so both directions are index reads and
neither costs the other anything.
Read it at GET /work/routing/{record_id}, or open an item’s Woke tab in
the dashboard. Every recipient names the one reason of eighteen that found them,
whether the notice asks something of them, and whether they were reached only
because nobody better was found — a lead who hears about a report’s task
because the report has left, with the rank saying which substitute they were.
An empty recipient list is three different facts, and the answer’s
delivery field says which. nobody means the routing genuinely resolved to
no one: every candidate was the person making the change, or has left the
company. swept means the change is older than the retention horizon below, so
the rows may have existed and been cleared — their absence is not evidence.
unknown means the caller stated no horizon, so nothing can date the absence.
And quiet means the commit carried no notification at all, which is most
field edits and every bulk one. The history row’s own notified flag cannot
tell these apart: it says the commit carried a notification, never that
anybody was woken — the applier deliberately holds no roster, because two nodes
briefly on different epochs would then write different rows for one record.
Inbox rows age out; the history does not. tracker.native.inbox_retention_days
(default 365, 30..3650) is how long an entry lives. A sweep on every node
deletes what is past the horizon — per node rather than once across the fleet,
because each node applies the log into its own copy. A notice is a pointer at
a history row, and the history answers for ever: “what was I told about in
2024” is a work_activity question, not an inbox one.
Read at GET /work/people/{handle}, and through the operator MCP with
work_inbox, get_person, mark_inbox, set_pins and set_priorities —
never by a seat. A seat is not a human: it has a mailbox, which is the
durable subscription the engine attaches when it acquires the seat, and nothing
on a person’s record describes one.
Hand-offs are bounded on the task
Section titled “Hand-offs are bounded on the task”A task can be reassigned by agents a limited number of times before the engine stops and asks a human. The budget is on the task, not on a call depth, and any human touch resets it.
That is deliberate: an assignment is an ownership transfer down a chart of known height, not a nested ask, so bounding it by delegation depth would bound the wrong thing. What it catches is the loop where two seats hand one task back and forth, which is the failure mode that actually happens.
The budget is 8: an agent moving the assignee spends one, re-asserting the
current assignee spends nothing, and a person or an operator touching the task
at all — any field — returns the whole budget. The ninth agent hand-off is
refused reassignment_budget, and the refusal says to explain on the item what
is blocking it instead.
The limit is served, not copied: the item answer (GET /work/{id},
get_work_item) carries reassignment_budget beside the task’s own
reassignments, so a screen saying “hand-off 7 of 8” reads the figure from the
engine that enforces it. And every task history row carries reassignments
as that change left it — the count after a hand-off, and the zero a person’s
touch reset it to — so the item’s history can say which hand-off each one was.
An assignment made with a reason shows it as that row’s excerpt.
Checklists
Section titled “Checklists”A task carries named checklists — up to 16 lists, 64 items in a list
and 256 items between them, a list’s name at most 128 bytes and an item’s
at most 256. Each item can be ticked or given to somebody (which puts it in their
my_work answer under checklist_items). An item cannot be promoted into a
subtask in this build: the tracker knows how to mark an item with the subtask
it became, but no tool, route or command performs that promotion, so file the
subtask with create_work_item and remove the line.
update_work_item changes them with a checklist gesture — one change per
call, stated as what somebody did rather than as the lists they want to end
with:
op | Arguments | What it does |
|---|---|---|
add_list | name, optional items (names, in order) | adds a list, with its first lines |
remove_list | list | removes a list and everything in it |
rename_list | list, name | renames a list |
add_item | list, name, optional assignee | adds a line at the end of a list |
remove_item | item | removes a line and the lines nested under it |
rename_item | item, name | renames a line |
set_done | item, done | ticks or unticks a line |
assign_item | item, assignee ("" for nobody) | gives a line to somebody |
Lists and items are named by the ids get_work_item shows; item ids are unique
across the whole task, so an item gesture names the item alone. A gesture is a
gesture rather than the collection because the collection is carried whole
on the record: two people working one checklist is the ordinary case, and one
ticking a line while the other adds one would otherwise have whichever landed
second silently undo the other. The engine applies the gesture to the
checklists as they are when it lands, so both changes survive in either order.
The ids a gesture adds are derived from the operation, so a retried request
names the same list rather than adding a second one.
A gesture that would grow the checklists past a cap is refused naming it; one that removes, renames, ticks or assigns is never refused for a size the task already has.
A project’s files
Section titled “A project’s files”A project keeps files beside its work — the report a task asked for, the
spec it is built from, the notes a seat left for the next one. A file lives at a
path inside its project (reports/2026/q3.md), and a path is one file: two
writers putting the same path contend, and exactly one wins.
Seats reach them with the four tools in What a seat can do;
a person reaches them on the dashboard’s project page, through their own
assistant (the same four tools), or over REST —
/work/files takes an upload and
streams a download without either passing through a model.
The bytes are not in the tracker. A file is a row saying where it lives, what it is, its size and SHA-256, and which object holds its bytes; the object is in the object store — one store the whole fleet shares, a bucket on the fleet’s own broker or an S3 bucket — rather than in every data node’s database. So a node that holds no data at all reads and writes files as any other node does. Every upload stores its bytes as a new object before it writes the row naming it, so a file that is listed is always a file that can be read, and every read is checked against the row’s hash.
| Largest file | 1 GiB — larger artefacts belong in a store built for them, with a link in the project |
| Path | up to 1 024 bytes, / between folders; no empty folder, no . or .., no backslash or control character. A leading / and surrounding spaces are dropped |
| Version | every write moves it; if_version (tools) or If-Match (REST) refuses a write when the file has changed since it was read |
| Removing | the file leaves the project’s listing and its history records who removed it and when; its content is deleted from storage by the object store’s hourly collection once no file names it and it was last written more than a day ago, so write it again to bring it back |
A file’s changes are history rows like any other — file_written and
file_removed — naming who made them. They wake nobody: a file is read from its
project rather than delivered to an inbox, so a seat that finished a report
comments on the task that asked for it.
A move is a write and a removal. There is no rename: write the file at its new path and remove the old one. The write uploads the content again — every upload is an object of its own, and nothing is shared between two files — so a move costs the file’s size in storage until the old object is collected — by the first hourly collection after the removal, once the old object is more than a day old.
Removing, deleting and purging
Section titled “Removing, deleting and purging”Three different gestures, and the difference matters:
- Remove hides a task. Its rows stay and a restore brings it back — and
removed=trueis how you find one to restore: every other query excludes removed work, which is what a board means, so the trash is a filter rather than a screen, and any listing carrying that parameter is a trash listing. Every container ships a builtintrashview carrying it (Views), so the one thing a person needs after an assistant removes the wrong subtree — seeing what was removed — is one saved query away on every surface that reads them. On the dashboard it is the Filter menu’s Removed items, which is the same parameter reached the same way: a trash of one project’s bugs is a narrowing like any other, where a tab would have been a place you leave your arrangement to get to. - Delete writes a marker. Every node drops every record about that task for ever, which is what stops a redelivery months later resurrecting it.
- Purge removes the rows. Its report comes back in three groups: what was purged, what could not be reached, and what is stale.
Only the first two are a seat’s. A purge is an operator gesture — a person or an operator token, never an agent and never the engine — because it is the one operation with no inverse and nothing else can be asked to confirm it:
crewlet work purge <task-id> -project KEY -reason "why" -confirm <task-key>Its children move rather than being destroyed: each direct child re-parents onto the purged task’s own parent, or becomes a root when the purged task was one. Destroying the subtree would destroy work nobody confirmed.
What its own records wrote goes with it — the content of its history, the inbox notices that history routed, its turn records and its dependency mirror — leaving the purge’s own history row and the lead’s notice as the one account of it. Of its history, only a skeleton stays: the rows that moved a count — its creation, a removal or restore, and each change of status, assignee or project — each holding its kind, its instant and those three changes and nothing else, no title, no body, no comment and no excerpt. They are what the flow walks backward to answer the past, and a purge takes a task out of that series only from the moment it happened: a board two weeks ago held the task, and the chart still says so.
Every other task that named it stops naming it, for good: a task that waited on it is no longer blocked by it, a task it waited on no longer lists it among the work it unblocks, a link to it is gone, and a child’s parent is the one it moved onto. Each of those tasks keeps that through its own next edit. A purge is a gate on the log, so a node on a build that cannot apply it stops its tracker rather than guess — do not purge in the middle of a rolling upgrade (What a rolling upgrade blocks).
The project’s lead is told, and nobody else. There is no assignee left to tell and no watcher list worth carrying — a notification naming them would be a copy of exactly the content the purge exists to remove, kept on the log for its whole retention window. What survives is that it happened, to which key, by whom, and the reason the operator gave.
The purge report gives no time guarantee, and that is honest rather than evasive: an offline or evicted disk keeps its copy until it replays, adopts a snapshot, is replaced, or is destroyed. There is no duration to state. See Retention.
See also
Section titled “See also”- The Tracker — the two shapes a company’s work can take, and why this one is not a mirror.
- Read consistency — what a seat’s tools see and when.
- Retention — what the log keeps, and what a purge reaches.
- API endpoints — the read routes and the operator MCP surface.
Part of Crewlet. Generated from crewlet/crewlet main at f665f5a. This is not the current version — see the latest docs.