Sawera Boards — a team task board on Dodil
A Trello-style team task board that anyone can remix into their own Dodil organization and have running in minutes. Boards → lists → cards, real roles, an activity log, and the same actions from a web page and from Claude or ChatGPT over MCP — because both doors call one shared set of use cases.
Built for the Dodil Boards Challenge. MIT licensed.
What it does
The core (all of it, working):
- People and roles. The owner invites members. Roles:
owner,member,viewer. A viewer can't change anything; only the owner manages members. Nobody can see a board they are not on — it doesn't exist for them. - Boards → lists → cards. Every board starts with To do / Doing / Done, and lists can be added, renamed and removed. A card has a title, description, assignee, due date and labels, and moves between lists by drag and drop on the web page (or by name over MCP). The owner can also delete a board — every list, card and comment goes with it.
- Activity log. Every change is recorded — who, when, what changed — and readable on the web page and over MCP.
- Card comments. Each card carries its own discussion: members comment on
the card detail (web) or through MCP (
add_comment/card_comments), viewers can read but not write, and a 💬 count on the card face shows where the conversation is. - MCP actions, not table access.
my_cards("what's on my plate this week?"),add_card,move_card,assign_card,overdue_cards("what's overdue?"),what_is_blocking, and more — always scoped to the boards the signed-in person can see.
Extras:
- Overdue flags — computed on read, never stored, so they can't go stale. Cards in a done-like list (Done, Complete, Shipped…) are never overdue.
- Blocked-by links — a card can be blocked by other cards on its board; circles are refused by the domain rules, and "what is blocking the launch?" walks the chain. Deleting a blocker frees the cards behind it.
- AI (Dodil Models) —
suggest_checklistturns a card title into a checklist;daily_summarywrites a short stand-up summary of a board. - Search across your boards: word matches first, then semantic search — Dodil embeddings rank cards by meaning, so "money owed" finds "Send the invoice". A card's vector is computed when the card is saved (against a hash of its text, recomputed only when the text changes), so a search only has to embed the question and answers in seconds; if the AI is unavailable the word matches still come back.
Run it locally (no Dodil account needed)
cd app
python -m venv .venv && . .venv/bin/activate
pip install -r requirements.txt pytest
python -m pytest -q ../tests # the rules, through the use cases and both doors
To run the server against your remixed bucket, set BUCKET,
DODIL_SERVICE_ACCOUNT_ID, DODIL_SERVICE_ACCOUNT_SECRET and
DEV_USER=you@example.com (acts as that person without the gateway; local
only), then uvicorn boards.main:app --port 8080.
Remix it into your own organization
dodil update # CLI 0.1.89 or later
dodil git remix <org>/sawera-boards --to-org <your-org> --name <your-repo>
Keep <your-repo> to 18 characters or less (the data bucket is
<repo>-data, max 25). The remix creates the repo, the DataK3 bucket with the
tables from schema.sql, the <repo>-users pool, and a service account with
data and Models access; CI then tests, builds and deploys it to Ignite.
After the remix — the one manual step: let people sign in. Add the app's
callback URL to the new pool, exactly as dodil guide remix describes under
"After the remix: let people sign in". Until that step is done, the app is up
but sign-in can't complete.
Check any copy before or after remixing:
dodil git remix check <your-org>/<your-repo> # must say: valid
Connect Claude or ChatGPT
The app serves MCP at https://<your-app-host>/mcp. Add it as a custom
connector in Claude (or ChatGPT): it asks you to sign in through your pool,
then approve the client once with dodil appid client list <repo>-users and
dodil appid client approve <repo>-users <client-id>. Try: "What's on my
plate this week?", "Add a card 'Send invoice' to Doing on the Ops board",
"Move the invoice card to Done", "What's overdue?", "What is blocking
the launch card?"
How it is built
Light Domain-Driven Design, one rule in one place:
app/boards/
domain/model.py Board, BoardList, Card, Role — the rules
(a viewer can't edit, only the owner manages
members, blocked-by circles are refused)
application/use_cases.py every action: create_board, add_list, add_card,
move_card, assign_card, my_cards, overdue_cards,
what_is_blocking, daily_summary …
application/ports.py what the use cases need (repository, AI assistant)
infrastructure/dodil.py service-account token, DataK3, Dodil Models
infrastructure/repository.py boards, lists, cards, members, activity (DataK3)
doors/web.py JSON routes + the board page -> the use cases
doors/page.py the page itself (one HTML file, no build step)
doors/mcp.py MCP tools -> the same use cases
doors/identity.py who is calling (the gateway's X-Dodil-User header)
main.py wires it together, /mcp, sign-in metadata, /healthz,
and a POST / self-check that proves the app can
reach its own data with its own credential
tests/test_boards.py the rules, through the use cases and both doors
schema.sql the tables, created by the remix
.dodil/remix.yaml what a remix creates
.dodil/deploy.yaml how CI deploys every copy
Design decisions, briefly
- Fractional positions order cards in a list: a drag is one UPDATE, never a renumbering.
- Overdue and blocked are derived, not stored: a card's flags are computed from its due date, its list and its blockers on every read.
- Done is a name, not a flag: a list whose name reads as finished (done / complete / completed / shipped / finished / closed) ends overdue and blocking. Rename the list and the meaning follows.
- Labels and blocked-by are stored comma-joined in one column each, cleaned and validated by the domain. At board scale this keeps the schema flat and the queries trivial; a join table would be the first change at 100× the data.
- Visibility is enforced in the use cases (
_board()turns "not yours" into "not found"), so no door can leak a board by accident.
Honest notes — what was hard
- The MCP server in the Python SDK returns a list result as one JSON text item per element, not one JSON array; the door tests assert against that real behaviour.
- Validation must happen before mutation: an early version of
set_blockersassigned the new links before checking them for circles, and a refused call still poisoned the in-memory card. It now validates the proposed links first, and a test pins that ordering down. - DataK3 wants natural keys and no sequences, so ids are short random hex chosen by the app; every write is an upsert, which makes CI retries safe.
- A remix creates the bucket's tables from the template's schema at remix
time, so a live copy can hold an older table shape than the code expects.
The app upgrades its own bucket at startup with idempotent statements
(
ensure_schemain the SQL repository): on a fresh bucket they are no-ops. One starter column could not be retired that way —cards.list_nameis NOT NULL in the starter shape and DataK3 does not support dropping a NOT NULL constraint — so the app writes the denormalized list name on every card save, andschema.sqlkeeps the column for new buckets too.
What I would improve next
- File attachments (Dodil files) on the card detail.
- Due-date reminders in the daily summary ("due tomorrow" section).
- "Find cards like this one" (card-to-card similarity) on top of the shipped semantic search.
- An archive for done cards older than a chosen date.
Licence
MIT. See LICENSE.