Skip to content

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.md files 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 veans is 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.io was 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.

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:

ClassFilesCards
Shipped650
Active3675
Planned1321
Parked70
Stale30
Total124108

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.


Five candidates survived the survey’s cut; the rows below are its findings, and the versions are as of 2026-09-29.

CandidateLicenseAgent surfaceFootprintVerdict
Vikunja v2.6.0AGPL-3.0REST v1 + v2 with OpenAPI 3.1, bearer tokens, webhooks, bot users; official CLIapp + Postgres, under 100 MBChosen
Plane v1.4.2 CEAGPL-3.0REST with PAT and webhooks, official MCP server7 containers, 4-8 GBRichest UI, lost on operations
Kanboard v1.2.54MITJSON-RPC 2.0 only, official Python clientsingle PHP container + SQLite, about 50 MBCheapest and reliable; weakest agent path
Wekan v12.09MITREST with a login token, no personal tokensapp + Mongo or FerretDB, 0.5-1 GBPerformance regressions recorded upstream
Forgejo projectsMITno projects endpoints on the running release (404)none extraNo 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.


servarr (storage host)Coding agent(pi or Claude Code)veans CLIreads .veans.ymlclaim / updateVikunja REST APItoken authvikunja172.19.24.2, :345610.0.71.66macvlanHumanweb UIedge Caddyvikunja.erfi.io10.0.71.66:3456postgres_vikunja172.19.26.3backend net(internal)

Same thing as a list, for readers who get the diagram as text:

  1. An agent in a wired repo runs veans, which reads .veans.yml and calls the Vikunja REST API with the repo’s bot token.
  2. The API is on the app network; the edge Caddy reaches the same container on the LAN macvlan address 10.0.71.66:3456 for the web UI and the public hostname.
  3. vikunja talks to postgres_vikunja on a second network marked internal, so the database has no route off the host.
  4. 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:

NetworkCIDRPurpose
app172.19.24.0/24, dynamic range .128/25API and edge
backend172.19.26.0/24, dynamic range .128/25, internal: truedatabase only
servarr_lan (external macvlan)10.0.71.0/24edge 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/register answers 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 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:

Terminal window
veans list --ready # Todo, not blocked
veans claim '#3' # assign this bot, move to In Progress
veans 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 included

Quote 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.


The board started empty except for one import, which is where the stocktake came in. It was a read-only sweep:

  1. Globbing for TODO.md and docs/plans/*.md across three repo roots, with vendored and retired trees excluded, enumerated 124 files.
  2. Each file was classified from its own status or banner line, then from git log -1 on the file’s repo, and given a class: shipped, active, planned, parked or stale.
  3. The classification ran on a cheap remote model rather than a session model, because it is a bulk labelling job with a mechanical rule.
  4. 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:

  1. Import the CSV once, and let it create the per-repo projects.
  2. Then wire each repo to the imported project rather than letting veans init create its own:
Terminal window
veans init --server https://vikunja.erfi.io --project <id> --yes-buckets --install-claude

Running 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.


More than one repo, or morethan one agent client?Can an agent move a cardwith an API token?yesThe repo's own TODO.mdis enoughnoIs the rich UI worth7 containers?noKeep the board next to the repos(forge projects)yes, on your releaseVikunja plus veansnoPlane plus its MCP serveryes

The same decision as a list:

  1. One repo, one agent client: the repo’s own TODO.md is enough, and a board adds a second place for status to drift.
  2. 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.
  3. Several repos, and it does not: take a board with an agent surface. Vikunja plus veans if the app plus Postgres footprint and a working API are what matter.
  4. 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.

ClaimHow it was checkedStatus
124 plan docs, 65 shipped, 36 active, 13 planned, 7 parked, 3 staleStocktake 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 handMeasured
At least 14 docs contradict the repoSame stocktake’s drift list, each entry naming the contradicting evidenceMeasured
108 open cards, per-project countsCards produced by the same sweep and carried into the CSV importMeasured
The running release’s projects endpoints answer 404Probed against the live forge on 2026-09-29Measured
git.erfi.io down about 1h40m on 2026-09-29, Forgejo exit 128 Address already in useIncident record kept in the forge stack’s own notesMeasured
Restore drill row counts matched liveDrill run against the nightly pg_dump -Fc artifactMeasured
Board footprint under 100 MB, app plus PostgresSurvey table, container image and memory figures, 2026-09-29Documented, not re-measured
Vikunja REST, tokens, bot users, webhooks, veansVendor documentationDocumented
Plane 7 containers, 4-8 GB, upgrade and frontend failuresSurvey adversarial notes, from upstream issues and forum reportsNot tested here
Kanboard degrades past 500-700 tasks, Wekan board-load regressionsSurvey adversarial notes, from upstream issuesNot tested here

  1. Vikunja, “Webhooks,” Vikunja Documentation. https://vikunja.io/docs/webhooks/ ↩

  2. Vikunja, “veans,” Vikunja Documentation. https://vikunja.io/docs/veans/ ↩ ↩2

  3. Vikunja, “Bot users,” Vikunja Documentation. https://vikunja.io/docs/bot-users/ ↩

  4. Vikunja, “Filters,” Vikunja Documentation. https://vikunja.io/docs/filters/ ↩