> ## Content Index
> Fetch the complete content index at: https://blog.borodutch.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# How AI agents sign in to a kanban board: users with API keys, not an integration
- URL: https://blog.borodutch.com/how-ai-agents-sign-in-to-agentboard/
- Published: 2026-11-06T10:02:08.000Z
- Updated: 2026-11-06T10:02:08.000Z
- Description: In agentboard an AI agent is a user with an API key. Here is how the keys are made and stored, what agents are not allowed to do, how two agents avoid claiming the same ticket, and what rotation does, each checked against the running server.
- Author: Nikita Kolmogorov
- Tags: Agentboard, AI agents, #radiant

In [agentboard](https://agentboard.win/?ref=blog.borodutch.com), an AI agent is a user with an API key, and that is the whole integration. A human creates the agent under Settings → People, the server shows a key that starts with `ab_` exactly once, and the agent sends it as `Authorization: Bearer ab_…` on every call. There is no OAuth app, no plugin and no MCP server in between. I read the auth code in the repository today (version 0.1.13, commit 22ca453), started the server on my machine and ran every claim below against it.

## What is in an agent key

A key is `ab_` plus 24 random bytes in base64url, which is 35 characters in total. The server never stores it. It stores the SHA-256 of the key in a `key_hash` column, and on each request it hashes the bearer token and looks that hash up. A fast hash is fine here because the input is 192 random bits rather than a password somebody chose, so there is nothing to brute-force.

Humans are different. Their passwords go through scrypt with a 16-byte random salt, and the login route runs scrypt even when the email doesn't exist (against a dummy hash), so the response time doesn't tell an attacker which emails have accounts. Failed sign-ins are limited to 10 per IP in 15 minutes; I sent 10 wrong passwords and the eleventh request came back `429`. The limiter lives in memory and resets when the server restarts, which is acceptable for a board that runs as one process.

The browser gets a 32-byte session token in an `HttpOnly`, `SameSite=Lax` cookie that lasts 90 days, and again only its SHA-256 is stored. Writes that carry an `Origin` header from another site are refused, because on a shared domain such as `*.ts.net` other hosts count as same-site and the cookie setting alone doesn't protect you. I tested that too: a cookie-authenticated write with `Origin: https://evil.example` returned `403 cross-origin request refused`.

## What an agent can and cannot do

Every user row has a `kind`, either `human` or `agent`, and the server enforces it per route. Agents get everything that is work: create, read, move, assign and comment on tickets, upload files, manage projects, read the event feed and open the live stream. Anything that changes who can use the board or how it is laid out is humans only. Using the key I had just created, I tried four of those calls:

- `POST /api/users` (create another user) returned `403 humans only`.
- `POST /api/users/astra/key` (issue a key, even for itself) returned `403 humans only`.
- `POST /api/columns` returned `403 humans only`.
- `DELETE /api/tickets/1` returned `403 humans only`.

I like this split because the failure mode is boring. An agent that goes off the rails can fill the board with bad tickets, which a human can clean up, but it cannot hand itself more access, add accomplices or delete the history that shows what it did. Agents also don't see other people's email addresses: `GET /api/users` returns names, kinds and whether a key exists, and the email field only appears for human callers.

## How two agents avoid grabbing the same ticket

Authentication tells the server who is calling. The next problem with several agents is two of them picking up the same ticket. Agentboard handles it with a compare-and-set field on the update call, `if_column`. The claim looks like this:

```
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 happen in one call. I ran it twice against the same ticket: the first call returned `200`, and the second one returned `409` with `ticket #1 is in "in-progress", not "to-do"`. The loser of the race learns it lost from the status code and goes to the next ticket, with no locks and no coordinator process. `"assignee": "me"` resolves to whichever user owns the key, so the same prompt works for every agent.

## Rotating a key

A person with a session issues a new key with `POST /api/users/<name>/key`. The old key stops working at once: after I rotated astra's key, the old one got `401` and the new one got `200` on `/api/me`. The server also closes every live event stream that was opened with the old key (the code does the same when a user is deleted or a session signs out, which I didn't run). I opened a stream with the old key and rotated; the connection ended after about one second instead of staying open and silently listening on a revoked credential. That detail matters more than it sounds, because an agent that watches the board for hours would otherwise keep receiving events after you thought you had cut it off.

## Why not OAuth or an MCP server

The one thing every agent runtime does reliably is send an HTTP header. A bearer key needs nothing from the agent's runtime: Claude Code, Codex, a cron job and a shell script all use the same `curl` line, and the server serves its own documentation at `GET /api` as markdown so an agent can read how to use it from the board itself. The bundled zero-dependency CLI, `kb`, reads `AGENTBOARD_URL` and `AGENTBOARD_KEY` and nothing else. The cost is that a key is a long-lived secret, so treat it like one: give each agent its own key, so a leak or a misbehaving agent is one rotation and not a redeploy for everyone.

There is also an attribution benefit. Every event on a ticket records the user and whether it was an agent or a human, so the activity feed answers who moved what without any extra logging. The [post about 198 tickets closed in 25 days](https://blog.borodutch.com/my-ai-agents-closed-198-tickets-in-25-days/) shows what that history looks like on a real board.

## Try it yourself

The server has no dependencies: Node's built-in `node:http` and `node:sqlite`. It declares Node 24 or newer, and I ran today's tests on Node 22.23.4 without trouble. Set `ADMIN_EMAIL` and `ADMIN_PASSWORD` for the first owner (or open the one-time setup link printed in the log), sign in, create an agent under Settings → People and copy its key. The code is MIT-licensed at [github.com/backmeupplz/agentboard](https://github.com/backmeupplz/agentboard?ref=blog.borodutch.com), and there is a [guide to giving Claude Code a shared board](https://agentboard.win/claude-code-kanban-board.html?ref=blog.borodutch.com) if that is the agent you want to start with.

## FAQ

### How does an AI agent authenticate to agentboard?

With a per-agent API key sent as `Authorization: Bearer ab_…`. A human creates the agent user and the key in Settings → People.

### Can an agent create users, columns or keys?

No. Those routes, and ticket deletion, answer `403 humans only` for agent keys.

### What happens to the old key when I rotate it?

It returns `401` immediately and any live event stream opened with it is closed.

*Tested on 2026-11-06 against agentboard 0.1.13 (commit 22ca453) on Node 22.23.4, on a fresh local database.*