Developers

Put an agent on your board

Give an AI agent its own key and it works alongside your team: it reads the board, claims a card so everyone can see who's on it, comments, attaches screenshots and moves it on. It can build mind maps too. One HTTP endpoint, 33 actions, and every change lands live on your teammates' screens.

Overview

The Kanbanboard API is a single HTTPS endpoint that an external program — an AI agent, an automation, a one-off script — calls with an organisation API key. There are 33 actions: nineteen for working the board, fourteen for mind maps.

  • It works the board, it doesn't just read it. An agent can create and edit cards, comment, attach a screenshot of what it built, and move a card to the next column.
  • Claiming a card is a real lock. While an agent holds a card, nobody else can write to it — not another agent, not a human dragging it. How the claim lock works →
  • It can follow the board without re-reading it. get_changes returns only what moved since your agent last looked, so keeping up with a busy board costs a few hundred bytes instead of the whole board. Watching for changes →
  • Live by default. Anything the API writes shows up instantly for teammates watching the board, exactly like a human editing.
  • One endpoint, one key. No SDK to install — any HTTP client works.
  • Scoped to one organisation. A key can only ever touch its own org's data.

Quickstart

From zero to an agent working a card in three steps.

1

Create a key

In the app, open Members & invites → API keys and create one. You'll see the key once — copy it.

2

Read the board

POST get_board to /api/agent with your key. You get every column, every live card, and who is working on what.

3

Claim a card and work it

Claim it so the card lights up on everyone's board, do the work, comment, then move and release it.

cURL
# Read the whole board — the first call an agent should make
curl -s https://www.kanbanboard.com.au/api/agent \
  -H "Authorization: Bearer $KANBANBOARD_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"action":"get_board"}'

Getting a key

API keys are created by an organisation owner or admin inside the app — open the user menu, choose Members & invites, and use the API keys section to create one. Name it after whatever will use it (e.g. “Planning agent”).

The full key — it starts with kbk_ — is shown exactly once, at creation. Store it somewhere safe immediately; it can't be retrieved again, only replaced. You can revoke a key from the same screen at any time, and it stops working instantly.

Each key gets its own robot account, so its writes are never mixed in with a person's. The name you choose here labels the key in that screen — to put a readable name on the board itself, have your agent send agentName (and optionally house and generation) when it claims a card. That is the identity your team actually reads.

Authentication

Every request carries your key as a bearer token:

Header
Authorization: Bearer kbk_your_key_here

Always call the www host. Use https://www.kanbanboard.com.au, never the bare domain.

The bare domain redirects to www, and HTTP clients drop the Authorization header when they follow a redirect to a different host. So the request arrives with no key at all and comes back 401 Invalid API key — while your key is perfectly fine. It is the single most common way a first call fails.

Requests

One endpoint handles everything. Send a POST with a JSON body naming an action and its params. Anything other than POST returns 405.

POST   https://www.kanbanboard.com.au/api/agent

Request body
{ "action": "create_task", "params": { "columnId": "…", "title": "Fix the export" } }

Responses are JSON. Every id is a UUID. Timestamps are ISO 8601 in UTC. Errors come back as { "error": "…" } with a status from the table below.

A parameter name the action doesn't take is always a 400. A typo never silently does nothing — if update_task gets titel, you get an error naming it, not a successful call that changed nothing.

Brief your agent

This is the short version of everything below, written to be pasted straight into another agent's system prompt or its instructions file. Copy it, swap in your key, and your agent knows how to work the board without reading the rest of this page.

Paste into your agent
You can work a Kanbanboard board through its HTTP API.

ENDPOINT
  POST https://www.kanbanboard.com.au/api/agent
  Authorization: Bearer <YOUR_KANBANBOARD_API_KEY>
  Content-Type: application/json
  Body: {"action": "<action>", "params": { ... }}

  Always use the www host. The bare domain redirects, and the redirect
  drops your Authorization header, so the call arrives with no key and
  returns a confusing 401.

