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'.

(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 (state todo or doing) so closed rows don't dominate every walk in a long-lived project. Set this to also include done and wont_do. An explicit state filter (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 specific cycle_id to 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.md for 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 configured mcp_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's next_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:

  1. state = 'todo'.
  2. type <> 'feature' (features are containers).
  3. No depends_on row points to a task that is not done.

Filters narrow the candidate set:

  • assignee_user_id set → tasks assigned to that user or unassigned tasks whose target_role matches one of the user's roles in the project.
  • role_id set → tasks whose target_role_id equals 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 &amp; deploy. The kanban, gantt and detail dialog escape these for display, so an encoded payload renders to the user as the literal &amp;. 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 for target_role_id.
  • Changing priority is the canonical way to reorder; pass priority_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: setting parent_task_id on a leaf task forces the task's cycle_id to match the new parent's cycle (DB trigger; any cycle_id you 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 → fills actual_start (only if currently null).
  • done → fills actual_end and preserves any earlier actual_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:

  1. Is there another open task that describes the same delivery? Close it too instead of letting a duplicate row sit in todo.
  2. 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").
  3. Did the human mention side-work? File it with nottario.tasks.create BEFORE moving on (see §"The user just mentioned a different task / bug / feature" below).

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_commit already exists.
  • bug — three sections, terse but explicit:
    1. 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.
    2. 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.
    3. 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". A wont_do with 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.

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:

  1. 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.
  2. Substantive new work the user explicitly asks you to do. "Let's add Biome", "do the design review of the Kanban", "rename content_md to content". Even when the user is telling you to act, the act starts with tasks.createclaim → 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.