Skip to main content

Kanban and tasks, by behavior

This page covers the board and task surface of the udctl API, grouped by what you are doing rather than by swagger tag. Every endpoint comes with one full HTTP call: the request in raw HTTP and curl, the response per status code.

Live Draft — Contract first: every path on this page is the target contract. Live means the operation is implemented — until the path renames ship (/todolist/* becomes /tasks/*, /kanban/boards/* becomes /boards/*) it answers at the "works today" path shown beneath it. Draft means the endpoint is published contract only, not implemented yet, and may still change before shipping. The machine truth for what is deployed right now stays the OpenAPI spec.
Base URL: https://api.oatnil.com (hosted) or wherever you self-hostAuth: every route below needs Authorization: Bearer <access_token>Full machine-readable truth: OpenAPI · openapi.json

Board day-to-day

Reading columns, creating cards in a column, moving cards, reordering — the loop you live in.

POST/api/v1/boards/{boardId}/columns/{columnId}/queryDraft

See what is in a column

Semantic column read. You name the column and how to treat sprints; the server assembles the query — column query, board default_tags, active-sprint expansion, your ad-hoc filter, default ordering — and echoes the result as effective_query so you can see exactly what was asked.

Request body

FieldTypeDescription
sprintstring"active" (default) filters by the board's running sprint; "none" disables the filter; or a sprint id.
filterstringAd-hoc filter fragment, ANDed onto the column query by the server.
page / page_sizeintPer-column pagination, defaults 1 / 20.

Request

POST /api/v1/boards/7d55a212-30f5-4a41-a5da-b2f7f01f3bb9/columns/col-doing/query HTTP/1.1
Host: api.oatnil.com
Authorization: Bearer <token>
Content-Type: application/json

{
"sprint": "active",
"filter": "title ~ \"callback\"",
"page": 1,
"page_size": 20
}

Response

{
"data": [
{
"id": "7c2f6a1e-4b09-4d2a-9e51-2f8c3d7b9a10",
"title": "Retry payment callback on timeout",
"status": "doing",
"tags": ["dev", "urgent"],
"metadata": {"ud.sprint": "3f6d9a52-88a1-4f0e-b1d4-6c1f2e7a9b31", "order": 3}
}
],
"total": 2,
"page": 1,
"page_size": 20,
"effective_query": "(status = 'doing') AND ('q4' IN tags) AND ud.sprint = '3f6d9a52-88a1-4f0e-b1d4-6c1f2e7a9b31' AND (title ~ \"callback\") ORDER BY metadata.order ASC"
}

Highlighted: computed by the server, not sent by you — this line is the assembly every client used to do itself.

Errors

400Invalid filter fragment; the message points at the offending position.
404The column id is not on this board.
Column ids come from GET /api/v1/boards/{id} (the board carries its column definitions); translating a column name to its id is the client's one remaining job. Until this endpoint ships, column reads go through the raw query gate below.
POST/api/v1/boards/{boardId}/queryLive

Works today at: POST /api/v1/kanban/boards/{boardId}/query

Free query within a board

The raw query gate, scoped to one board. You send a complete query string and the server only applies the board's visibility scope. Free querying is a product feature and stays; the semantic column read above absorbs only the most common shape.

Request body

FieldTypeDescription
querystringComplete query string (same syntax as /api/v1/tasks/query).
pageint1-indexed.
page_sizeintItems per page.

Request

POST /api/v1/boards/7d55a212-30f5-4a41-a5da-b2f7f01f3bb9/query HTTP/1.1
Host: api.oatnil.com
Authorization: Bearer <token>
Content-Type: application/json

{
"query": "(status = 'doing') ORDER BY metadata.order ASC",
"page": 1,
"page_size": 20
}

Response

{
"data": [
{
"id": "7c2f6a1e-4b09-4d2a-9e51-2f8c3d7b9a10",
"title": "Retry payment callback on timeout",
"description": "",
"status": "doing",
"path": "",
"tags": ["dev", "urgent"],
"checkInCount": 0,
"metadata": {"ud.sprint": "3f6d9a52-88a1-4f0e-b1d4-6c1f2e7a9b31", "order": 3},
"created_at": "2026-09-22T08:01:44Z",
"created_by": "9a7e5c21-1b3f-4e8a-92d6-04c8f1a7d502",
"updated_at": "2026-09-25T10:12:03Z",
"updated_by": "9a7e5c21-1b3f-4e8a-92d6-04c8f1a7d502"
}
],
"total": 9,
"page": 1
}
POST/api/v1/boards/{boardId}/tasksLive

Works today at: POST /api/v1/kanban/boards/{boardId}/tasks

Create a card on a board

Creates a task associated with the board; the server merges the board default_tags in. Today the client decides the initial status/tags itself. The Draft column_id field changes that: name the column, and the server computes the initial values from the column's enter actions (your explicit fields always win).

Request body

FieldTypeDescription
titlerequiredstringCard title.
descriptionstringMarkdown body.
statusstringInitial status.
tagsstring[]Merged with the board default_tags on the server.
metadataobjectExtensible keys (cf.*, ud.sprint, order, ...).
assignee / derived_from_id / linked_to_ids / resourceIdsvariousOptional; see OpenAPI for exact shapes.
column_idDraftstringDraft. Target column; the server computes initial values from its enter actions and stamps ud.sprint with the active sprint by default (send "ud.sprint": null in metadata to opt out).

Request

POST /api/v1/boards/7d55a212-30f5-4a41-a5da-b2f7f01f3bb9/tasks HTTP/1.1
Host: api.oatnil.com
Authorization: Bearer <token>
Content-Type: application/json

{
"title": "Export support tickets",
"description": "CSV and XLSX",
"status": "todo",
"tags": ["dev"]
}

Response

{
"id": "b7e02c4d-9a31-4f6e-8c25-d10f4a7b3e92",
"title": "Export support tickets",
"description": "CSV and XLSX",
"status": "todo",
"path": "",
"tags": ["dev", "q4"],
"checkInCount": 0,
"created_at": "2026-09-26T09:14:03Z",
"created_by": "9a7e5c21-1b3f-4e8a-92d6-04c8f1a7d502",
"updated_at": "2026-09-26T09:14:03Z",
"updated_by": "9a7e5c21-1b3f-4e8a-92d6-04c8f1a7d502"
}

In this sample "q4" arrived from the board default_tags — the one initial value the server already merges today.

POST/api/v1/boards/{boardId}/tasks/{taskId}/moveLive

Move a card to another column

Single-transaction move. The server computes merge(exit(from), enter(to)) and writes once — replacing today's client-side sequence of one update plus N metadata patches, which can fail halfway. dry_run: true computes without writing, for confirmation UIs.

Request body

FieldTypeDescription
from_column_idrequiredstringThe column you believe the card is in — the server verifies it.
to_column_idrequiredstringTarget column.
dry_runbooltrue computes without writing; response shape is the same as 200.

Request

POST /api/v1/boards/7d55a212-30f5-4a41-a5da-b2f7f01f3bb9/tasks/7c2f6a1e-4b09-4d2a-9e51-2f8c3d7b9a10/move HTTP/1.1
Host: api.oatnil.com
Authorization: Bearer <token>
Content-Type: application/json

{
"from_column_id": "col-todo",
"to_column_id": "col-doing",
"dry_run": false
}

Response

{
"task": {
"id": "7c2f6a1e-4b09-4d2a-9e51-2f8c3d7b9a10",
"title": "Retry payment callback on timeout",
"status": "doing",
"tags": ["dev", "urgent"]
},
"applied_actions": [
{"op": "set", "field": "status", "from": "todo", "to": "doing"},
{"op": "add_tags", "value": ["dev"]}
]
}

Highlighted: what the server actually did — also exactly what dry_run returns for a confirmation dialog.

Errors

409Your view is stale: the card no longer matches the source column query. The response carries the card's current snapshot — refresh and retry. The server never writes fields based on an outdated view.
The whole move is idempotent (set / add / remove are all idempotent), so replays are safe — no idempotency key needed. Not to be confused with PATCH /api/v1/tasks/{id}/move, which moves a task to a different folder path.
GET/api/v1/boards/{boardId}/summaryDraft

Glance at the whole board

Per-column counts plus the first few cards of each column, in one call — no per-column paging just to see how the board stands.

Query parameters

FieldTypeDescription
sprintstringSame semantics as the column read: "active" by default.
previewintCards to preview per column; default 3, 0 = counts only.

Request

GET /api/v1/boards/7d55a212-30f5-4a41-a5da-b2f7f01f3bb9/summary?preview=2 HTTP/1.1
Host: api.oatnil.com
Authorization: Bearer <token>

Response

{
"board_id": "7d55a212-30f5-4a41-a5da-b2f7f01f3bb9",
"name": "Product launch",
"active_sprint_id": "3f6d9a52-88a1-4f0e-b1d4-6c1f2e7a9b31",
"columns": [
{"column_id": "col-todo", "name": "To Do", "total": 24,
"preview": ["Deep links broken on mobile", "Sign-up page A/B test"]},
{"column_id": "col-doing", "name": "Doing", "total": 9,
"preview": ["Retry payment callback on timeout", "Report export encoding bug"]},
{"column_id": "col-done", "name": "Done", "total": 132,
"preview": ["Login page refresh", "Notification grouping"]}
]
}
PATCH/api/v1/tasks/{taskId}/metadataLive

Works today at: PATCH /api/v1/todolist/{taskId}/metadata

Reorder in a column / assign to a sprint

Patches one metadata key per call. Reordering inside a column writes "order"; putting a card into a sprint writes "ud.sprint". Removing a key (taking a card out of a sprint) is the DELETE twin: DELETE /api/v1/tasks/{taskId}/metadata/{key}.

Request body

FieldTypeDescription
keyrequiredstringMetadata key, e.g. "order", "ud.sprint", "cf.priority".
valueanyAny JSON value. To remove the key entirely use the DELETE route, not null.

Request

PATCH /api/v1/tasks/7c2f6a1e-4b09-4d2a-9e51-2f8c3d7b9a10/metadata HTTP/1.1
Host: api.oatnil.com
Authorization: Bearer <token>
Content-Type: application/json

{
"key": "order",
"value": 12
}
The 200 response is the full task object (same shape as GET /api/v1/tasks/{id}).
GET/api/v1/tasks/{taskId}Live

Works today at: GET | POST | DELETE /api/v1/todolist/{taskId}

Open, edit, delete a card

The generic task detail family: GET reads (with notes and linked items), PATCH /api/v1/tasks/{taskId} updates, DELETE soft-deletes. The update is a partial update — every field is optional and an omitted field stays unchanged; for assignee, kickoff and deadline an explicit empty string means "clear". Note the verb change in the contract: today the update answers to POST at the old path; the target verb is PATCH, which is what the semantics have been all along.

Request

PATCH /api/v1/tasks/7c2f6a1e-4b09-4d2a-9e51-2f8c3d7b9a10 HTTP/1.1
Host: api.oatnil.com
Authorization: Bearer <token>
Content-Type: application/json

{
"status": "done",
"deadline": ""
}
This sample marks the card done and clears its deadline in one call. Full field list (title, description, status, path, tags, assignee, kickoff, deadline, derivedFromId, metadata) is in OpenAPI under updateTodolistItem.

Sprint rhythm

Sprints are tasks (ud.type = sprint); membership is the ud.sprint metadata key. Reads go through the free query gate.

POST/api/v1/tasks/queryLive

Works today at: POST /api/v1/todolist/query

Backlog, sprint members, any free query

The global query gate: SQL-like syntax over built-in fields, tags, ud.* and cf.* metadata. Backlog views, sprint member lists and every ad-hoc slice go through here. Free querying is a product feature and stays — the semantic endpoints above only absorb the most common shape ("give me a column").

Request body

FieldTypeDescription
querystringQuery string.
sortobjectOptional sort override; see OpenAPI (SortDTO).
page / pageSizeintNote the camelCase pageSize here (the board query gate uses page_size).
viewstring"" or "full" returns the historical payload; "lite" drops description, notes and share links for card rendering.

Request

POST /api/v1/tasks/query HTTP/1.1
Host: api.oatnil.com
Authorization: Bearer <token>
Content-Type: application/json

{
"query": "(status = 'todo') AND ('q4' IN tags) ORDER BY metadata.order ASC",
"page": 1,
"pageSize": 20,
"view": "lite"
}

Response

{
"data": [
{
"id": "4de19b0c-2f6a-47d3-b8a1-9c05e2f7d614",
"title": "Sign-up page A/B test",
"status": "todo",
"path": "",
"tags": ["q4", "growth"],
"checkInCount": 0,
"metadata": {"order": 5},
"created_at": "2026-09-18T02:11:09Z",
"created_by": "9a7e5c21-1b3f-4e8a-92d6-04c8f1a7d502",
"updated_at": "2026-09-24T11:40:52Z",
"updated_by": "9a7e5c21-1b3f-4e8a-92d6-04c8f1a7d502"
}
],
"total": 14,
"page": 1,
"pageSize": 20,
"totalPages": 1
}

In "lite" view the description field is absent (not empty) — an absent description means "fetch the task to get the body", never "this card has no body".

POST/api/v1/tasks/{sprintId}/close-sprintLive

Works today at: POST /api/v1/todolist/{sprintId}/close-sprint

Close a sprint

The close ceremony in one server-side transaction: rolls unfinished members to the target, settles velocity, writes the retro note, marks the sprint done. Milestone-ordered — the sprint only flips to done after every rollover succeeded, so retries are safe.

Request body

FieldTypeDescription
rolloverstring"backlog" or an open sprint id. Omitting the field means "no choice made" — the server refuses that when unfinished members exist, instead of silently sweeping them to the backlog. An empty body is legal when the sprint is already clean.

Request

POST /api/v1/tasks/3f6d9a52-88a1-4f0e-b1d4-6c1f2e7a9b31/close-sprint HTTP/1.1
Host: api.oatnil.com
Authorization: Bearer <token>
Content-Type: application/json

{
"rollover": "backlog"
}

Response

{
"sprintId": "3f6d9a52-88a1-4f0e-b1d4-6c1f2e7a9b31",
"target": "backlog",
"completed": 11,
"rolledOver": 4,
"rotatedBoardIds": ["7d55a212-30f5-4a41-a5da-b2f7f01f3bb9"],
"skippedBoardIds": [],
"velocity": 23.5
}

Highlighted: the boards whose active-sprint pointer this close moved (rotatedBoardIds) and the ones it left for a group admin (skippedBoardIds).

Errors

400Not a sprint task, bad rollover target, or unfinished members with no rollover chosen.
The close also repoints the activeSprintId of every board pointing at this sprint — to the target sprint, or cleared for a backlog close — and reports them: rotatedBoardIds are the boards it moved; skippedBoardIds are shared boards the caller cannot write, whose pointer is left for a group admin to reconcile (a view preference must not fail the close).

Board lifecycle

Board CRUD and sharing; until the rename ships these answer under /api/v1/kanban/boards/*. One thing to know: PUT /api/v1/boards/{id} is the single write path for column definitions — the server materializes column actions and backfills missing column ids on every board write. Field shapes are in OpenAPI.

POST/api/v1/boardsCreate a board (name and board_type "private" | "shared" required; columns optional).
GET/api/v1/boardsList the boards you can see.
GET/api/v1/boards/{id}Get one board with its column definitions and settings.
PUT/api/v1/boards/{id}Update name, columns, default_tags, metadata — the column-definition write path.
DELETE/api/v1/boards/{id}Delete the board; its tasks are preserved.
POST/api/v1/boards/{id}/shareShare with a group (group_id, permission "r" | "rw").
DELETE/api/v1/boards/{id}/shareRemove group sharing.
POST/api/v1/boards/preview-actionsPreview the column actions a query would auto-generate — read-only, used while editing columns.

Samples use fabricated data. Draft samples show the agreed contract and may change before shipping. This page is maintained by hand against the backend DTOs; when it disagrees with /api/openapi.json for a Live endpoint, the OpenAPI file wins — please report the mismatch.