On a machine without a browser the loopback redirect lands on the user's other machine and fails. Prompt for the redirect URL while waiting for the callback; whichever arrives first wins.
veans
A beans-shaped CLI for Vikunja. Drop it into a repo, run veans init, paste a
hook snippet into your coding agent's settings, and the agent immediately
knows to track its work in Vikunja instead of in TodoWrite or .beans/.
veans is a thin Go binary that wraps Vikunja's REST API with an opinionated
agent-friendly surface and emits a system prompt teaching agents the workflow
(claim → work → in-review → human closes). The agent prompt is re-emitted on
every SessionStart and PreCompact, so context never goes stale.
Quick start
# 1. Build (or download) the binary
cd veans && mage build && sudo install ./veans /usr/local/bin/
# 2. In a repo with a Vikunja instance reachable
veans init --server https://vikunja.example.com
# 3. Wire it into Claude Code (.claude/settings.json):
{
"hooks": {
"SessionStart": [{ "hooks": [{ "type": "command", "command": "veans prime" }] }],
"PreCompact": [{ "hooks": [{ "type": "command", "command": "veans prime" }] }]
}
}
# OpenCode (.opencode/plugin/veans-prime.ts):
export const VeansPrime = {
event: ["session.start", "compact.before"],
handler: async ({ exec }) => exec("veans prime"),
}
veans prime exits silently with status 0 when no .veans.yml is reachable
upward from cwd, so the hook is safe to install in a global ~/.claude/
without breaking sessions in unrelated repos.
What veans init does
-
Authenticates as you. Default is OAuth 2.0 Authorization Code + PKCE against Vikunja's built-in authorization server (Vikunja 2.3+ — no client registration needed). veans opens the authorize URL in your browser and catches the redirect on a loopback port.
On a machine without a browser (e.g. over SSH), open the printed URL on any machine, sign in, and paste the
http://127.0.0.1:NNN/callback?code=...URL the browser ends up on back into the CLI. The page itself fails to load, which is expected; the address bar still has what we need.Alternative auth modes:
--token <jwt-or-personal-api-token>— paste-in, useful for SSO/OIDC--use-password— fall back toPOST /login(local accounts only)--username+--password(non-interactive; implies--use-password)
-
Asks you to pick a project and a Kanban view.
-
Bootstraps the canonical buckets if missing:
Todo,In Progress,In Review,Done,Scrapped. -
Creates a
bot-<repo-name>user (Vikunja bot user — no password, no email, can't log in interactively). -
Shares the project with the bot at read+write.
-
Mints a long-lived API token for the bot via
PUT /tokenswithowner_id, scoped to the discovered route groups (tasks, comments, labels, relations, assignees, etc.) the server actually exposes. -
Stores the token in your OS keychain (or
~/.config/veans/credentials.ymlif no keychain is available). -
Writes
.veans.ymlto the repo root.
The token stored is the bot's, not yours. The human's transient session is discarded as soon as init finishes — rotate or revoke the bot independently without affecting your own session.
Commands
veans init OAuth/login → create bot → mint token → write .veans.yml
veans prime emit system prompt for agents (silent if no .veans.yml)
veans list filtered list (--ready, --mine, --branch, --filter, --status); emits JSON
veans show <id> view a task (JSON)
veans create "title" --description, --label, --status, --priority, --parent, --blocked-by
veans update <id> --status, --title, --priority, --label-add/remove,
--description, --description-replace-old/new, --description-append,
--comment, --reason, --if-unchanged-since
veans claim <id> assign the bot, move to In Progress, tag with current branch label
veans api METHOD PATH raw REST passthrough — escape hatch for endpoints not wrapped here
veans login re-mint the bot's token (rotation)
veans version
Task IDs accept PROJ-NN (when the project has an identifier), #NN
(when it doesn't), or a bare integer.
.veans.yml
Committed to the repo root. The numeric IDs are the source of truth; cached identifiers and bot username are for human-readable output.
server: https://vikunja.example.com
frontend_url: https://vikunja.example.com/ # from /info; backfilled if missing
project_id: 42
project_identifier: PROJ # may be "" — task IDs render as #NN then
view_id: 7
buckets:
todo: 11
in_progress: 12
in_review: 13
done: 14
scrapped: 15
bot:
username: bot-myrepo
user_id: 99
Credentials
Resolved in order on every command:
- OS keychain (macOS Keychain, Windows Credential Manager,
libsecret/gnome-keyring on Linux), via
github.com/zalando/go-keyring. VEANS_TOKENenv var (read-only). Intended for CI / containers.~/.config/veans/credentials.yml(mode 0600) — automatic fallback when the keychain is unavailable.
Trusted servers
.veans.yml is checked into the repo, so veans only talks to its server
once you trust it. veans init trusts the server you typed. Any other
command run in a terminal shows the server's origin and asks once; the
answer is stored in ~/.config/veans/trusted.json.
Without a terminal (agent hooks, CI) veans never asks: it refuses to send
credentials, and veans prime renders without project data. In CI, set
VEANS_SERVER to the server URL to declare it trusted.
Mage targets
mage build # go build -o ./veans ./cmd/veans
mage test # unit tests across the module
mage test:filter EXPR # go test -run EXPR ./...
mage test:e2e # e2e suite (needs VEANS_E2E_API_URL)
mage lint / lint:fix # golangci-lint
mage fmt # go fmt ./...
mage clean # remove built binary
End-to-end tests
The suite in e2e/ assumes a running Vikunja API. Locally, point it at any
dev instance:
export VEANS_E2E_API_URL=http://localhost:3456
export VEANS_E2E_ADMIN_USER=user1
export VEANS_E2E_ADMIN_PASS=12345678 # canonical fixture password
mage test:e2e
CI spins Vikunja up the same way the frontend Playwright suite does — see
.github/workflows/veans-e2e.yml. The workflow builds the parent API
binary, starts it with VIKUNJA_DATABASE_TYPE=sqlite,
VIKUNJA_DATABASE_PATH=memory, fixtures from pkg/db/fixtures/, and runs
mage test:e2e from this directory.
E2E tests never touch the developer's keychain — they override HOME and
XDG_CONFIG_HOME per test, which forces the credential store to fall
through to its file backend.
Status model
| Status | Bucket name | Done flag | Who moves there? |
|---|---|---|---|
todo |
Todo | false | created here by default |
in-progress |
In Progress | false | veans claim / update -s in-progress |
in-review |
In Review | false | the agent, when work is finished |
completed |
Done | true | humans / merge hook only |
scrapped |
Scrapped | true | the agent, with --reason |
The agent never moves tasks to completed itself — it parks them in
In Review and a human (or the future merge hook) closes them once the
PR lands.
Out of scope (for now)
- OAuth 2.0 device flow (RFC 8628) — would replace the headless paste-back step; not implemented upstream yet.
- Project-scoped API tokens — Vikunja doesn't ship them yet. The
credential schema's
scopefield is forward-compatible for when it does. - Auto-installing hook snippets. We print them; you paste them.
- Merge-hook GitHub Action that auto-closes tasks on PR merge — separate repo, future work.