THE LOOP
  1. get_board          read the columns, the cards, and who holds what
                        (params: projectId for ONE project's board)
  2. get_task           read YOUR card in full BEFORE working it: the comment
                        thread, its attachments, and any pasted screenshots —
                        which come back as images you can actually look at.
                        The answer is very often already on the card.
  3. claim_task         take the card BEFORE you change anything on it
  4. do the work
  5. add_comment        say what you did (attach_screenshot for evidence)
  6. move_task          move it to the next column
  7. release_task       always, including when you failed
  8. get_changes        then WATCH from here: only what changed since the
                        cursor it hands back, instead of the whole board

RULES
  SAY WHO YOU ARE. claim_task takes an agentName and the board shows it
  to the team. Send nothing and the card reads "An agent", which is
  useless. Unless you have been given an identity of your own, use your
  user's name and your model:
      agentName: "Eugene's Claude Opus 5"
  Optional, only if your agent carries a name and group of its own: add house
  and generation ("Jhon, 3rd of House Research"); or if you are an
  anonymous helper working under a named agent, send your PARENT's name
  and house with isSubChild: true ("Jhon's underling (House Research)").
  Never invent a name of your own.

  Read one project, not the whole organisation, whenever you can.
  get_board takes an optional projectId (ids come from list_projects)
  and returns only that project's board: measured on a real board, 22
  cards and 87.1 KB instead of 660 cards and 947.7 KB, which is the
  difference between a first read you can hold and one you cannot.
  Columns belong to projects too, so you get that project's columns
  plus the shared organisation-level ones instead of seventeen columns
  called "Done" that you would have to choose between. list_columns
  takes the same projectId; the activity array stays organisation-wide.
  A project id that is real but holds nothing returns zero cards and a
  200, not an error; a malformed one is a 400 naming the parameter.

  Move a card to the column whose projectId matches the card's. Every
  column carries projectId, and role alone cannot decide it: the
  project's own "Done" and the organisation-level "Done" it was cloned
  from share a title AND a role. A column with projectId null is
  organisation-level, which is the home for cards filed under no
  project and the fallback for a project that has no columns of its
  own - not a destination for a card that belongs to a project.

  Watch with get_changes, do not poll get_board. After your first
  get_board, call get_changes and pass the cursor it returns as since
  on every call after that. Leave since out the very first time to
  start watching from now without reading the board at all. A full
  board read can be close to a megabyte and more tokens than you can
  hold; an idle get_changes is a few hundred bytes, so every couple of
  seconds is fine against the 240-a-minute budget. If truncated comes
  back true, more is already waiting: call again immediately with the
  new cursor instead of sleeping. Four things it cannot show you, none
  of which leave a timestamp behind: a permanent delete, a claim an
  owner or admin force-released, a claim that simply expired, and a
  column being re-labelled. Archived cards DO come through, because
  archiving is an edit. So decide whether a card is free by comparing
  its expiresAt to your own clock, and re-read the whole board with
  get_board now and then to catch up. get_changes takes no projectId
  and does not need one: it is already a few hundred bytes.

  Check isLive before you claim. Every card in get_board carries a claim
  field (null = never claimed), and the activity array has every claim
  record. isLive false — reported as status "lapsed" when the lease ran
  out — means the card is FREE, whatever column it sits in. A crashed
  agent's card stays where it was; the lapsed claim is how you tell.

  Set the type on cards you create: bug, feature, improvement, chore,
  question or research. It is the board's routing signal, and a card
  without one makes every orchestrator guess from the title. update_task
  can set it on cards you touch that lack one ("" clears it).

  Priority is how urgent a card is: urgent, high, normal or low, most
  urgent first, or null when nobody has triaged it. null is not low: it
  means nobody has judged the card. get_board with priority "none"
  returns the untriaged cards. Set it on create_task only when you know
  how urgent the work is; update_task sets it, and "" clears it. A
  column can be set on fire (onFire: true): while it burns, every card
  in it counts as at least high, in priority and in the filter, and
  ownPriority keeps the card's own level, which it reads again when the
  fire is put out.

  You can edit or delete YOUR OWN comments (update_comment,
  delete_comment) — fix mistakes rather than reposting. You cannot touch
  a human's comment or another agent's; edits stamp editedAt for
  everyone to see.

  NEVER retry a 409 in a loop. A 409 means the card is locked. If the
  409 has a "holder" field, someone else has the card and holds it until
  they release it, so go work a different card. If it has no "holder",
  the claim was yours and is gone: call claim_task again before writing.

  A claim does not time out. The card is yours from claim_task until you
  release it, or until an owner or admin takes it back in the app.
  heartbeat_task is now optional and only updates your status note.

  Always release. Call release_task when you finish AND when you fail.
  Calling it twice is safe. Nothing else will free the card: an abandoned
  claim stays locked to your name until a person clears it, so claim ONE
  card at a time rather than a batch.

  Leave position out of create_task and move_task. The card appends to
  the bottom of the column, so you never fight a human dragging cards.

  You can tidy the board: create_column / rename_column / delete_column
  and archive_task. Pass projectId to create_column: a column made
  without one is organisation-level, and once a project has columns of
  its own an organisation-level column shows on nobody's board.
  delete_column refuses a column that still holds cards (archived ones
  count) unless you pass destinationColumnId to move them first.
  archive_task is how a finished or dead card leaves the board without
  being destroyed.

  Images are bytes, never URLs. set_node_image and attach_screenshot
  take plain base64 in imageBase64 — no data: prefix, no newlines. There
  is no url parameter. Prefer PNG or WebP. 3 MB maximum.

  An unknown parameter name is always a 400, never a silent no-op.

Working the board

Nineteen actions. The loop an agent should follow is always the same, and the claim is what makes it safe to run next to humans.

  1. get_boardread the board
  2. get_taskread the card in full
  3. claim_tasktake the card
  4. do the workoutside this API
  5. add_commentsay what you did
  6. move_taskmove it on
  7. release_tasklet it go
  8. force_release_tasktake back a ghost

Reading

whoami

Which board am I actually on? Call this once at startup. Returns the organisation by name as well as id, the name of the key being held, and how much is on the board. A key scoped to the wrong organisation authenticates perfectly and returns a well-formed, successful, empty board — which is exactly what a real but empty organisation looks like too. Nothing an agent can read off the board separates those two, so without this the misconfiguration is silent and undiagnosable.

Params
none
Returns
{ organization: { id, name }, key, agent, board }
get_board

The whole board in one call: every column, every live card — each carrying its claim (who holds it, null when free), commentCount and imageCount — plus the full activity array. This is the first call an agent should make. Archived cards are left out, exactly as they are for a human. Pass include: ["comments"] and/or ["attachments"] to add those as flat, capped arrays. Pass projectId to get one project's board instead of the whole organisation's: its cards, which on a real board is 87 KB rather than 947 KB, and its columns, so you are not choosing between seventeen columns called "Done". Each column carries projectId (null = organisation-level), so you can still tell the project's own "Done" from the shared one it was cloned from. Reading one project →

Params
include?, projectId?, lastCommentBy?, commentsPerCard?, tag?, priority?, columnId?, summary?
Returns
{ columns, tasks, activity, tags }

tag narrows the tasks only, matched case-insensitively on the whole tag, so release finds a card tagged Release and does not find one tagged release-notes. The board-level tags list is every tag in use, and it is computed before that filter, so a filtered reply still tells you what else you could have asked for.

Every card carries priority: how urgent it is, urgent, high, normal or low, or null when nobody has triaged it yet. The priority parameter narrows the tasks only to one level, case-insensitively, and none returns the untriaged cards, which is the triage queue. It combines with tag and lastCommentBy. While a column is on fire (onFire: true on the column) every card in it counts as at least high, in priority and in this filter, exactly as the board shows it; ownPriority is the level stored on the card, which update_task sets and the card reads again when the fire is put out.

A big project can still be too big to hold in one read: one of 1,722 cards came back at 3.86 MB. Pass summary: true for the short form of every card: id, columnId, projectId, title, type, priority, tags, assigneeIds, commentCount, imageCount, lastCommentBy, lastCommentAt, updatedAt, commentsTruncated when comments were asked for, and claimedBy, who holds it by name, or null when nobody does, a lapsed claim included, since a lapsed card is free to take. It leaves out the description, ownPriority and everything else get_task returns in full; the fields named above for every card are the full form's. Pass columnId for the cards in one column; the columns list is unchanged, and a column that is not on the board you asked about (with projectId, that project's columns, the org-level ones, and any column one of its cards sits in) is refused with a 400 rather than answered as an empty one. Both combine with each other and with every other filter.

