Tasks
A project task is a durable unit of work on a project backlog. It is not a chat turn, not a memory, and not an agent run.
- A memory is something that happened or was said, owned by an identity, recalled into hydration.
- An agent run is a budgeted execution loop (iterations, tokens, wall-clock, tool profile, events). A run may link to a project task via
taskId; it does not replace the board. - An agent task (
AgentTaskunder a run) is run-local work scaffolding. It is a different table fromProjectTask.
The board answers operational questions: what is open, what kind of work it is, which subsystem it touches, whether it gates launch, what blocks what. Priority alone cannot carry that — it was tried and the High column stopped meaning anything (2026-07-28 rebalance). Classification is a separate axis, enforced at create (#273).
All endpoints require authentication. Responses are wrapped in ApiEnvelope<T>. Canonical API host: https://api.atamaia.ai.
Permissions: TaskView, TaskCreate, TaskEdit, TaskDelete, TaskManageDependencies.
Classification — why priority was not enough
Three fields, shipped 2026-08-13:
| Field | Type | Role |
|---|---|---|
kind |
TaskKind? |
What the work is |
subsystem |
TaskSubsystem? |
Where it primarily lands |
isLaunchBlocker |
bool |
Whether it plainly gates public launch / first external users |
Kind
| Value | Meaning |
|---|---|
Bug |
Broken vs intended behaviour — including security defects and silent failures |
Feature |
A capability that does not exist yet |
Chore |
Maintenance: upgrades, cleanup, refactors, infra, docs, deploys |
Investigate |
Output is a decision or document, not code |
Closed vocabulary on purpose. Free-text tags were measured against the live MemoryTag store before this shipped: 1,364 distinct tags, 61% used once. That shape recreates the unreliable [Bug] title-prefix problem this field exists to end.
Subsystem
One per task — the area whose code changes most:
Agent · CodeGraph · Mcp · Spa · Docs · Infra · Memory · Security · AiRouter · Api · Auth · BuildDeploy · Marketing
Launch blocker
Boolean, default false. Conservative: only mark when the work plainly gates launch. Independent of priority — a Normal-priority docs purge can still be a launch blocker.
Null means untriaged
Columns are nullable with no default. A task with null kind/subsystem is honestly unclassified. On create, null is rejected. Existing rows (especially historical Done) may still be null; the board can select them.
Why enforce in the service, not the controller: REST, MCP, agent tools, and skills all call TaskService.CreateAsync. A controller attribute would be one entry point remembering; the service is every entry point.
OpenAPI still shows kind/subsystem as optional on CreateTaskRequest. The running service rejects null. Prefer runtime over schema until the schema is fixed.
Status and priority
Status (TaskStatus)
| Status | Notes |
|---|---|
Todo |
Default on create |
InProgress |
Active work |
Blocked |
Human-set status value |
Done |
Complete |
Cancelled |
Abandoned |
There is no hard transition matrix in the service: any status may be written via the status endpoint. Side effects on change are separate (notes, unblock signals) — see below.
isBlocked is not the same as status Blocked.isBlocked is a computed flag: true when any dependency’s target is still not Done/Cancelled. Task #27 is status Todo with isBlocked=true because it depends on #26.
Priority (TaskPriority)
Low · Normal (default) · High · Critical
Urgency only. Do not overload it with kind or launch semantics.
Create
POST /api/projects/{projectId}/tasks
Auth: TaskCreate.
| Field | Type | Required | Notes |
|---|---|---|---|
title |
string | yes (practically) | Max length 500; trimmed |
kind |
TaskKind |
yes at runtime | 400 if missing |
subsystem |
TaskSubsystem |
yes at runtime | 400 if missing |
description |
string | no | |
parentTaskId |
long | no | Makes this a subtask |
assignedToId |
long | no | Defaults to caller’s identity when omitted (if the caller has one) |
specDocId |
long | no | Link a project doc |
priority |
TaskPriority |
no | Default Normal |
isLaunchBlocker |
bool | no | Default false |
Response: TaskDto summary (counts, not nested trees). Mutations deliberately avoid returning full TaskDetailDto so a one-field edit does not ship every note over MCP.
Rejection: missing classification → HTTP 400, error code INVALID_ARGUMENT, message lists valid enum names. Mapped by ExceptionMiddleware from ArgumentException (not a 500).
Initial status is always Todo.
Read
List (board)
GET /api/projects/{projectId}/tasks
Auth: TaskView.
| Parameter | Type | Default | Notes |
|---|---|---|---|
status |
TaskStatus |
— | Exact status |
includeCompleted |
bool | false |
When no status, hide Done and Cancelled |
priority |
TaskPriority |
— | Named filter |
assignedToId |
long | — | 0 means unassigned |
page / pageSize |
int | 1 / 50 | Page size clamped to 200 |
sort |
string | sortOrder |
Whitelist below |
dir |
string | asc (for default) | asc / desc |
filter |
bag | — | See Filtering |
Scope: top-level only (parentTaskId == null). Subtasks are not board rows; fetch them separately or via detail.
Live probe (project 1, defaults): active top-level totalCount 210 (2026-08-13).
Get detail
GET /api/tasks/{id}
Returns TaskDetailDto: all list fields plus:
| Field | Content |
|---|---|
subTasks |
Ordered by sortOrder, then id |
notes |
Newest first |
blockedBy |
Tasks this one depends on (id, title, status) |
blocks |
Tasks that depend on this one |
Subtasks
GET /api/tasks/{parentTaskId}/subtasks
Paged TaskDto list. Same sort/filter whitelist as the board list (minus the parent scope, which is the path).
Update metadata
PUT /api/tasks/{id}
Auth: TaskEdit. Partial: omit a field to leave it unchanged.
| Field | Notes |
|---|---|
title, description |
|
assignedToId |
Sentinel 0 clears assignee (null cannot mean “clear” under PATCH-style semantics) |
specDocId |
|
priority, sortOrder |
|
kind, subsystem, isLaunchBlocker |
Re-triage allowed. Cannot clear kind/subsystem back to null via this request — null means leave unchanged |
Status is not on this body. Use the status endpoint.
Response: TaskDto summary.
Status transitions
PUT /api/tasks/{id}/status
Auth: TaskEdit.
| Field | Type | Required |
|---|---|---|
status |
TaskStatus |
yes |
note |
string | no — stored as [{Status}] {note} |
Observed side effects (TaskService.UpdateStatusAsync):
- Start while dependency-blocked: moving to
InProgresswhen incomplete dependencies exist is allowed. A system note is added:[Warning] Started while blocked by: …. - Optional note on any status change when
noteis supplied. - On
Done:- Downstream tasks that are now fully unblocked get a note:
[Unblocked] All dependencies resolved…(status of those tasks is not auto-changed). - If this task is a subtask and all siblings are Done/Cancelled, the parent gets
[Subtasks Complete]…— parent is not auto-completed.
- Downstream tasks that are now fully unblocked get a note:
- Publishes
TaskStatusChangedEvent.
Moving to Done while still dependency-blocked does not add the same warning note as InProgress (only the InProgress branch writes that warning).
Notes
POST /api/tasks/{taskId}/notes
GET /api/tasks/{taskId}/notes
Auth: edit for POST, view for GET.
Notes are the activity log — status changes, unblock signals, human commentary, client-report recurrences. Search indexes note bodies (#426).
GET supports paging, sort (createdAtUtc default desc, id, createdById), and filters (createdById, content contains).
Dependencies
POST /api/tasks/{taskId}/dependencies
DELETE /api/tasks/{taskId}/dependencies
Auth: TaskManageDependencies.
Body (both):
{ "dependsOnTaskId": 26 }
Semantics: taskId depends on dependsOnTaskId — taskId is blocked until that dependency is Done or Cancelled.
Rejected (POST → 400, message names the cases):
- Self-dependency
- Duplicate edge
- Cycle — BFS from
dependsOnTaskIdwalking upstream edges; iftaskIdis reachable, the add is refused
DELETE returns 404 if the edge is absent.
There is no separate “list dependencies” route; they appear on GET /api/tasks/{id} as blockedBy / blocks.
Search
GET /api/projects/{projectId}/tasks/search
Auth: TaskView.
| Parameter | Notes |
|---|---|
query or q |
Required; empty/whitespace → 400 |
includeCompleted |
Default false. Use true when checking duplicates before filing |
page / pageSize |
Same paging caps |
Matches title, description, and note content via case-insensitive ILIKE. Title hits rank above body hits; then updatedAtUtc desc.
Unlike list, search is not limited to top-level tasks — subtasks can appear.
Why it exists: paging task_list at pageSize 200 cannot prove absence. “Search before filing” is only enforceable if the API can look at the whole board including notes (where intent often lives).
Live probe: query=classification&includeCompleted=true returns task #273 among others.
Filtering, sorting, untriaged
Sort whitelist (sort + dir)
sortOrder (service default) · title · status · priority · kind · subsystem · createdAtUtc · updatedAtUtc · noteCount
Unknown sort keys fall back to the map default. Tie-break: id.
(The SPA UI descriptor defaults the board view to updatedAtUtc desc; the API default without sort remains sortOrder.)
Filter bag
Passed as filter[field]=value (URL-encode brackets).
| Key | Values | Behaviour |
|---|---|---|
status |
enum name | |
priority |
enum name | |
assignedToId |
long | Also available as named query param |
kind |
enum name or none |
none → kind IS NULL (untriaged) |
subsystem |
enum name or none |
none → subsystem IS NULL |
isLaunchBlocker |
true / false |
AddEnumOrNone is required for untriaged: a plain enum filter that fails to parse drops the predicate and would return the whole board as if it were “untriaged” — a wrong answer wearing a right answer’s clothes. Literal none is handled explicitly.
Live probes (project 1, 2026-08-13):
| Query | totalCount |
|---|---|
filter[kind]=none&includeCompleted=true |
195 (mostly old Done rows never backfilled) |
filter[kind]=Bug (active default) |
72 |
filter[isLaunchBlocker]=true (active) |
11 |
includeCompleted is a scope switch, not a filter field — it does not appear in the filter map.
Named query params status, priority, assignedToId still work alongside the bag.
Delete
DELETE /api/tasks/{id}
Auth: TaskDelete.
Soft delete only (IsDeleted = true). Global query filters hide deleted rows. No hard delete path on this surface.
Client reports (SPA → board)
POST /api/projects/{projectId}/client-reports
Auth: TaskCreate.
The SPA files bugs, feature requests, and crashes into the same task table. It does not invent a second issue tracker.
Request (CreateClientReportRequest)
| Field | Type | Notes |
|---|---|---|
kind |
ClientReportKind |
Bug, Feature, Crash |
summary |
string | Required (trimmed non-empty) |
detail |
string | Repro steps or component stack |
route |
string | SPA path, e.g. /ai/models |
stack |
string | Truncated server-side (~4000) |
userAgent |
string | SPA always sends navigator.userAgent |
priority |
TaskPriority? |
Honoured for Bug/Feature only |
What the server derives (not required from the client)
| Derived | Rule |
|---|---|
Task kind |
Feature → Feature; Bug or Crash → Bug |
Task subsystem |
Always Spa (honest coarse default; triager can move it) |
Task priority |
Crash → always High; else request priority or Normal |
Task title |
[{prefix}] {route} — {summary head} where prefix is [Feature] / [UI Crash] / [UI Bug], route defaults to app, summary head truncated to 90 chars |
Task description |
Markdown body: route, summary, optional Detail/Stack/UA sections |
Deduplication
Signature = full title. Open tasks (Todo or InProgress) matching that title prefix collapse:
- Returns existing
taskIdwithdeduplicated: true - Appends a recurrence note
Recurrence at {utc} — {route}only if it differs from the last note (prevents crash-loop note floods)
Response (ClientReportResult)
{ "taskId": 123, "title": "[UI Crash] /x — …", "deduplicated": false }
SPA behaviour (Atamaia.Web report.ts / ErrorBoundary)
- Hard-coded
REPORT_PROJECT_ID = 1(Atamaia board). - Crash: error boundary →
kind: Crash, in-session signature set so a render loop does not POST every frame. - API errors: auto-file when the SPA composed the bad request (5xx/network; failed GET/DELETE; non-field 4xx). Skip 401/403/404/409 and field-validation failures on writes. Never report failures of
/client-reportsitself. - Failures are swallowed on purpose — a failed report must not replace the crash screen. That is why classification is derived on the server rather than required from the client: a 400 here would be invisible and bug filing would silently stop.
MCP tools vs REST
MCP wraps the same ITaskService (API-first). Tools in TaskTools.cs:
| MCP tool | REST analogue | Differences |
|---|---|---|
task_list |
GET …/tasks |
No sort/dir/priority/assignedToId/filter bag — status + includeCompleted + paging only |
task_search |
GET …/tasks/search |
Same core behaviour; query required as tool arg |
task_get |
GET /api/tasks/{id} |
Full detail |
task_create |
POST …/tasks |
kind + subsystem positional non-nullable in schema; no specDocId on the tool |
task_update |
PUT /api/tasks/{id} |
Includes kind/subsystem/isLaunchBlocker/specDocId |
task_update_status |
PUT …/status |
Same |
task_add_note |
POST …/notes |
Same |
task_add_dependency |
POST …/dependencies |
Returns bool; no remove tool |
REST-only (no MCP tool in TaskTools):
DELETE /api/tasks/{id}DELETE …/dependenciesGET …/notes(paged)GET …/subtasksPOST …/client-reports- List filter bag / sort / priority / assignee filters
MCP returns service DTOs directly (not necessarily the outer ApiEnvelope shape the HTTP client sees). Create/update return summary TaskDto, same as REST mutations.
Agent-executor tool TaskCreateTool also requires kind/subsystem in JSON schema and validates in ExecuteAsync (local models omit required fields routinely).
Endpoint catalog
GET /api/projects/{projectId}/tasks
POST /api/projects/{projectId}/tasks
GET /api/projects/{projectId}/tasks/search
POST /api/projects/{projectId}/client-reports
GET /api/tasks/{id}
PUT /api/tasks/{id}
DELETE /api/tasks/{id}
PUT /api/tasks/{id}/status
GET /api/tasks/{parentTaskId}/subtasks
GET /api/tasks/{taskId}/notes
POST /api/tasks/{taskId}/notes
POST /api/tasks/{taskId}/dependencies
DELETE /api/tasks/{taskId}/dependencies
Related but not this board: GET/POST /api/agent/runs/{runId}/tasks, PATCH /api/agent/tasks/{id} — agent-run work items.
TaskDto fields (list / mutation response)
| Field | Notes |
|---|---|
id, guid |
D3: long + guid |
projectId, projectKey |
|
parentTaskId |
null = top-level |
assignedToId, assignedToName |
|
specDocId, specDocPath |
|
title, description |
|
status, priority |
enums as names on the wire |
kind, subsystem |
nullable; omitted when null in JSON |
isLaunchBlocker |
|
sortOrder |
|
subTaskCount, noteCount |
|
isBlocked |
computed from open dependencies |
createdAtUtc, updatedAtUtc |