# vikunja-agents git clone https://git.dominat.us/oc/vikunja-agents.git Public read, push restricted to the owner. On `reptar` the working copy is `/srv/pods/vikunja-agents/`, which is also where the quadlets and the Claude Code MCP registration point. Harness-agnostic glue between Vikunja and agent harnesses. Standard library Python only — no virtualenv on any device. This file is the how-to. For why the design is shaped this way — the MAS SSO constraints, the API-token limits, why polling instead of webhooks, and the known gaps — see [`/srv/pods/docs/agent-collaboration.md`](../docs/agent-collaboration.md). | File | What it is | |---|---| | `vkclient.py` | Shared API client. | | `vk` | CLI. Any harness that can shell out is integrated. | | `vikunja_mcp.py` | MCP server (stdio). Any harness that speaks MCP is integrated. | | `vk-watch@.service` | systemd template for the per-device task watcher. | ## The model - **Task** = unit of work. **Assignee** = which agent owns it. **Comments** = the thread. **Labels** = routing and state. - **Outbound** (agent → Vikunja): `vk` or the MCP tools, authenticated as that bot, so its actions are attributed to it in the UI. - **Inbound** (Vikunja → agent): `vk watch` polls the bot's own assigned queue and runs a command per new task. ### Why polling rather than webhooks Harnesses live on several machines. A webhook receiver would need an inbound port on each device (or SSH fan-out from one host), and Vikunja's WebSocket is not an alternative: it authenticates with a JWT only (API tokens are rejected by `auth.GetUserIDFromToken`) and its subscribable events are notifications and timers — there are no task events to subscribe to. Polling needs only outbound HTTPS, so a device behind NAT or on another subnet works with no extra config. ## Onboarding an agent Hand [`AGENT-ONBOARDING.md`](AGENT-ONBOARDING.md) to the agent as a task prompt — it covers token bootstrap, MCP registration per harness, verification, and the constraints that otherwise get rediscovered the hard way. ## One-time setup per bot Agents run as Vikunja **bot users**: local accounts owned by a human, with no password of their own. Create one in the UI (Settings → Bot Users); the `bot-` prefix is reserved for them and `vikunja user create` will refuse it. A bot cannot mint its own token — `tokens` is not a token-grantable route group, and there is no password to log in with. The owner issues it instead, with `owner_id` set to the bot's user id (UI, or `PUT /api/v1/tokens`), and the agent stores it: ```sh vk profile bot-nullclaw --username bot-nullclaw < token.txt # reads stdin, verifies, 0600 ``` `vk bootstrap` remains for legacy password accounts only. This mints a scoped token and writes `~/.config/vikunja-agents/config.json` (mode 0600). The username is stored alongside the token because `/user` is unreachable with an API token, so `vk whoami` and `--mine` read it from there. Default grants: tasks (read/create/update), comments, assignees, labels, projects (read + `projectusers` for username lookup). Override with `--permissions`; `vk bootstrap` intersects your map against the live `/routes` and reports what it dropped rather than failing at creation. ## Deploying a watcher to a device Applies equally to this host, the Spark, and any other server. ```sh # 1. Copy the tools and put `vk` on PATH mkdir -p ~/.local/bin ~/opt/vikunja-agents scp vk vkclient.py vikunja_mcp.py :~/opt/vikunja-agents/ ln -sf ~/opt/vikunja-agents/vk ~/.local/bin/vk # 2. Bootstrap that bot's token on that device vk bootstrap --username bot-nullclaw # 3. Confirm it sees its queue before wiring systemd vk watch --once --dry-run -- nullclaw --task '{task_id}' # 4. Configure and start cat > ~/.config/vikunja-agents/watch-bot-nullclaw.env <<'EOF' VK_WATCH_ARGS=--interval 30 -- nullclaw --task {task_id} --url {url} EOF cp vk-watch@.service ~/.config/systemd/user/ systemctl --user daemon-reload systemctl --user enable --now vk-watch@bot-nullclaw ``` **On the Spark, enable linger first** — user services are killed on logout otherwise, and the watcher will quietly stop: ```sh loginctl enable-linger $USER ``` ### Placeholders Available in the command: `{task_id}` `{title}` `{project_id}` `{priority}` `{labels}` `{url}`. The same values are also exported as `VIKUNJA_TASK_ID`, `VIKUNJA_TASK_TITLE`, `VIKUNJA_TASK_URL`. ### Replying to comments Add `--comments` and the watcher also fires when **someone else** comments on one of your tasks: ```sh vk watch --comments --interval 30 -- nullclaw --task {task_id} ``` - Scans your assigned tasks **including done ones** — a follow-up question almost always lands on a task that was just closed. - Your own comments never fire it, so an agent replying cannot wake itself. - On first sight of a task the newest comment id is adopted **without firing**, so turning this on does not replay the whole history. The flip side: a comment already sitting there when you enable it will not fire either. To pick it up, delete that task's entry from the `comments` map in the state file. - The command gets `VIKUNJA_TRIGGER=comment` plus `VIKUNJA_COMMENT`, `VIKUNJA_COMMENT_AUTHOR`, `VIKUNJA_COMMENT_ID` alongside the usual task vars. Task triggers set `VIKUNJA_TRIGGER=task`. ### Trigger semantics A task fires once, when it first appears as assigned-to-me and not-done. Handled IDs are recorded in `~/.local/state/vikunja-agents/watch-.json` **before** the command runs — at-most-once, so a harness that wedges the machine is not relaunched on every poll. Entries expire after 30 days. To replay a task, delete its ID from that file. Poll failures log and back off to 10 minutes; the watcher never exits on an API error. ## MCP ```sh claude mcp add vikunja -- ~/opt/vikunja-agents/vikunja_mcp.py ``` Auth comes from the same config file / `VIKUNJA_PROFILE`. Seven tools: list/get/create/update/assign tasks, comment, list projects. ## Routing work to a specific agent Assignment is the dispatch mechanism — assign a task to `bot-picoclaw` and only picoclaw's watcher picks it up. For finer control, give each watcher a filter: ```sh vk watch --project 7 --filter "priority >= 3" -- picoclaw --task {task_id} ```