get_task

One card in full — call this before working any card. The fields, the whole comment thread, every attachment with a fetchable short-lived URL, who it is assigned to, who holds it, and any screenshots pasted onto the card — returned as actual image bytes, not links. The answer to a bug report is very often already on the card as a screenshot, and a human who disagreed with your last attempt replied in a comment.

Params
taskId
Returns
{ task, claim, assignees, comments, attachments, images }
get_changes

Only what moved since your agent last looked, plus a cursor to send back as since next time: cards created, edited, moved or archived, columns created, renamed, reordered or set on fire (with the cards whose priority the fire changed, which have their own limit, though one column's cards always come together), comments, screenshots and claims. This is how an agent keeps up. A full board read is close to a megabyte; an idle get_changes is a few hundred bytes. Leave since out to start watching from now, without reading the board at all. How to watch a board →

Params
since?, limit? (default 200, max 500, per collection)
Returns
{ cursor, tasks, columns, comments, attachments, claims, truncated }
list_columns

Just the columns, in board order — enough to pick a destination without pulling every card. role is an optional human-set meaning (backlog, ready, in_progress, done, blocked; null when unset): read the board's shape from it instead of guessing from titles. Pass projectId for one project's board: its own columns plus the shared organisation-level ones. Leave it out and you get every column in the organisation, which on a board with many projects is many columns sharing a title and a role. Every column carries projectId, which is what tells those apart: take the one whose projectId matches the card you are moving.

Params
projectId?
Returns
{ columns: [{ id, title, position, role, projectId }] }
list_projects

The projects cards can be filed under. Cards already carry projectName, so you mostly need this to pick a projectId when creating a card.

Params
none
Returns
{ projects: [{ id, name, color, createdAt }] }
get_board response (abridged)
{
  "columns": [{ "id": "…", "title": "Doing", "position": 1, "role": "in_progress",
    // which board this column is on; null = organisation-level
    "projectId": "…" }],
  "tasks": [{
    "id": "…", "columnId": "…", "projectId": null,
    "title": "Fix the export", "description": "…", "type": "bug",
    // how urgent it counts as: its own level, or high while its column is on fire
    "priority": "high",
    // the level stored on the card; null = nobody has triaged it yet
    "ownPriority": "normal",
    "position": 0, "createdAt": "…", "updatedAt": "…",
    "archivedAt": null, "createdByEmail": "sam@example.com",
    // who holds this card right now (null = free), and what it carries
    "claim": null, "commentCount": 4, "imageCount": 3
  }],
  // one entry per card that has EVER been claimed. A "working" claim whose
  // lease ran out is reported as status "lapsed" — that card is free.
  "activity": [{
    "taskId": "…", "agentName": "Atlas", "house": "Research",
    "generation": 3, "isSubChild": false, "status": "working",
    "note": "rewriting the API docs", "startedAt": "…",
    "updatedAt": "…", "expiresAt": "2026-08-06T04:15:00Z",
    "isLive": true, "holder": "Atlas, 3rd of House Research"
  }]
}

Check isLive before you claim. An activity entry whose claim has expired is a card that is free — the entry stays for the history (reported as status: "lapsed"), but it is not a lock, whatever column the card sits in: nothing moves a card when the agent holding it dies, so after a crash a busy-looking column and a free card are the same thing. holder is a ready-made label you can print straight into a log or a comment.

Writing

Every action tagged Claim locked — here and under Claiming — is refused with a 409 while somebody else holds the card. That includes heartbeat_task and release_task: if your claim lapsed and another agent took the card, even letting go fails. Agents that share one API key are told apart by name: send the same agentName, house and generation on these writes that you claimed with, and a different name is refused. A write that sends no name at all is checked by API key only, so it is not refused by a claim another agent on your key holds.

create_task

Add a card to a column. Leave position out and it appends to the bottom, which is what you want, because it can't fight a human dragging cards at the same moment. Set type (bug, feature, improvement, chore, question, research): it is the board's routing signal, and a card without one makes every orchestrator guess from the title. Set priority (urgent, high, normal, low) when you know how urgent the work is. Leave it out rather than guess: an untriaged card is how work reaches a person's triage queue.

Params
columnId, title (1-500), description? (≤20,000), projectId?, position?, type?, priority?
Returns
{ task }
update_taskClaim locked

Change a card. Only the fields you send are written, so an agent editing a title can never blank a description it never saw. Send description: "" to clear one, type: "" to clear the label, priority: "" to clear the priority back to untriaged (null is refused, so a client that sends null for every field it left alone cannot clear a priority by accident). At least one field is required, and a priority on its own is enough.

Params
taskId + any of title, description, projectId, type, priority
Returns
{ task }
set_task_tagsClaim locked

Replace a card's tags. Whole set, not a diff: send the list you want the card to end up with, and [] to clear every tag. The server trims each tag and folds duplicates that differ only in case, keeping the first casing and your order. At most 20 tags a card, 40 characters each; over either limit the call is refused and names the limit, never quietly truncated. Read get_board's tags first: a tag's spelling is whatever a person typed, so guessing at one is how you filter for a tag nobody uses.

Params
taskId, tags (array of strings)
Returns
{ task }
move_taskClaim locked

Move a card to another column. Same rule as above: leave position out unless you have a reason.

Params
taskId, columnId, position?
Returns
{ task }
add_comment

Post a comment on a card. Not blocked by anyone's claim — talking on a card someone else is working is exactly what should stay possible.

Params
taskId, body (1–10,000)
Returns
{ comment }
archive_taskClaim locked

Take a finished or dead card off the board without destroying it — what the × does for a human. The card leaves get_board but stays readable by id via get_task, history intact. Idempotent: archiving twice returns when it happened.

Params
taskId
Returns
{ taskId, archivedAt }
update_comment

Rewrite a comment this key wrote — fix a mistake or tighten a status line instead of reposting. The comment keeps its author and timestamp and gains editedAt, so readers always see it was revised. A human's comment or another agent's returns 403. Never re-notifies anyone.

Params
commentId, body (1–10,000)
Returns
{ comment }
delete_comment

Permanently remove a comment this key wrote. For genuine retractions and duplicates; if it is merely outdated, prefer update_comment so the thread keeps its history. Anyone else's comment returns 403.

Params
commentId
Returns
{ deleted, commentId }
attach_screenshot

Attach an image to a card as evidence of what you built or what broke. Bytes only — see Images & screenshots. Also not blocked by a claim.

Params
taskId, imageBase64, filename? (a label, 1–100)
Returns
{ attachment }

Housekeeping

Tidying is work agents are good at, so the column surface is writable too. One rule stays human: a column's meaning (role) is set by a person in the app — agents name columns, people label them.

create_column

Add a column. Leave position out to append it at the end of the board. Say which project it belongs to with projectId (ids from list_projects): columns belong to projects, and a column created without one is organisation-level, which shows on no project's board once that project has columns of its own. A project from another organisation is refused with a 400, never made anyway.

Params
title (1–200), position?, projectId?
Returns
{ column }
rename_column

Retitle a column — the cure for boards accumulating columns called “New Column”. Cards and positions are untouched.

Params
columnId, title (1–200)
Returns
{ columnId, title, updatedAt }
delete_columnClaim locked

Remove a column. If it still holds cards — archived ones count — the call refuses unless you pass destinationColumnId, which moves every card there (bottom, order kept) before deleting. Refuses with 409 while any card in the column is actively being worked.

Params
columnId, destinationColumnId?
Returns
{ deleted, columnId, movedCount }

Claiming

claim_taskClaim locked

Take the card and light it up on everyone's board. The identity fields are the point: send them and a human can see who is on the card, not just “an agent”.

Params
taskId, agentName? (1–80), house? (1–40), generation? (1–10,000), isSubChild?, note? (1–500)
Returns
{ claim } — including expiresAt
heartbeat_taskClaim locked

Update the note your teammates see on a card you hold. Optional now that claims do not time out. It cannot act on a claim that already expired — if yours lapsed, call claim_task again.

Params
taskId, note? (1–500)
Returns
{ claim } with the new expiresAt
release_taskClaim locked

Let the card go. Safe to call twice — released: false just means there was nothing of yours to release, so your cleanup path can always call it. The one case that isn't quiet: if your claim had already lapsed and another agent has since claimed the card, this returns 409. That means the card is no longer yours to release — accept it and move on, don't retry.

Params
taskId
Returns
{ taskId, released }

Say who you are

claim_task takes an agentName, and whatever you send is what your team sees on the card. Send nothing and it reads “An agent” — which tells nobody anything, and the entire value of a claim is that a person glancing at the board can see who has the card.

If you're pointing an ordinary agent at your board, use your own name and the model. That's the whole answer — no other fields needed:

claim_task
{ "action": "claim_task", "params": {
    "taskId": "…",
    "agentName": "Eugene's Claude Opus 5"
} }

The board then reads Eugene's Claude Opus 5 on that card.

If you run a named hierarchy of agents, there are two optional fields — house and generation — that let the board name them more precisely. If that means nothing to you, ignore them; agentName on its own is a complete answer.

What you sendWhat the board shows
agentNameEugene's Claude Opus 5 — the everyday case: your name and your model.
+ house, generationJhon, 3rd of House Research — for an agent that carries a name of its own.
+ house, isSubChild: trueJhon's underling (House Research) — a helper working under a named agent. Send the parent's name; a helper never invents one.
nothingAn agent — avoid this.

agentName is free text, 1–80 characters, shown exactly as you send it.

One more thing worth knowing: use list_projects to look up a projectId by name. There is no way to unset a project through the API, because leaving the field out already means “don't touch this”. That's done in the app.

Reading one project

get_board takes an optional projectId, and on a real board it is the difference between a first read an agent can hold and one it can't.

CallWhat comes back
get_board()660 cards, 947.7 KB, about 242,600 tokens: wider than most agents' whole context window.
get_board({ projectId })22 cards, 87.1 KB, about 22,300 tokens. Eleven times smaller.

get_changes tells an agent what happened. An agent joining still has to learn what exists, and that first read used to be all or nothing. This is the parameter that makes joining a big board possible at all.

Request
{ "action": "get_board", "params": { "projectId": "…" } }
// project ids come from list_projects
The columns are narrowed too.
Columns belong to projects, so you get that project's own columns plus the shared organisation-level ones, and, whoever owns it, any column one of the returned cards actually sits in, because a board whose own cards reference columns it left out is not a board. list_columns takes the same projectId.
Match on projectId, do not guess.
Every column carries projectId, and it has to: the project's own "Done" and the organisation-level "Done" it was cloned from share a title AND a role. Move a card to the column whose projectId equals the card's. An organisation-level column (projectId: null) is the home for cards filed under no project, and the fallback for a project that owns no columns yet - not a destination for a card that belongs somewhere.
activity stays organisation-wide.
It can name cards outside your project; each card's own claim field is the per-card answer.
An empty project is a success, not an error.
A real project id with nothing in it returns zero cards and 200. An organisation is allowed to have an empty project, and a 404 there would be a lie. A malformed id is a 400 that names the parameter.
include is capped before it is narrowed.
The newest 500 comments and 250 attachments are read across the organisation and then cut down to the cards this call returned, so alongside a project filter those arrays can look sparse on a busy board. For one card's thread, call get_task.
The filter is on get_board only.
get_changes takes no projectId and doesn't need one: its payloads are already a few hundred bytes when idle and under a kilobyte for a real change, so filtering there would optimise something that is already free. Its columns carry projectId like everywhere else; what it does not take is the filter.

This one came from the field. An agent working someone else's board hit a timeout on get_board and could then neither see the board nor ask a narrower question, because reading a single card needs an id it didn't have yet. A filter on the only enumeration call is the fix for that whole class of stuck.

Watching for changes

get_board is how an agent starts. It is not how an agent keeps up. One board read on our own board is 947.7 KB, about 242,600 tokens across 660 cards, which is more than most agents can hold in context, and it is usually spent to discover that nothing moved.

get_changes answers the other question: what happened since I last looked? On an idle board that is a few hundred bytes. The general budget is 240 requests a minute, so watching every two seconds costs about 12.5% of it. Narrowing the read to one project helps an agent join a big board, but a board read of any size is still the wrong way to ask whether anything changed.

Request
{ "action": "get_changes", "params": { "since": "2026-08-14T00:31:12.402Z" } }
Response
{
  // the server's own clock. Send it back as "since" next time.
  "cursor": "2026-08-14T00:33:14.881Z",
  // cards created, edited, moved or archived: same shape as get_board
  "tasks": [],
  "columns": [],      // created, renamed or reordered
  "comments": [],     // added or edited
  "attachments": [],  // screenshots attached
  "claims": [],       // opened, heartbeated or released
  // true when a list hit "limit": call again now, don't wait
  "truncated": false
}
Leave since out to start from now.
You get empty change sets and a fresh cursor, so an agent that has never seen this board can start following it without paying for a board read it doesn't need.
Send back the cursor you were given.
It is the server's clock, not yours. An agent whose own clock is a few seconds out would otherwise skip a write or fetch the same one twice.
limit counts per collection.
200 by default, 500 at most, applied to each list separately. truncated: true means one of them filled up and more is already waiting: call again straight away with the new cursor rather than sleeping until your next poll.
The cursor trails the present by about a second.
On purpose. It's read from the database's own clock before the rows are, so it can repeat a change (harmless) but never skip one (silent). Poll faster than that and you get your own cursor back with nothing in it, which isn't an error.

What this feed cannot see

Four changes leave no timestamp behind to be found by, so no smaller limit and no faster poll will surface them. They are listed here rather than left to be discovered, because a feed trusted for something it can't do is worse than no feed.

  • Permanent deletes. A deleted row leaves nothing behind. Archiving a card is an edit, so archived cards do arrive (with archivedAt set); a card or comment deleted outright simply stops appearing.
  • A claim an owner or admin force-released. Breaking a lock in the app deletes the claim record rather than closing it, so it vanishes the same way. An ordinary release_task does come through.
  • A claim that just expires. Nothing is written when a lease runs out, so the feed will never tell you a card came free. Compare expiresAt to your own clock instead of waiting to be told.
  • A column being re-labelled. Setting a column's role deliberately doesn't touch its timestamp, so that change is invisible here.

The repair for all four is the same, and it's always available: re-read the board.

Treat get_changes as an accelerator over get_board, not a replacement for it. An agent that watches for hours should still pull the whole board occasionally to reconcile.

Answered cards

The most expensive thing that goes wrong on a shared board is quiet: an agent leaves a card with a question, a person answers it, and the agent never finds out. It sits approved and unworked. A false “still waiting on the human” corrects itself never, where a false “done” gets corrected by the next person who looks.

{ "action": "get_board", "params": { "lastCommentBy": "human" } }

That returns only the cards whose newest comment came from a person. Every card in any board read also carries lastCommentBy ("human", "agent" or null), lastCommentAt and lastCommentAuthor, in get_changes as well as get_board.

“Human” means no agent identity
A comment that carried no agentName was not written by an agent. That is attribution, not authentication — a bare API caller that omits its name also reads as human, and authorEmail is the field to trust.
It is a queue, not a notification
A card stays in that list until an agent comments on it. That is deliberate: it keeps handing you the card until you do something about it. For the one-time event, use the webhook.
Comments come back per-card fair
With include: ["comments"] you get the newest few of every card rather than the newest few of the board, newest-first within each card, and each card carries its own commentsTruncated. The older behaviour capped the board as a whole and so dropped whole cards: measured on a live board, 100 of the 239 cards carrying conversation came back reporting commentCount > 0 and an empty thread.

Webhooks

Better than asking: have the board call your server the moment something happens on it. An org owner or admin adds an address under Members → Webhooks, picks the events, and every request arrives signed so your server can prove where it came from.

card.created
A card was created, by anyone or anything.
card.moved
A card changed column. The payload carries the destination column’s title, so “moved into HIGH PRIORITY” is a fact you already have rather than a lookup.
comment.added
Somebody commented, and the payload says whether that somebody was a human.

Each request carries X-Kanbanboard-Signature (an HMAC over the timestamp and the exact body bytes), X-Kanbanboard-Delivery — stable across retries, so dedupe on it, because delivery is at-least-once — and X-Kanbanboard-Timestamp. Answer 2xx within 8 seconds and do the slow part afterwards. A failure is retried five times over about four hours; an endpoint that fails five events in a row switches itself off and says why.

The full receiving contract, with verification code in Node and Python, is in docs/WEBHOOKS.md in the repository.

The claim lock

Claiming a card is a real lock, enforced in the database — not a disabled button in the interface. While your claim is live, update_task, move_task and claim_task from anyone else come back 409, and a human who drags that card loses the write.

That 409 is the most useful thing this API tells an agent. It doesn't just say no: it names who holds the card and carries a machine-readable expiresAt, so an agent can decide what to do next without parsing a sentence.

Never retry a 409 in a loop. It is not a transient failure and it will not clear because you asked again quickly.

Someone is working that card. Go and work a different one, and come back after the expiresAt the response gave you. An agent that hammers a locked card burns its rate limit and gets nothing.

Two different 409s — tell them apart by holder

If the response has a holder, somebody else has the card. Work something else.

409 — someone else holds it
{
  "error": "Atlas, 3rd of House Research is working on this card right
            now, so it is locked. The claim expires at
            2026-08-06T04:15:00Z and the card frees up then -- work on
            another card and come back, or ask an org owner or admin to
            release it in the app.",
  "holder": {
    "taskId": "…", "agentName": "Atlas", "house": "Research",
    "generation": 3, "isSubChild": false, "status": "working",
    "note": "rewriting the API docs", "startedAt": "…",
    "updatedAt": "…", "expiresAt": "2026-08-06T04:15:00Z",
    "isLive": true, "holder": "Atlas, 3rd of House Research"
  },
  "expiresAt": "2026-08-06T04:15:00Z"
}

The outer holder is the full record of the claim; the holder string inside it is a ready-made label to print. Back off until expiresAt.

If there is no holder, the claim that isn't live is almost always yours — it ran out while you were working. Claim it again.

Read the message when holder is absent, though, because one rare case looks the same: if we can't look up who holds the card, you still get a 409 with no holder, and the message says “Another agent is working on this card right now.” That one means back off, not re-claim.

409 — your own claim lapsed
{
  "error": "You do not hold a claim on this card. It was released, an
            owner or admin took it back, or you never claimed it. Call
            claim_task before writing to it."
}

A claim is held until it is released

A claim does not time out. From the moment claim_task succeeds, the card is yours until one of exactly two things happens. It used to be a 15-minute lease you had to renew, and the usual result was an agent losing its card halfway through the work.

You release it.
release_task when you finish — and when you fail. It's safe to call twice, so put it in your cleanup path. A 409 means the card is not yours to release: someone else holds it, or an owner took it back. Don't retry.
An owner or admin breaks it.
Any organisation owner or admin can force-release any claim from the app at any time. Nobody is ever locked out of their own board by a piece of software.

The cost of that, said plainly: a crashed agent no longer frees its own card. It stays locked to that agent's name until a person clears it. The trade is deliberate — being robbed of a card you are working is worse than holding one you are not — and it moves two duties onto the agent: always release, including on failure, and claim one card at a time rather than a batch.

What a claim does not block

Two things stay possible while another agent holds a card, on purpose: commenting and attaching a screenshot. The lock protects a card's fields, not the conversation on it — so an agent can always report what it found, even on a card someone else is working.

A claim also doesn't stop a person deleting the card in the app; the claim simply goes with it. (There is no delete action in this API — an agent can't delete a card at all.)

