A kanban board for coding agents
A cross-repo work queue that coding agents can read and move, on a board that is not the git host. This page covers what the checkbox-per-plan status system was doing wrong, which self-hosted board won the comparison, how the stack is hosted, and how the agent half is wired.
Everything measured here comes from one deployment: the board at
https://vikunja.erfi.io, that stack’s own notes, the veans CLI, and a
one-off stocktake of the plan corpus run 2026-09-30. Option comparison rows are
reported from a survey dated 2026-09-29 and are not re-tested here, so their
version numbers are as of that date. The drift counts, the outage duration and
the import behaviour are measured. No secret, token or environment value from
the stack’s encrypted .env appears on this page.
TL;DR:
- Plan docs plus
TODO.mdfiles were the status system, and they drifted. A 2026-09-30 stocktake of 124 plan docs found 65 shipped, 36 active, 13 planned, 7 parked and 3 stale, with at least 14 docs whose boxes or banner contradicted the repo. The status banner and git history are the reliable signal; a ticked box is not. - Agents had no shared place to claim work, so two sessions could start the same item and a finished item left no record in the queue. One Vikunja project and one bot user per repo fixes the claim, and
veansis the CLI that moves the card. - Vikunja won the comparison on the agent surface per byte served: full REST API, bearer tokens, bot users, webhooks1 and an official agent CLI2 in an app plus Postgres footprint under 100 MB.
- Plane is the richer product and lost on operations: 7 containers and 4-8 GB, with upgrade and frontend failures recorded upstream in the survey’s adversarial notes.
- Serving the board from Forgejo’s own project boards would have added no stack, but the running release has no projects API at all: the projects endpoints returned 404 when probed on 2026-09-29.
- The board runs on the storage host, not the router. On 2026-09-29 a router reboot let the CI buildkit container take a static IP first, Forgejo exited 128 and
git.erfi.iowas down about 1h40m. A work queue used during a git outage should not live on the host that caused it. - The backlog was seeded by hand once: a cheap model classified every plan doc from its own status line plus
git log, a human spot-checked the result, and 108 cards went in through the CSV importer. Import first, then wire each repo to the imported project.
The problem: status lived in checkboxes
Section titled “The problem: status lived in checkboxes”Work-in-flight lived in plan docs (docs/plans/*.md) and repo-root TODO.md
files: a markdown checklist, a status banner typed at the top, and a git log
beside it. Two of those three carry status and the third does not, but the
checklist is the part that looks authoritative.
The 2026-09-30 stocktake of 124 such files across three repo roots made the drift concrete:
| Class | Files | Cards |
|---|---|---|
| Shipped | 65 | 0 |
| Active | 36 | 75 |
| Planned | 13 | 21 |
| Parked | 7 | 0 |
| Stale | 3 | 0 |
| Total | 124 | 108 |
At least 14 documents contradicted their repo. Five active plans had shipped work with every box unticked; four shipped plans carried 27 to 50 open boxes; one plan with a shipped banner had 40 open boxes and said so in its own text (“its checkboxes were never backfilled - the banner is canonical”). Counting boxes is not a status read, and neither is reading the doc’s own summary of itself without checking the log.
Three failure modes follow, and they are what a board addresses:
- An agent starting cold re-derives the open list from a doc plus the git log, which costs a session of reading and can be wrong either way.
- Two sessions given the same product area pick the same item, because nothing holds an assignment.
- Finished work leaves no trace in the queue. The plan doc stays open, so the same item is rediscovered and re-triaged weeks later.
Forgejo’s project boards would have been the zero-extra-stack answer, since the forge is already the primary git host on the edge router (Forgejo as the primary forge). It does not work for this: the release that is running exposes no projects API, so an agent cannot move a card between columns. The projects endpoints returned 404 when probed on 2026-09-29 (issues, labels and milestones do have endpoints). The survey noted board REST work upstream in Gitea; whether and when it reaches Forgejo was not checked. A board an agent cannot move is a board a human has to maintain.
Which board
Section titled “Which board”Five candidates survived the survey’s cut; the rows below are its findings, and the versions are as of 2026-09-29.
| Candidate | License | Agent surface | Footprint | Verdict |
|---|---|---|---|---|
Vikunja v2.6.0 | AGPL-3.0 | REST v1 + v2 with OpenAPI 3.1, bearer tokens, webhooks, bot users; official CLI | app + Postgres, under 100 MB | Chosen |
Plane v1.4.2 CE | AGPL-3.0 | REST with PAT and webhooks, official MCP server | 7 containers, 4-8 GB | Richest UI, lost on operations |
Kanboard v1.2.54 | MIT | JSON-RPC 2.0 only, official Python client | single PHP container + SQLite, about 50 MB | Cheapest and reliable; weakest agent path |
Wekan v12.09 | MIT | REST with a login token, no personal tokens | app + Mongo or FerretDB, 0.5-1 GB | Performance regressions recorded upstream |
| Forgejo projects | MIT | no projects endpoints on the running release (404) | none extra | No board API |
The cut list, one line each: Focalboard is unmaintained (last release 2024-06-13); Taiga is dead (no commits since 2023-12-13); Planka moved off MIT to a source-available community licence and has no OIDC in that edition; Restyaboard has no verifiable public source; the 2026 Linear-clone entrants are too young to recommend. Linear itself stayed in the matrix as a SaaS baseline only, since it cannot be self-hosted.
Vikunja’s case is the agent surface per byte served. It exposes a documented
REST API with bearer tokens, distinguishes bot users from human accounts so an
agent gets its own identity with no signup path3, emits
webhooks, and has an official CLI built for exactly this use
(veans2). The whole stack is two containers under 100 MB, and
the board’s own quirks are known quantities: the v1 API is deprecated and
goes in 4.0, and the core is effectively single-maintainer.
Plane’s problems were operational rather than API-shaped. The survey’s
adversarial notes record 7 or more containers and around 200 environment
variables, an upgrade that broke file permissions, a redirect loop, and a
commercial v3.0.x frontend that crashed on navigation over a poisoned store
response. The official MCP server and the richer UI are real, and none of that
matters if the board is the thing that is broken when you need it.
Topology and hosting
Section titled “Topology and hosting”Same thing as a list, for readers who get the diagram as text:
- An agent in a wired repo runs
veans, which reads.veans.ymland calls the Vikunja REST API with the repo’s bot token. - The API is on the app network; the edge Caddy reaches the same container on the LAN macvlan address
10.0.71.66:3456for the web UI and the public hostname. vikunjatalks topostgres_vikunjaon a second network markedinternal, so the database has no route off the host.- A human signs in to the web UI, moves verified cards to Done, and reads the cross-repo filter.
The stack is composer-managed (git-synced, deployed by a webhook on push, never
by a raw docker compose) and it runs on the storage host, not the router.
That placement is the design decision worth stating. The router hosts the forge
and composer, and on 2026-09-29 it rebooted; the CI buildkit container came up
first and took the static address, Forgejo exited 128 with failed to set up container networking: Address already in use, and git.erfi.io answered 502
for about 1h40m. Serving the task board from the same host as the git server
means a git incident also takes the queue you would use to record the incident.
Networking is pinned, and the pins are the second half of that outage’s lesson:
| Network | CIDR | Purpose |
|---|---|---|
| app | 172.19.24.0/24, dynamic range .128/25 | API and edge |
| backend | 172.19.26.0/24, dynamic range .128/25, internal: true | database only |
servarr_lan (external macvlan) | 10.0.71.0/24 | edge Caddy reach |
Static: vikunja at 172.19.24.2 and 172.19.26.2, Postgres at
172.19.26.3, LAN address 10.0.71.66. The ip_range on each network keeps
dynamic allocations out of the static addresses, which is the failure above
caught in configuration. Any change to the ipam means composer down then
up; a plain up re-attaches the existing containers without their
ipv4_address and every pin is lost.
Three settings were chosen without needing a decision:
- Registration off.
POST /api/v1/registeranswers 404 (verified on 2026-09-30), and the owner account is created from inside the container. - No SMTP, so reminders are off; no OIDC; no Redis. One user, one board per repo.
- Attachments and the database live on the hot tier of the storage host, with the database on a 16K record size, so both fall under the host’s existing hot-tier snapshot policy (hot vs bulk placement).
Backups follow the storage host’s convention rather than a per-stack sidecar:
the database is added to the nightly pg_dump -Fc timer that writes to the
backup dataset, the same timer every other Postgres on the storage host uses
(declarative backups on a ZFS homelab).
A restore drill was run against that dump, and the row counts came back
matching live. The .env is SOPS-encrypted and committed; no value from it is
reproduced here.
The agent workflow (veans)
Section titled “The agent workflow (veans)”The unit of wiring is the repository. One Vikunja project per repo, one bot
user per repo (bot-<repo>), and a .veans.yml at the repo root holding the
server, project id, view id, bucket ids and the bot identity. That file carries
no token and is committed, so a fresh clone is wired as soon as the agent reads
it.
Buckets are Todo, In Progress, In Review, Done and Scrapped. The
workflow rule is that an agent claims and moves through the first three, and a
human moves the card to Done:
veans list --ready # Todo, not blockedveans claim '#3' # assign this bot, move to In Progressveans create "<title>" -s in-progress -d "<p>why, and the source file</p>"veans update '#3' -s in-review -c '<p>done: commits abc123, verify: make test</p>'veans show '#3' # full card, comments includedQuote the card reference: an unquoted # starts a bash comment. Card
descriptions and comments are HTML in TipTap shapes rather than markdown, which
is why the examples carry tags. Filing a follow-up found mid-task is
veans create with the source path in the description, not a silent detour and
not a lost observation.
The workflow prompt is injected at session start so the agent does not have to
discover any of this. Claude Code gets it from the SessionStart and
PreCompact hooks that veans init --install-claude writes into the repo’s
settings. veans ships no pi hook, so pi gets the equivalent from a small
extension: it finds the nearest .veans.yml from the session’s working
directory, runs veans prime there once, appends the output to the system
prompt, and appends a one-time note when a bash command cds into a different
wired repo mid-session. The prime output is re-read per session because it
embeds the live project and bot identity, and the extension stays silent
outside a wired repo.
Wired repos as of 2026-09-30, with the project id the wiring points at: eaves 9,
the caddy-compose board 10, composer 5, fjctl 12. Every other repo’s cards
exist on the board from the import below but have no .veans.yml until
someone runs the init.
Seeding the board
Section titled “Seeding the board”The board started empty except for one import, which is where the stocktake came in. It was a read-only sweep:
- Globbing for
TODO.mdanddocs/plans/*.mdacross three repo roots, with vendored and retired trees excluded, enumerated 124 files. - Each file was classified from its own status or banner line, then from
git log -1on the file’s repo, and given a class: shipped, active, planned, parked or stale. - The classification ran on a cheap remote model rather than a session model, because it is a bulk labelling job with a mechanical rule.
- A human spot-checked the output. Two corrections came out of it: netlens was already shipped and absorbed, and the WAF redesign cards were re-labelled parked after the same-day decision to drop the edge WAF.
Cards came out of the active and planned classes plus twelve known-open items, 108 in total, concentrated in a few projects (composer took 12 cards and fjctl 8) with a long tail of one- and two-item repos. The per-file evidence and the drift list stay in the stocktake’s own inventory, which is the record of why each card exists.
The import is the gotcha. Vikunja’s CSV importer maps columns 1:1 onto title,
description, project, labels, priority and done, and it always creates new
projects: one sub-project per distinct project value, under an “Imported from
CSV” parent. It cannot append to an existing project, and re-running it
duplicates everything. So the order is fixed:
- Import the CSV once, and let it create the per-repo projects.
- Then wire each repo to the imported project rather than letting
veans initcreate its own:
veans init --server https://vikunja.erfi.io --project <id> --yes-buckets --install-claudeRunning the init first and importing afterwards leaves two projects per repo with cards split across them.
Two smaller traps sit around that command. The interactive picker needs a
terminal, so a non-pty run dies after the browser login with
not a terminal - pass --project <id> and writes nothing; the non-interactive
form above is the one to use. And re-running it in an already-wired repo is
refused with a conflict rather than updating it, so re-wiring means deleting
.veans.yml first.
Finally, Vikunja has no single cross-project board view. The equivalent is a
saved filter over the labels and the done flag (for example
labels in priority && done = false); filter syntax is
documented4.
Decision guide
Section titled “Decision guide”The same decision as a list:
- One repo, one agent client: the repo’s own
TODO.mdis enough, and a board adds a second place for status to drift. - Several repos or clients, and your forge already exposes board columns and card moves to its API: keep the board next to the repos and add no stack.
- Several repos, and it does not: take a board with an agent surface. Vikunja plus
veansif the app plus Postgres footprint and a working API are what matter. - Same case, and a rich UI with cycles and modules is worth 7 containers and 4-8 GB to operate: Plane plus its MCP server.
Evidence
Section titled “Evidence”| Claim | How it was checked | Status |
|---|---|---|
| 124 plan docs, 65 shipped, 36 active, 13 planned, 7 parked, 3 stale | Stocktake 2026-09-30: glob for TODO.md and docs/plans/*.md across three repo roots, each file classified from its own status line plus git log -1; spot-checked by hand | Measured |
| At least 14 docs contradict the repo | Same stocktake’s drift list, each entry naming the contradicting evidence | Measured |
| 108 open cards, per-project counts | Cards produced by the same sweep and carried into the CSV import | Measured |
| The running release’s projects endpoints answer 404 | Probed against the live forge on 2026-09-29 | Measured |
git.erfi.io down about 1h40m on 2026-09-29, Forgejo exit 128 Address already in use | Incident record kept in the forge stack’s own notes | Measured |
| Restore drill row counts matched live | Drill run against the nightly pg_dump -Fc artifact | Measured |
| Board footprint under 100 MB, app plus Postgres | Survey table, container image and memory figures, 2026-09-29 | Documented, not re-measured |
Vikunja REST, tokens, bot users, webhooks, veans | Vendor documentation | Documented |
| Plane 7 containers, 4-8 GB, upgrade and frontend failures | Survey adversarial notes, from upstream issues and forum reports | Not tested here |
| Kanboard degrades past 500-700 tasks, Wekan board-load regressions | Survey adversarial notes, from upstream issues | Not tested here |
Related docs
Section titled “Related docs”- A cross-client memory store for coding agents - the other half of durable agent state: session history in one Postgres, when the board only holds work that is still open.
- Forgejo as the primary forge on the NixOS edge router - the host whose 2026-09-29 outage decided where this board lives, and the CI that deploys stacks like it.
- Declarative backups on a ZFS homelab: nix timers, sanoid, syncoid - the
pg_dumptimer pattern this board’s database was added to. - Hot vs bulk: placing app state on a two-tier ZFS homelab - why the database and attachments sit on the hot tier.
- Forgejo webhooks to Composer GitOps - the push-to-deploy path that ships a change to this stack.
References
Section titled “References”-
Vikunja, “Webhooks,” Vikunja Documentation. https://vikunja.io/docs/webhooks/ ↩
-
Vikunja, “veans,” Vikunja Documentation. https://vikunja.io/docs/veans/ ↩ ↩2
-
Vikunja, “Bot users,” Vikunja Documentation. https://vikunja.io/docs/bot-users/ ↩
-
Vikunja, “Filters,” Vikunja Documentation. https://vikunja.io/docs/filters/ ↩