Tasks domain — full reference
Entities
Task
| Field | Type | Notes |
|---|---|---|
id |
uuid | |
project_id |
uuid | |
parent_task_id |
uuid | null | When set, this task is a child of a feature parent. |
type |
task|bug|chore|spike|feature |
|
title |
text | Required. |
description_md |
text | Markdown. |
state |
todo|doing|done |
Lifecycle. |
priority |
int | 0–100 by convention. Higher = picked sooner. |
assignee_user_id |
uuid | null | Specific user. |
target_role_id |
uuid | null | Role-scoped; eligible to any holder. |
actual_start |
timestamp | null | Set automatically when entering doing (kept across re-enters). |
actual_end |
timestamp | null | Set automatically when entering done, cleared on revert. |
created_by_user_id |
uuid | null | Audit. |
created_by_token_id |
uuid | null | Audit. |
Dependency
A directed edge (task_id, depends_on_id). The Nottario server
rejects edges that would create a cycle. A task is considered
eligible (nottario.tasks.next) only when every depends_on is
in state = 'done'.
Commit link
(task_id, repo, sha, message). Repo must be in "owner/repo"
form. Re-linking the same (task_id, repo, sha) updates the message.
Comment
(id, task_id, author_user_id, author_token_id, body_md, created_at). Append-only.
Tool surface
nottario.tasks.list
Filter parameters (all optional except project_id):
-
state— restrict to one state. -
type— restrict to one task type. -
assignee_user_id/target_role_id— restrict assignment. -
parent_task_id— list children of a specific feature. -
include_children: true— by default only top-level tasks (parent_task_id IS NULL) are returned; set this to flatten feature subtrees. -
include_closed: true— by default the response is restricted to open tasks (statetodoordoing) so closed rows don't dominate every walk in a long-lived project. Set this to also includedoneandwont_do. An explicitstatefilter (state: 'done',state: 'wont_do') always wins regardless of this flag. -
cycle_id(optional) — when omitted, all read tools (tasks.list,tasks.next,tasks.claim_next) default to the project's active cycle. Pass a specificcycle_idto inspect a closed cycle. Pass"all"for the no-filter behaviour (every cycle). To see what shipped in sprint-2 specifically:nottario.tasks.list { project_id, cycle_id: <sprint-2 id>, state: 'done' }See
domains/cycles.mdfor the full cycles surface.
Ordering: priority DESC, created_at ASC.
Pagination
tasks.list is paginated with a keyset cursor:
limit— page size, 1..500. Omit to use the project's configuredmcp_page_size(default 50, editable in the web UI under Project settings → MCP).cursor— opaque string. Empty/omitted ⇒ first page. Otherwise pass the previous response'snext_cursor.
Each response is now {tasks, next_cursor, has_more} (instead of just
{tasks}). The canonical walk loops while has_more:
cursor = ""
loop:
page = tasks.list { project_id, state: "todo", cursor }
process page.tasks
if not page.has_more: break
cursor = page.next_cursor
Filters can change between calls (e.g. swap state mid-walk) without
corrupting the cursor — the ordering is stable across mutations
because the cursor encodes (priority, created_at, id).
nottario.tasks.next
Returns the next eligible task or {task: null} if nothing is
available. Eligibility:
state = 'todo'.type <> 'feature'(features are containers).- No
depends_onrow points to a task that is notdone.
Filters narrow the candidate set:
assignee_user_idset → tasks assigned to that user or unassigned tasks whosetarget_rolematches one of the user's roles in the project.role_idset → tasks whosetarget_role_idequals it or is null.
Priorities
Each project defines its own priority buckets (named labels mapped to
a numeric value). Defaults seeded on project creation: low=30,
medium=60, high=90, critical=100. Admins can rename, retune or
add buckets per project.
Always pick a key from the project's vocabulary — call
nottario.projects.list_priorities first and pass the chosen key as
priority_key to tasks.create / tasks.update. Avoid passing raw
numbers in priority unless you have a deliberate reason to bypass
the buckets (e.g. inserting between two existing buckets).
nottario.tasks.create
Defaults: state=todo, type=task, priority=50. To create a
feature with subtasks:
1. create(type='feature', title='Sign in with Google') → F
2. create(parent_task_id=F.id, type='task', target_role_id=design) → A
3. create(parent_task_id=F.id, type='task', target_role_id=backend) → B
then add_dependency(B, A)
4. create(parent_task_id=F.id, type='task', target_role_id=frontend) → C
then add_dependency(C, B)
5. create(parent_task_id=F.id, type='task', target_role_id=qa) → D
then add_dependency(D, C)
The parent transitions to done automatically when all its children
are done.
tasks.create does not accept a cycle_id argument — every new
task lands in the project's active cycle. To create a task in a
different cycle, create it first, then move it with tasks.update
(and reparent the whole feature if the target is a leaf under a
feature parent — see the cascade note under tasks.update).
Send title and description as plain UTF-8. Do not HTML-encode
ampersands, angle brackets or quotes — Build & deploy, not
Build & deploy. The kanban, gantt and detail dialog escape
these for display, so an encoded payload renders to the user as the
literal &. The server now decodes a small set of common
entities defensively when it receives them, but the cleanest fix is
to never encode in the first place. The same rule applies to
tasks.update.
Do not pre-escape quotes or backslashes either. The MCP transport
JSON-encodes the value for you. Write quotes as quotes and newlines
as real line breaks — never \" or the two-character sequence \n.
Pre-escaped forms are stored verbatim and render with the backslash
visible everywhere. The string you would type into a human-facing
form is the string you send to the tool.
One task per role
When a unit of work spans multiple roles (e.g. backend migration +
frontend reorder UI + qa smoke), do not file a single multi-role
task. Create one task per affected role and link them with
add_dependency in the order they must be executed. Each role-scoped
task is then individually pickable by the right agent and tracked
independently.
Typical pattern: group the role-scoped tasks under a type=feature
parent so they share a title and roll up to done together.
1. create(type='feature', title='Roles: add order, drag UI, Gantt lanes') → F
2. create(parent_task_id=F.id, target_role_id=backend, title='migration + API') → B
3. create(parent_task_id=F.id, target_role_id=frontend, title='drag UI + Gantt') → Fe
then add_dependency(Fe, B)
4. create(parent_task_id=F.id, target_role_id=qa, title='smoke reorder + lanes') → Q
then add_dependency(Q, Fe)
nottario.tasks.update
Mutates the fields you pass. Notable nuances:
- Pass
assignee_user_id: ""(empty string) to unassign the user. Same fortarget_role_id. - Changing
priorityis the canonical way to reorder; passpriority_key(resolved against project buckets) rather than a raw number. - Use this for description edits and renames; do not delete-and-recreate.
- Reparenting cascades
cycle_id: settingparent_task_idon a leaf task forces the task'scycle_idto match the new parent's cycle (DB trigger; anycycle_idyou pass alongside is overridden). To move a leaf to a different cycle, either detach it from its feature parent first, or move the whole feature subtree instead.
nottario.tasks.set_state
The only correct way to move a task between states. It manages
actual_start and actual_end for you:
todo→ clears both.doing→ fillsactual_start(only if currently null).done→ fillsactual_endand preserves any earlieractual_start.
For terminal transitions (done and wont_do) prefer
nottario.tasks.close over set_state — it bundles the closing
comment and the commit links into the same transaction, so a
precondition failure cannot leave an orphan comment behind. See
tasks.close below.
nottario.tasks.close
Atomic close: attaches commits, adds a closing comment and transitions state, all in one Postgres transaction.
nottario.tasks.close {
task_id,
state: "done", # or "wont_do"
comment: "Repro:... Fix:... Test:... abc1234.", # optional, see §"Always leave a closing comment"
commits: [
{ repo: "neverbot/nottario", sha: "abc1234", message: "feat: x" },
{ repo: "neverbot/nottario", sha: "def5678" }
]
}
Response (slim by default): {task, comment_id?, linked_commit_count}.
verbose: true returns the full Task instead of the slim ack.
On precondition failure the response shape is the same as
set_state ({error, preconditions}) and the whole transaction
rolls back — no orphan comment, no orphan commit link.
Closing-the-loop checklist
After every set_state done, run the three checks from skill.md §4:
- Is there another open task that describes the same delivery?
Close it too instead of letting a duplicate row sit in
todo. - Did the work add/remove/modify components or their relations?
Update
nottario.arch.*so the diagram matches reality (domains/architecture.md§"When to touch the architecture"). - Did the human mention side-work? File it with
nottario.tasks.createBEFORE moving on (see §"The user just mentioned a different task / bug / feature" below).
Always link commits before closing
Before set_state done, call nottario.tasks.link_commit { repo, sha } once per commit the task produced. This is non-negotiable
whenever the work yielded code: the Commits panel in the UI, the
"what shipped here" queries and any traceability audit all depend on
the structured link, not on prose in a comment. The bar is: a future
reader of the closed task can jump straight to the diff without
grepping git. Tasks that are pure documentation or bug-recovery in
the DB legitimately have no commit; everything else does.
Always leave a closing comment
Before set_state done (or wont_do, or any terminal state), call
nottario.tasks.add_comment with a short summary of what actually
happened. The diff is the truth; the closing comment is the story
a future reader needs to understand the diff without reverse-
engineering it. Skipping this is a low-friction mistake that compounds
— months later nobody remembers why a task was closed without code,
why wont_do was the right call, or what the bug actually was.
What the comment must carry, by task type:
- task / chore / spike — one paragraph: what you delivered, the
non-obvious decisions you made, and any follow-up you spotted but
did not file as its own task (a small enough loose end to live as a
note here instead of a new row). Link the commits inline for
readability even though the structured
link_commitalready exists. - bug — three sections, terse but explicit:
- Repro — the exact steps or input that triggered the bug, in past tense. Make it copy-pasteable. The bar: a future agent should be able to re-trigger from this paragraph alone, without reading the original report.
- Fix — the change in one or two sentences, focusing on why this is the right fix and not just the apparent one. If you rejected an obvious alternative, mention it.
- Test — what you ran to confirm: integration test added, manual reproduction now fails to trigger, smoke test in the dev container, etc. "Verified the gate is green" is not a test plan.
- feature parents close automatically when their children flip to
done; you don't usually comment on the parent. Leave the closing story on each child. wont_do— say why. "Superseded by<id>", "out of scope after user clarified X", "infeasible because Y". Awont_dowith no comment looks indistinguishable from "forgot about it" to anyone who comes back later.
The canonical close is one call:
nottario.tasks.close {
task_id,
state: "done",
comment: "Fix: deferred the cycle check to inside the tx (was racing the FOR UPDATE). Repro: two concurrent add_dependency calls — used to allow A→B→A intermittently. Test: TestAddDependency_NoCycleUnderRace, 10 iterations under -race.",
commits: [
{ repo: "neverbot/nottario", sha: "abc1234" },
{ repo: "neverbot/nottario", sha: "def5678" }
]
}
tasks.close runs the commit links, the comment and the state
transition inside one Postgres transaction. On a precondition failure
(state=done blocked by an open dependency) the whole thing rolls
back — no orphan comment, no orphan commit link. That is the reason
to prefer it over the legacy three-call pattern (link_commit +
add_comment + set_state), which is still supported but leaves the
caller to clean up the half-applied state itself.
For ordinary close-as-no-code paths (a wont_do, a documentation
task) just drop commits and the comment carries the story.
Preconditions are enforced
Closing a task (state: "done") is rejected when the task has at
least one direct dependency whose own state is not done. The
response carries a preconditions array listing what's still open:
{
"error": "cannot close task: 2 unresolved preconditions",
"preconditions": [
{ "id": "…", "title": "Backend migration", "state": "doing" },
{ "id": "…", "title": "API endpoint", "state": "todo" }
]
}
The fix is always to close the preconditions first, never to bypass. Canonical pattern when an agent hits this:
me = nottario.whoami { }
# walk every unresolved precondition; pick whichever is yours.
for p in error.preconditions:
if p.assignee_user_id == me.user_id and p.state == "todo":
# work on p first.
...
Feature parents (type: feature) are an exception — the engine rolls
them up automatically when all their children are done, so the
check is skipped for them.
nottario.tasks.add_dependency / remove_dependency
add_dependency rejects edges that would form a cycle:
A depends_on B ✓
B depends_on A ✗ (cycle)
Cycle detection considers the entire transitive graph, not just the direct edge.
nottario.tasks.link_commit
repo is the GitHub-style "owner/repo" string. sha can be the
full 40-char hash or a shorter prefix; we store what you send.
message is optional (it's the human-readable subject for
display). Re-linking the same (task, repo, sha) updates the
message in place.
nottario.tasks.add_comment
Body is markdown. The comment is attributed to the calling user and token. Comments are append-only; there is no edit or delete from the agent surface (a human can purge data directly in Postgres if needed).
When to file a task before doing the work
Two shapes of "new work" both go through nottario.tasks.create
before you write any code, open any editor, or run any command:
- Side-channel requests / bugs spotted in passing. "Ah, and we should also…", "this is broken: …", "I noticed X". File the row, decide whether to pivot or stay on the current task. Verbatim quotes from the user (the bug repro, the half-formed idea) belong in the description — future-you will not remember them.
- Substantive new work the user explicitly asks you to do.
"Let's add Biome", "do the design review of the Kanban", "rename
content_mdtocontent". Even when the user is telling you to act, the act starts withtasks.create→claim→ work. Skipping the row because "the request is obviously the task" leaves the backlog blind: the work has no handle for tracking, no audit trail, no link to the resulting commits. The exception is conversational tweaks that fit in a single small commit and need no follow-up (a typo fix in a doc, a one-line CSS adjustment) — those can land directly.
Both shapes need the right target_role, an honest description,
dependencies linked if relevant, and a split into role children when
multi-role. The bar is: if I had to leave the session right now,
would someone else be able to pick this up? If not, file more
context.
Idiomatic patterns
"Carry on" — the loop
Use nottario.tasks.claim_next to atomically pick AND claim the
next eligible task in one MCP call. It sets assignee = you and
state = doing in a single Postgres UPDATE backed by SELECT … FOR UPDATE SKIP LOCKED, so two agents running this loop in parallel get
two DIFFERENT tasks — no double-claim, no race.
loop:
result = nottario.tasks.claim_next {
project_id,
assignee_user_id: me.user_id # optional: include role-matched todos for me
}
if result.task is null: tell the human "no eligible tasks" and stop
task = result.task
...do the work in the local repo...
nottario.tasks.close {
task_id: task.id,
state: "done",
comment: "...", # REQUIRED — see §"Always leave a closing comment"
commits: [{ repo, sha }, ...] # whatever the work produced
}
goto loop
"Take the next task about topic X"
When the human (or your own judgement) narrows the pickup to a topic,
discover candidates with tasks.list, then claim a specific id
atomically with nottario.tasks.claim. The claim either succeeds or
returns a 409-shaped conflict with details; if it loses the race or
the task isn't eligible, try the next candidate.
candidates = nottario.tasks.list { project_id, state: "todo" }
relevant = filter(candidates, matches=topic_X) # client-side reasoning
for t in relevant: # already ordered priority DESC, created_at ASC
result = nottario.tasks.claim { project_id, task_id: t.ID }
if result.error:
# 409: somebody else just took it OR preconditions still pending OR feature has open children
# See result.reason, result.current_state, result.current_assignee_user_id,
# result.preconditions[], result.pending_children_count.
continue
task = result # task is yours, already in doing
break
"Work on this specific task"
If the human hands you an id, call claim directly. Read the
conflict shape on failure and surface it to the human ("that task is
already in doing assigned to X", "preconditions still pending: …").
Why the old three-call pattern is gone
The historical tasks.next + tasks.update {assignee} + set_state doing sequence is racy: between any two calls another agent can
slip in and claim the same task. Use claim_next / claim instead;
tasks.next is a preview, never a pickup.
If a task you want is already claimed by another user: leave a
nottario.tasks.add_comment or escalate to the human. Do not
silently re-assign.
"I found a bug while doing my task"
Create it; do not silently fix it without filing:
nottario.tasks.create {
project_id, title, type: "bug", priority_key: "high",
description: "context, repro, suspected fix",
}
Then continue with your current task. The general rule (verbatim
quote, file path, proposed direction, role split) lives in skill.md
§"Filing work as you discover it".
"The user just mentioned a different task / bug / feature"
Same rule, broader scope: any side-comment from the human about work
that is NOT what you're currently executing → FIRST action is
nottario.tasks.create, only THEN decide whether to pivot or keep
going. If the item lives only in conversation, multi-agent and
multi-session work lose the single source of truth.
When to pivot vs. keep going: cheap context-switch (≤5 min, no architectural decision required) → pivot, file, fix, return. Otherwise: file with enough context that someone else can pick it up, and resume your current task.
"Block this until X is done"
After you create both tasks, declare the order:
nottario.tasks.add_dependency {
project_id, task_id: B, depends_on_id: A,
}
tasks.next will then skip B until A is done.
Token discipline
Keep MCP responses small. Default to the slim shapes; opt in to heavier ones deliberately.
Default responses are slim. As of the current build, the high-
frequency mutations (tasks.create, tasks.update, tasks.set_state,
tasks.claim, tasks.claim_next, tasks.next, tasks.add_comment)
and tasks.list return only the fields you need to chain the next
call — id, title, state, priority, role/assignee, updated_at — NOT
the description or comment body you just sent. tasks.get returns the
full base task but OMITS dependencies, commits and comments unless you
opt in with include_deps, include_commits, include_comments.
When NOT to pass verbose: true. Almost always. The slim shape
carries everything you need to pick the next call (id for routing,
updated_at for optimistic concurrency, state for the gate). Only pass
verbose: true when you specifically need the description back in
your context — e.g. an agent is reading a task it didn't create.
Closing comments are one line per item. The commit message + the diff carry the detail; the closing comment is a pointer, not a story. For a bug:
Repro: <symptom in 6-10 words>. Fix:
. Test: <name + count>. Commit: .
For an ordinary task: one sentence of "why" + the commit sha. A multi-paragraph "Fix" block is a sign the work belongs in the commit message or a separate doc, not in a Nottario comment that every future agent will re-read.
No pickup comments. Right after tasks.claim or claim_next,
some agents reflexively add a comment like "starting this", "claimed",
"picking it up", "I'm on it". Do not. The state=doing IS the record
that pickup happened; the assignee field carries who. The empty
comment adds no signal and is noise every future reader has to skim
past. Comment only when you have something a future reader actually
needs: a closing summary, a mid-work decision you had to make, a
discovered blocker.
Close in one call when possible. tasks.close collapses
link_commit + add_comment + set_state into a single round-trip
with one response. The slim ack is {task, comment_id?, linked_commit_count} — you don't pay for repeated Task echoes between
the three legacy calls. Falling back to the three-call form is only
worth it when the work was big enough that the closing comment
genuinely needs to be written in stages.
Don't re-tasks.get what you already know. If the previous tool
call returned a task with id X and updated_at Y, don't fetch it
again to confirm — pass those values straight into the next call. The
same goes for whoami and projects.list: once per session.
Don't re-read a skill page you already loaded this session. The
content is stable; reading skill/domains/tasks.md twice costs as
much as reading it once and learns nothing new.
Pass include_* to tasks.get deliberately. Pulling all three
(deps + commits + comments) on a feature parent with many children
returns a large payload. Ask for only what your next decision needs.
tasks.list returns only open tasks by default. Closed rows
(state done or wont_do) accumulate forever and would dominate
every backlog walk. Pass include_closed: true only when you
genuinely need them, or set an explicit state filter
(state: 'done' / state: 'wont_do') to scope to a closed bucket.
nottario.search is slim by default and capped at 20. The
hits carry the highlighted description_html snippet but not the
raw description fallback that the web UI consumes — your context
only needs one. Default limit is 20, max 100. Raise it only when
you genuinely need a wider sweep, and pass verbose: true only when
you want the raw description back (rare).
Things you cannot do (today)
- Delete a comment via MCP (the web UI can; agents cannot).
- Edit historic actual_start/actual_end.
- Modify the project, role catalogue or memberships (admin-only, done via the web UI).
- Subscribe to live updates from the MCP — agents poll. The web UI uses SSE; agents do not need real-time and we keep the protocol simple.