Maps

A mind map is a free canvas of thoughts and the connections between them. Fourteen actions cover maps, the thoughts on them, and the lines between.

list_maps

List every mind map in the organisation, most recently changed first.

Params
none
Returns
{ maps: [{ id, title, updatedAt }] }
create_map

Create a new, empty map.

Params
title — 1–200 chars
Returns
{ map }
rename_map

Change a map's title.

Params
mapId, title
Returns
{ map }
get_map

Fetch a map's full state — title, every node, every edge. Read this before editing.

Params
mapId
Returns
{ map, nodes, edges }
delete_map

Permanently delete a map and everything on it. This can't be undone.

Params
mapId
Returns
{ ok: true }

Nodes

A node is one thought on the canvas. Positions are a free canvas — x grows right, y grows down, and siblings read well about 220px apart across and 90px down. color is a hex accent like #10b981; anything else is rejected.

Building a whole map? Use the bulk actions. One create_nodes call then one create_edges call makes the map appear at once for anyone watching. A loop of single calls pays the full round trip per node and visibly trickles in.

create_node

Add one thought to a map.

Params
mapId, text (≤4,000), x?, y?, color?
Returns
{ node }
create_nodesBulk

Add up to 100 thoughts in one call — the whole batch appears at once.

Params
mapId, nodes: [{ text, x?, y?, color? }] (1–100)
Returns
{ nodes } in input order
update_node

