Lanes: team task boards on Dodil
Lanes is a small team task board (boards → lists → cards) that runs entirely on Dodil: DataK3 stores everything, an appid user pool signs people in, Ignite runs it, Dodil Models does the AI, and Dodil Git CI deploys it. The same board works from a web page with drag and drop, and from Claude or ChatGPT over MCP.
Someone says "I need a Trello". You say "remix this, it's yours":
dodil git remix rgboards/rg-boards --to-org <your-org> --name <your-repo> # name ≤ 18 chars
dodil ignite app get <your-repo> # ~5 min later: deployed; open public_urls[0], sign in
The one post-remix step (checking the sign-in callback) is in After the remix.
How to judge this in 60 seconds
Live: https://rg-boards-rgboards-8080.ignite.dodil.cloud · MCP: https://rg-boards-rgboards-8080.ignite.dodil.cloud/mcp
Demo login (password LanesDemo-2026! for all three) | What it shows |
|---|---|
demo.owner@lanes-demo.dev | Owner of Product Launch and Ops, member of Website Refresh. Manages members. |
demo.member@lanes-demo.dev | Member on Product Launch and Ops (can edit), owner of Website Refresh. |
demo.viewer@lanes-demo.dev | Viewer on Product Launch and Website Refresh (read only) and not on Ops at all. |
- Open the live URL. The signed-out page lists these logins. Sign in as the viewer: the board says Viewer · read only, cards don't drag, there is no "add card", a card opens read-only with a lock note, and Ops is missing from the sidebar. (Same rules hold through MCP and the JSON API: every write answers 403, every board you're not on 404.)
- Sign out, sign in as the owner. Product Launch shows red Overdue cards, amber Blocked by #n cards and a stats strip; the sidebar has My cards, Overdue and Blocked across every board. Drag a card to another list (a teal line shows where it lands), then open it: the History timeline shows who moved it and when, and the seeded history reads like a working week. Members lets the owner invite, change roles and remove people.
- Open #3 "Ship v1 to production" → "What is blocking this?" It walks the chain (#3 ← #2 QA ← #9 mobile login bug, and #10 security review) and says where to start.
- AI extras: in a card, ✦ Turn into checklist and ✦ Find cards like this; on the
board, ✦ Daily summary (Dodil Models:
kimi-k2.6,jina-embeddings-v4). - Claude or ChatGPT (MCP):
- Claude (claude.ai): Settings → Connectors → Add custom connector → URL
https://rg-boards-rgboards-8080.ignite.dodil.cloud/mcp. Under Advanced settings put OAuth Client IDlanes-ai(a first-party client already approved on the demo pool, so there is no wait for approval). Connect, sign in with a demo login. - ChatGPT: Settings → Connectors → Create (developer mode) → the same URL, OAuth,
client ID
lanes-ai. - Then ask:
- "What's on my plate this week?" (as the member)
- "Add a card 'Send invoice' to Doing on the Ops board"
- "Move card 12 to Done on Product Launch"
- "What's overdue?" · "What is blocking the launch?" (cards can be named by title)
- As the viewer: "Move card 3 to Done" → refused: viewers can't edit. "Show the Ops board" → no such board.
- Claude (claude.ai): Settings → Connectors → Add custom connector → URL
The pod scales to zero when idle (about 5 minutes without traffic). The first request after a quiet spell starts a pod, which is mostly Ignite pulling the image; give it up to ~15 seconds once, then everything answers in well under a second.
Every requirement, and where it lives
| Brief item | Status | Where |
|---|---|---|
| People: owner invites members; roles owner / member / viewer | ✅ | domain/model.py (Board.add_member, require_owner), use_cases.add_member; web Members dialog; MCP add_member, remove_member, members |
| Viewer can't edit | ✅ | Board.require_edit on every write use case; UI disables editing; test test_viewer_can_read_but_never_edit, both doors in test_web_and_mcp_doors_share_the_rules |
| Only the owner manages members | ✅ | Board.require_owner; test test_only_owner_manages_members |
| Nobody sees boards they're not on | ✅ | use_cases._board / _card / _visible_cards (404, not 403); test test_stranger_sees_nothing; demo: the viewer can't see Ops |
| Boards → lists → cards with title, description, assignee, due date, labels | ✅ | domain/model.py (Board, Card), use_cases.add_card / update_card / assign_card; test test_card_fields_and_history |
| Cards move between lists, drag and drop on the web | ✅ | use_cases.move_card (with before_card for order); static/index.html drag handlers; test test_drag_and_drop_order |
| History: who, when, what changed | ✅ | activity table; use_cases._log_changes (field, old → new) on every change; card History and board History in the UI; MCP card_history, board_activity |
| MCP with real actions, scoped to the signed-in person | ✅ | doors/mcp.py: 29 action tools on the same use cases, with read-only / destructive annotations; identity from the gateway; /mcp answers 401 + RFC 9728 metadata to start sign-in (main.py) |
| "What's on my plate this week?" | ✅ | my_cards(days=7) |
| "Add a card 'Send invoice' to Doing on the Ops board" | ✅ | add_card(board="Ops", title=..., list_name="Doing") (boards by title, lists case-insensitive) |
| "Move card 12 to Done" | ✅ | per-board card numbers; move_card(card=12, list_name="Done") |
| "What's overdue?" | ✅ | overdue() |
| Web page that does the same things | ✅ | doors/web.py + doors/static/index.html (vanilla JS, no build step) |
| Extra: card → checklist (AI) | ✅ | make_checklist / suggest_checklist (Dodil Models kimi-k2.6), tickable items |
| Extra: find cards like this (by meaning) | ✅ | find_similar (jina-embeddings-v4, vectors cached per card in DataK3) |
| Extra: daily board summary | ✅ | daily_summary, written in the background, stored per board per day |
| Extra: "blocked by" links + "what is blocking the launch?" | ✅ | add_blocker (loops refused), what_blocks walks the chain and says where to start; amber Blocked by #n on cards |
| Extra: overdue flagged automatically | ✅ | computed on every read; red Overdue chip and stripe, sidebar badges, overdue tool |
Rule 1 public Dodil Git repo, MIT, topics dodil-challenge, dodil-challenge-boards | ✅ | this repo, LICENSE |
| Rule 2 Dodil services only (DataK3, appid, Ignite, Dodil Models) | ✅ | infrastructure/dodil.py; no other API keys or databases |
| Rule 3 deployed by Dodil Git CI to Ignite | ✅ | .github/workflows/ci.yml (test → build) + .dodil/deploy.yaml |
| Rule 4 one MCP endpoint, tools are actions | ✅ | /mcp, no table CRUD |
Rule 5 remixable, valid .dodil/remix.yaml | ✅ | .dodil/remix.yaml + schema.sql; dodil git remix check rgboards/rg-boards → valid |
| Rule 6 own name and design | ✅ | "Lanes", own logo and UI |
| Tests for the main rules | ✅ | tests/test_boards.py + tests/test_edges.py (57 tests: every write as a viewer on both doors, every read as a stranger, bad input, races, AI outage, history of every change type; no Dodil account needed) |
| Docs: README + design notes | ✅ | this file, docs/DESIGN.md |
What it does
| Area | What you get |
|---|---|
| People | The board's creator is its owner and invites people as member or viewer. Viewers read, never edit. Only the owner manages members. Removing someone unassigns their cards. |
| Boards | Boards → lists (default To do / Doing / Done, add your own) → cards. A card has a number (#12), title, description, assignee, due date and labels. Drag cards between and within lists. Archive instead of delete, so history survives. |
| History | Every change is recorded: who, when, which field, old → new. Per card and per board. |
| MCP | 29 action tools (add_card, move_card, assign_card, my_cards, overdue, what_blocks, …), never table access. Every tool runs as the signed-in person, so the AI only sees that person's boards. |
| Web | The same actions as a single page: boards sidebar, kanban columns, card editor, members, history, search. |
| AI extras | Turn a card into a checklist (saved, tickable), find cards like this one (search by meaning with jina-embeddings-v4, cached per card), daily board summary (kimi-k2.6, written once a day and kept). |
| Dependencies | "Blocked by" links between cards (loops refused). what_blocks walks the whole chain and says which open card to start with: "what is blocking the launch?". |
| Overdue | Every card past its due date and not in Done is flagged on the board, counted in the sidebar, and listed by overdue / What's overdue?. |
Things to ask Claude or ChatGPT
- "What's on my plate this week?" →
my_cards(days=7) - "Add a card 'Send invoice' to Doing on the Ops board, due Friday, assign it to me" →
add_card - "Move card 12 to Done" →
move_card(card=12, list_name="Done") - "What's overdue?" →
overdue() - "What is blocking the launch?" →
search_cards("launch")thenwhat_blocks - "Turn card 4 into a checklist" →
make_checklist - "Find cards like #7" →
find_similar - "Give me today's summary of the Ops board" →
daily_summary - "Who changed card 3 and when?" →
card_history - "Add bo@team.com to Ops as a viewer" →
add_member(owner only)
Remix it (one command)
dodil update # CLI 0.1.89 or later
dodil git remix rgboards/rg-boards --to-org <your-org> --name <your-repo>
The remix creates in your org: the repo, a DataK3 bucket <repo>-data with every table from
schema.sql, a user pool <repo>-users, and a service account (k3.editor on the bucket +
ignite.model-user) whose credentials go straight into the repo's CI secrets. CI then tests,
builds and deploys it to Ignite. Keep <your-repo> to 18 characters or less (the bucket name
<repo>-data must stay within 25).
After the remix: sign in (the one documented step)
The remix plus its first CI run take about 4-6 minutes. Then, in the org you remixed into:
# 1. Wait for the first deploy and get the app's URL (copy it from public_urls; never build it)
dodil ignite app get <your-repo> # deployment_state: deployed
# 2. Check the sign-in callback. The deploy registers it on the pool by itself, so this
# normally already lists https://<host>/.dodil/auth/callback:
dodil appid settings get <your-repo>-users
# Only if redirect_uris is empty or lacks it (settings set REPLACES the list):
dodil appid settings set <your-repo>-users --redirect-uris https://<host>/.dodil/auth/callback
- Open
https://<host>/and sign in. The pool allows public sign-up, so click Sign in and then Create account on the pool's sign-in page to make the first account. You land on an empty Lanes with a Create box: the board you create is yours (owner). Invite teammates from Members; they sign up with that email and see only the boards they are on.
That's it: the tables were created by the remix from schema.sql, and the app checks and adds
any missing column on start-up, so there is no SQL to run.
| You see | Why | Do |
|---|---|---|
remix fails at user_pool: pool limit reached for this plan | a free Dodil org holds one user pool; the org you remixed into already has one | remix into an org without a pool, or delete the unused pool, then dodil git remix resume <run-id> |
| the sign-in page says the redirect URI is invalid | the callback is not on the pool | step 2 above |
| the first page load takes 5-15 s | Ignite scaled the app to zero and is starting a pod | wait once; it then answers in well under a second (see Cold start) |
{"detail":"sign in first"} on /api/... | you are not signed in (expected) | open / and sign in |
| deploy check red | see the checks for the commit | dodil git check list <your-repo> <commit-sha>; dodil-ci/deploy says whether the callback was registered |
POST / self-check | dodil ignite invoke <your-repo> opens the bucket with the app's own credential and returns a row count per table |
Optional: Claude / ChatGPT and demo data
- Connector. Add a custom connector with URL
https://<host>/mcp. It signs you in through the pool, then arrives pending; approve it once:dodil appid client list <your-repo>-users→dodil appid client approve <your-repo>-users <client-id>. Or skip the approval by registering a first-party client once and giving its id in the connector's Advanced settings → OAuth Client ID:dodil appid client register <your-repo>-users --client-id lanes-ai --yes \ --redirect-uri https://claude.ai/api/mcp/auth_callback \ --redirect-uri https://claude.com/api/mcp/auth_callback \ --redirect-uri https://chatgpt.com/connector_platform_oauth_redirect - Invite instead of public sign-up:
dodil appid user create <your-repo>-users <email> --roles member. - Demo data. Three realistic boards for three of your users (overdue cards, a blocked-by
chain, checklists, a week of history), through the same use cases:
cd app && pip install -r requirements.txt BUCKET=<your-repo>-data DODIL_TOKEN=<your dodil token> \ python ../scripts/seed_demo.py --owner a@you.com --member b@you.com --viewer c@you.com
The signed-out page of a remix shows a "sign in / create an account" welcome; the shared demo
logins appear only on the author's instance (DEMO_POOL in .dodil/deploy.yaml).
Health checks: GET /healthz (no database), and dodil ignite invoke <your-repo> (POST /),
which opens the bucket with the app's own credential and returns a row count per table.
Run it locally
No Dodil account needed. In-memory store, you act as DEV_USER:
cd app && pip install -r requirements.txt pytest
BOARDS_STORE=memory DEV_USER=you@example.com uvicorn boards.main:app --port 8080
python -m pytest -q ../tests # 57 tests: roles, visibility, both doors, edges, extras
python ../scripts/ui_check.py # optional: the page in a real browser (Playwright):
# drag and drop, editor, roles, mobile, empty states
Against your real bucket: set BUCKET=<repo>-data and either the service account
(DODIL_SERVICE_ACCOUNT_ID / _SECRET) or DODIL_TOKEN=<your own dodil token>, plus
DEV_USER, and drop BOARDS_STORE. DEV_USER is ignored behind the gateway, which always
injects the real person.
How it is built
Light domain-driven design. The web routes and the MCP tools are two thin doors onto the same use cases, so every rule lives in one place and can't be skipped through the other door.
app/boards/
domain/model.py Board, Card, Role, Person and the rules: viewer can't edit,
only the owner manages members, assignees must be on the board,
no blocking loops, list names, due dates, labels
application/use_cases.py every action: create_board, add_card, move_card, assign_card,
update_card, my_cards, overdue, what_blocks, make_checklist,
find_similar, daily_summary, ... All reads scoped by the caller.
application/ports.py what the use cases need: BoardRepository, Assistant
infrastructure/repository.py DataK3 over the Postgres wire (sslmode=require)
infrastructure/memory.py the same contract in memory (tests, local demo)
infrastructure/migrations.py additive start-up migrations (create table / add column)
infrastructure/dodil.py service-account token, DataK3 connection, Dodil Models
doors/web.py JSON routes + the page -> use cases
doors/mcp.py MCP tools for Claude/ChatGPT -> the same use cases
doors/identity.py who is calling (the gateway's X-Dodil-User header)
doors/static/index.html the board UI (vanilla JS, no build step)
doors/static/landing.html the signed-out page (what it is, sign in, demo logins)
main.py wires it: /mcp, sign-in metadata (RFC 9728), /healthz, POST /
scripts/seed_demo.py realistic demo boards + a week of history, via the use cases
scripts/ui_check.py the page in a real browser: drag and drop, roles, mobile
tests/test_boards.py the rules through the use cases and through both doors
tests/test_edges.py every viewer write and stranger read on both doors, bad input,
races, AI outage, the history of every kind of change
schema.sql the tables, created by the remix
.dodil/remix.yaml what a remix creates
.dodil/deploy.yaml how CI deploys every copy
.github/workflows/ci.yml test -> build -> deploy (Dodil Git CD)
Design decisions: docs/DESIGN.md.
Cold start
Ignite scales the app to zero when nobody uses it, so the first request after a quiet spell starts a pod. Lanes keeps that path short:
- Nothing slow before the pod is ready. Start-up migrations, the service-account token and
the first DataK3 connections run in a background thread;
/healthzand the page answer as soon as uvicorn is up. (The remix already created every table fromschema.sql, so the migration only adds columns to an older bucket.) - A shared pool of warm DataK3 connections instead of one per worker thread, so the first page load (several parallel API calls) doesn't open five TLS connections at once.
- A small image. Ignite pulls the image again on every cold start, so
python:3.12-alpinewith binary wheels only, no pip in the final image, and byte-compiled code (the pod's non-root user can't write__pycache__). scaledown_period_secs: 900in.dodil/deploy.yamlasks Ignite to keep the pod 15 minutes after the last request (on the platform today it still scales down after ~5 minutes).
Measured on the live instance (2026-10-30), first request after scale-to-zero: 12.1-19.6 s
before (67 MB image, migrations before the pod was ready), 6.6-12.1 s after (37 MB image;
the migrations finish ~2.5-3.5 s after the pod already answers). What remains is Ignite's
own pod start and image pull, which varied from 1.1 s to 6.7 s for the same image.
dodil ignite app update <app> --auto-min-instances 1 removes the cold start entirely, at the
cost of one always-on pod.
Honest notes
- The remix gate, tested as far as a free org allows. On 2026-10-30 I remixed this repo
into my own org as
lanes-rt: the repo and the bucket were created andschema.sqlmade all 8 tables, then the run stopped at the user pool with "pool limit reached for this plan" (a free org holds one pool, and mine already holdsrg-boards-users). I deleted the test copy. A fresh org has no pool, so a judge's remix doesn't hit this, but it is the one thing I could not watch end to end. The sameremix.yaml+deploy.yamlbuilt and deployed this repo's own instance, and CI'sdodil-ci/remixcheck passes on every commit. - Drag and drop is a desktop gesture. On a phone the board scrolls column by column and a card moves from its List field (HTML5 drag events don't fire on touch).
- Cold start. The pod scales to zero after ~5 idle minutes; the first request after that takes 5-15 s. I did not turn on min-instances (it costs money in the remixer's org).
- Overdue uses the server's date (UTC). A card due today turns overdue at 00:00 UTC, which is 05:30 in India and 19:00 the day before in New York.
- AI calls can be slow.
kimi-k2.6is a reasoning model; the daily summary is written in the background and the page polls. If Dodil Models is down, AI actions answer 503 and change nothing; everything else keeps working.
What I'd improve next
- Real-time updates (the page refreshes after each action today; two people see each other's moves on their next action).
- Move the embedding cache onto DataK3's native vector column and
vsearchinstead of cosine in Python, once the board has thousands of cards. - A scheduled daily summary and overdue digest per board (Scriptum), instead of on demand.
- Comments and attachments (DataK3 objects) on cards, and card templates.
- Per-board roles in appid tenants, so an org admin can see every board.
Licence
MIT. See LICENSE. Built on the Dodil boards-challenge starter
(docs/DODIL-BOARDS-CHALLENGE.md is the original brief).