To keep several AI coding agents from picking the same ticket, make the claim a compare-and-set: the agent moves the ticket out of to-do only if it is still in to-do, and the board answers 409 to everyone who comes second. On 12 November 2026 I tested that on agentboard 0.1.13, my self-hosted kanban board for agents, by sending 20 agents after the same ticket at once, 50 times. Every round had exactly one winner and 19 agents got a 409. Without the check, all 20 of them won.
The problem: two agents, one ticket, two pull requests
An agent that wants work lists the tickets in to-do, picks the top one and moves it to in-progress. With one agent that is fine. With several, two of them can read the same list within the same second, and both move the same ticket. Nothing fails, both agents say they have it, and an hour later I have two pull requests for one fix. I wrote about how that happened on my own board in the post about the 198 tickets my agents closed in 25 days, and the fix there was a single field on the update call. This post is the test I should have run before trusting it.
The claim, as one HTTP call
An agent claims a ticket with a PATCH that names the column it expects the ticket to be in:
curl -X PATCH -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \ $URL/api/tickets/42 \ -d '{"if_column":"to-do","column":"in-progress","assignee":"me","comment":"Picking this up."}' The move, the assignment and the comment all happen in that one call. If the ticket is no longer in to-do, nothing is written and the response is a 409 with a message the agent can read: ticket #42 is in "in-progress", not "to-do". The command line client does the same with kb set 42 --if-column to-do --column in-progress --assignee me. I ran it twice on a fresh board: the first call exited 0, the second printed 409: ticket #71 is in "in-progress", not "to-do" and exited 1, so a shell script or an agent's tool call can branch on the exit code.
Why the check can't be beaten: it's one synchronous function
I read the code at commit b5deb33 (tag v0.1.13). The handler for the update, updateTicket in server.js, loads the ticket, compares its column with if_column, and then writes the new column, the assignee and the event rows in one transaction. The server uses node:sqlite, whose calls are synchronous, so there is no await between the read and the write. Node runs one request handler at a time, which means a second claim can only start after the first one has finished writing, and by then it sees in-progress and gets its 409.
The check lives in the application and not in the SQL statement, so it holds for the way agentboard is meant to run: one server process that owns one SQLite file. I did not test two server processes pointed at the same database, and I would not run it that way.
The test: 20 agents, 50 rounds
I started a new board (Node 22.23.4, an empty database), created 20 agent users with POST /api/users, each with its own key, and wrote a short script. Each round creates a ticket in to-do, sends 20 claims at the same moment with Promise.all, one per agent, and then reads the ticket back. Every request carried "if_column": "to-do" and a comment saying which agent sent it.
| Claim style | Rounds | Results per round |
|---|---|---|
With if_column | 50 | 1 × 200, 19 × 409 |
Without if_column | 20 | 20 × 200 |
With the check, all 50 rounds ended the same way: one 200, nineteen 409s, and the assignee on the ticket was the agent that got the 200. The 50 rounds, about a thousand claims in all, took 1,260 milliseconds including creating and reading back the tickets. The same agent won every round, which says more about the order my script sent requests in than about the board, so don't read fairness into it. The board promises one winner, not a random one.
Without the check, which is how a plain "move it to in-progress" call behaves, all 20 agents got a 200 in each of the 20 rounds. In the first round the ticket ended up with 20 comments saying "claimed by agent-N" and with the last writer, agent-20, as its assignee. Every one of those twenty agents believed it owned the ticket. That is the duplicate pull request, reproduced in about a second.
What an agent does with a 409
A 409 on a claim is not an error to retry. It means somebody else is on that ticket, so the agent goes back to the list and takes another one:
for id in $(kb ls --column to-do --assignee none | jq -r '.[].id'); do kb set "$id" --if-column to-do --column in-progress --assignee me \ --comment "Picking this up" && break done I describe the whole loop, including the handoff comment and the pull request link, in the guide to giving Claude Code a shared kanban board through the API, with the commands Claude Code ran.
Create and claim in one call
The compare-and-set protects tickets that already sit in to-do. There is a second race it can't help with: an agent that creates a ticket for work it is about to do itself. If it creates the ticket in to-do and moves it a moment later, a dispatcher watching that column can grab it in between. That is the bug that gave me two pull requests for one fix on my real board. The creation call accepts the column and the assignee, so the ticket never sits unassigned. On the fresh board I ran kb new "Fix other thing" --column in-progress --assignee me and the ticket came back already in in-progress, assigned to the agent that created it.
What to copy if you build your own board
- Put the expected state in the claim itself, and answer 409 with the actual state in the message.
- Do the check and the write in one step with no
awaitin between, or move the check into the SQLWHEREclause if your server runs as several processes. - Test it with the requests really arriving at once. A claim that works when you call it twice in a row proves nothing about two agents calling it together.
- Let agents create tickets already assigned, so no ticket sits in the queue waiting to be grabbed.
Run it yourself
agentboard is MIT-licensed and the source is on GitHub. One command starts it:
docker run -d -p 3000:3000 -v agentboard:/data ghcr.io/backmeupplz/agentboard Open the setup link printed in the logs, add an agent under Settings, People, and give it the board's URL and its key. The race script is a short Node file using fetch, and the API it calls is documented at GET /api on your own board. Everything above was run on 12 November 2026 against agentboard 0.1.13 on Node 22.23.4; the numbers come from one machine and a local server, so treat the timings as a rough size and the one-winner result as the point.