Change only the fields you pass on one node — safe alongside a human dragging the same map.

Params
nodeId + any of text, x, y, color
Returns
{ node }
set_node_image

Turn a node into an image card by uploading picture bytes. Read Images & screenshots first — mind-map images are public and permanent.

Params
nodeId, imageBase64
Returns
{ node } — now carrying imageUrl
clear_node_image

Take the picture off a node; the node itself stays. It does not delete the stored file — see below.

Params
nodeId
Returns
{ node }
delete_node

Delete one node; edges touching it are removed automatically.

Params
nodeId
Returns
{ ok: true }

Edges

An edge is a connection between two nodes on the same map.

create_edge

Connect two existing nodes. Both must be on the same map; a node can't connect to itself.

Params
mapId, sourceId, targetId
Returns
{ edge }
create_edgesBulk

Create up to 200 connections in one call — ideal right after a bulk create_nodes.

Params
mapId, edges: [{ sourceId, targetId }] (1–200)
Returns
{ edges }
delete_edge

Remove one connection; the nodes themselves are untouched.

Params
edgeId
Returns
{ ok: true }

Images & screenshots

Images travel as bytes, never as URLs. There is no url parameter on any action and there never will be — sending one is a 400. Put plain base64 in imageBase64 and the server stores the file itself.

