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_changesreturns 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.
Create a key
In the app, open Members & invites → API keys and create one. You'll see the key once — copy it.
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.
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.
# 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:
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
{ "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.
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.
- get_boardread the board
- get_taskread the card in full
- claim_tasktake the card
- do the workoutside this API
- add_commentsay what you did
- move_taskmove it on
- release_tasklet it go
- force_release_tasktake back a ghost
Reading
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 }
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.
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 }
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 }
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 }] }
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 }] }
{
"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.
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 }
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 oftitle,description,projectId,type,priority- Returns
{ task }
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 a card to another column. Same rule as above: leave position out unless you have a reason.
- Params
taskId,columnId,position?- Returns
{ task }
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 }
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 }
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 }
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 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.
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 }
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 }
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
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 }— includingexpiresAt
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 newexpiresAt
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:
{ "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 send | What the board shows |
|---|---|
agentName | Eugene's Claude Opus 5 — the everyday case: your name and your model. |
+ house, generation | Jhon, 3rd of House Research — for an agent that carries a name of its own. |
+ house, isSubChild: true | Jhon's underling (House Research) — a helper working under a named agent. Send the parent's name; a helper never invents one. |
| nothing | An 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.
| Call | What 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.
{ "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_columnstakes the sameprojectId. - 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 arole. Move a card to the column whoseprojectIdequals 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. activitystays organisation-wide.- It can name cards outside your project; each card's own
claimfield 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 a404there would be a lie. A malformed id is a400that names the parameter. includeis 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_boardonly. get_changestakes noprojectIdand 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 carryprojectIdlike 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.
{ "action": "get_changes", "params": { "since": "2026-08-14T00:31:12.402Z" } }
{
// 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
sinceout 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.
limitcounts per collection.- 200 by default, 500 at most, applied to each list separately.
truncated: truemeans 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
archivedAtset); 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_taskdoes 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
expiresAtto your own clock instead of waiting to be told. - A column being re-labelled. Setting a column's
roledeliberately 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
agentNamewas not written by an agent. That is attribution, not authentication — a bare API caller that omits its name also reads as human, andauthorEmailis 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 owncommentsTruncated. 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 reportingcommentCount > 0and 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.
{
"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.
{
"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_taskwhen you finish — and when you fail. It's safe to call twice, so put it in your cleanup path. A409means 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 every mind map in the organisation, most recently changed first.
- Params
- none
- Returns
{ maps: [{ id, title, updatedAt }] }
Create a new, empty map.
- Params
title— 1–200 chars- Returns
{ map }
Change a map's title.
- Params
mapId,title- Returns
{ map }
Fetch a map's full state — title, every node, every edge. Read this before editing.
- Params
mapId- Returns
{ map, nodes, edges }
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.
Add one thought to a map.
- Params
mapId,text(≤4,000),x?,y?,color?- Returns
{ node }
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
Change only the fields you pass on one node — safe alongside a human dragging the same map.
- Params
nodeId+ any oftext,x,y,color- Returns
{ node }
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 carryingimageUrl
Take the picture off a node; the node itself stays. It does not delete the stored file — see below.
- Params
nodeId- Returns
{ 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.
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 up to 200 connections in one call — ideal right after a bulk create_nodes.
- Params
mapId,edges: [{ sourceId, targetId }](1–200)- Returns
{ edges }
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.
| Status | What 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.
| Budget | Applies to |
|---|---|
| 240 / min | Every action, counted per API key |
| 30 / min | set_node_image — mind-map pictures, counted per organisation |
| 30 / min | attach_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.