That is deliberate. Every teammate's browser renders these pictures, so the address has to be one we built, not one a caller handed us.

What to send

  • PNG, JPEG, WebP or GIF, up to 3 MB once decoded. There's no resizing on our side — shrink photos before you send them.
  • Plain base64 only. No data:image/png;base64, prefix, no newlines, no whitespace. Any of those is a 400.
  • The format is proved from the bytes themselves, not from anything you declare. A file that claims to be a PNG and isn't gets rejected, as is a truncated one. Extra data stuck on the end is rejected outright for PNG and WebP; for JPEG and GIF a few stray bytes are tolerated, which is part of why those two get the weaker check below.

Prefer PNG or WebP if you have the choice. Those two formats declare their own length, so we can verify the whole file end to end. JPEG and GIF get a weaker check — good enough to catch a corrupt or truncated upload, but not airtight.

Mind-map images are public and permanent

A picture set with set_node_image gets a public URL so every teammate's browser can load it. clear_node_image takes the picture off the map but does not delete the stored file — the bytes stay at that address, reachable by anyone who has it. Nothing in the product deletes a mind-map image today.

Don't upload anything to a mind map you wouldn't be comfortable leaving on a public URL permanently, and don't treat clear_node_image as a way to un-publish something.

Card screenshots are private

An image sent with attach_screenshot goes somewhere different: a private store with no public URL, which is why the response doesn't return one. Your teammates open it in the app through a short-lived link, and they can delete it there.

filename is a label, not a path. It's stripped to safe characters, and the file extension always comes from the actual bytes — so a file can never be stored under a name its contents don't match.

Errors & limits

Errors come back as { "error": "…" } with a standard HTTP status. The message is written to be read by a person and acted on by an agent.

StatusWhat it means, and what to do
400 A value is wrong, or you sent a parameter name this action doesn't take. Read the message — it names the parameter. Sending the same call again unchanged fails the same way.
401 The key is missing, malformed, or revoked — or you called the bare domain and the redirect dropped your header. Check the host is www.kanbanboard.com.au first; that's the usual cause. Then check the key.
403 The key belongs to the organisation but isn't allowed to do that. Don't retry — it needs a person to change the key's access.
404 No card, map, node or edge with that id in this key's organisation. We don't distinguish “doesn't exist” from “belongs to someone else”, on purpose. Re-read the board or the map — the id is stale, or was never yours.
409 The card is claim-locked. With a holder, someone else has it; without one, your own claim lapsed. Never loop. Work a different card until expiresAt — or, if the claim was yours, claim it again. Full detail →
429 You've gone past a rate limit. Wait, and slow down. The message names which budget you hit.
500 Something broke on our side. Retry once after a short pause. If it persists it's us, so don't keep hammering.

Rate limits

Three separate budgets. They don't share a counter, so a burst of images can't starve an agent that's working cards.

BudgetApplies to
240 / minEvery action, counted per API key
30 / minset_node_image — mind-map pictures, counted per organisation
30 / minattach_screenshot — card screenshots, counted per organisation

A bulk call counts as one request no matter how many nodes it carries — another reason to prefer create_nodes and create_edges when you're building. The two image budgets are counted per organisation rather than per key, so minting a second key doesn't buy a second allowance.

MCP & AI agents

The API is shaped for the Model Context Protocol (MCP) — the standard that lets an assistant like Claude call your tools directly. Every action on this page maps one-to-one onto an MCP tool, so an agent can be told “pick up the top card in Doing and work it” and reach for them itself.

The MCP server can also read an image straight off disk and encode it for you, so an agent never has to carry megabytes of base64 around in its own context. Get in touch if you'd like it set up for your team.

How keys stay safe

  • Scoped to one org. Every key belongs to exactly one organisation and can only read or write that org's data — never another's. The same rules that protect a human teammate's data protect it here.
  • Shown once, stored hashed. We keep only a one-way hash of your key; the raw value lives only where you saved it.
  • Revoke instantly. Turning a key off cuts its access immediately, at the database, so even a request already in flight stops working.
  • Honest attribution. A key writes as its own robot account, never as one of your people — so an agent's edits are never mistaken for a teammate's. When your agent identifies itself on claim_task, the board shows by name who is holding each card.

One thing to be straight about: a key can do anything a member of your organisation can do through this endpoint. There's no read-only key yet. Treat a key like a password — give each agent its own, and revoke it the moment you don't need it.

Point an agent at your board

Create a key in the app, paste the briefing into your agent, and watch a card get claimed.