Design notes
Short reasons for the choices that shape the code.
Two doors, one set of rules
The web routes (doors/web.py) and the MCP tools (doors/mcp.py) only translate: read who is
calling, call one use case, turn domain errors into 403/404/422 or a tool error. Every rule lives
in domain/ or application/, so Claude can't do anything the web page can't, and the other
way round. The tests check the same rules through both doors.
Roles live on the board
Each board has one owner (on the boards row) and members with member or viewer (in
memberships). That is the smallest model that answers the brief: a viewer can't edit, only
the owner manages people, nobody sees a board they're not on. A board you can't see raises
NotFound, never Forbidden, so its existence doesn't leak through ids, titles or card
numbers.
Card numbers instead of ids
People and AI assistants talk about "card 12", so the card's primary key is that number.
DataK3 has no sequences: insert_card reads MAX(number) + 1 from card_numbers and claims
it with INSERT ... ON CONFLICT (number) DO NOTHING; if another writer got there first, it
tries the next one. A claimed number stays claimed after its card is deleted, so a new card
never inherits an old card's history, links or embedding. Numbers are unique across the app,
so "card 12" needs no board. Card history is still read with the board id as well as the
number.
Each write touches only what it names
save_board writes the title and owner, save_list one list, save_membership one person.
Members are never written as a side effect of saving a board, so a request that loaded the
board before the owner removed someone can't bring that person back.
Names, not ids, at the edges
Boards are found by id or title, lists by id or name, case-insensitively. Claude can pass what the person said ("Ops", "doing") and the error messages list the real names when it misses, so the model can correct itself in one step.
The activity log is a diff
edit_card returns the new card plus the fields that really changed. Only those are logged,
one line each, with before and after. A save that changes nothing writes nothing.
Done is a property of a list
Lists carry is_done. Cards in a done list are never overdue and never on your plate, and a
blocker in a done list no longer blocks. This keeps custom lists working (rename "Done" to
"Shipped" and nothing breaks).
AI stays an assistant
- The checklist is a suggestion; nothing is written until a person (or Claude, asked by one) keeps it.
- The daily summary counts the facts in code (what moved, what's late, what's blocked) and the model only writes them up. If the model is down, you get the plain facts.
- Similar cards use
jina-embeddings-v4. Vectors are cached per card with a hash of its text, so a card is embedded again only when its title or description changes. Results are filtered to boards the person can see before ranking.
Which chat model
Measured on Dodil Models on 30.10.2026, same prompts, three runs each: kimi-k2.6 wrote the
daily summary in 28 s once and timed out at 40 s twice; kimi-k2.7-code took 5-7 s for the
summary and 2-3 s for a checklist, with the same quality for these short, factual jobs. Ignite
gives a request 60 s, so the app uses kimi-k2.7-code (CHAT_MODEL in .dodil/deploy.yaml
switches it back).
DataK3 access
One connection per worker thread, reused while the service-account token is fresh (the token is also the password). The first version opened a TLS connection per query, which made a board with a few lists noticeably slow. Small lists on a card (labels, blockers, checklist) are JSON in TEXT columns: they are always read and written with the card.
A board from one sentence
set_up_board takes a title, a goal, a deadline, people and, optionally, the steps. From Claude
or ChatGPT the assistant writes the steps itself (it already knows the conversation) and the
tool only checks and writes them; on the web page Dodil Models drafts them. Either way the
whole plan is checked first (titles, lists, assignees on the board, "after" pointing only to
earlier steps, so no loops are possible), and nothing is written if one step is wrong.
The cards are written straight through the repository in plan order, so each card already
knows the numbers of the cards it waits on. Going through add_card and block_card for each
card cost about ten round trips per card and took over 60 s for a nine-card board when run from
Kyiv against London; now it is a few seconds. After the first card, the next number is tried
as the last one + 1, which saves four MAX() queries per card and still falls back to the full
search if another writer got there first.
Example board and public demo
An empty copy offers an example board: a website launch with nine cards, two late, and a chain of blockers, so every feature has something to show in the first minute.
The live copy also runs a public demo: three accounts (owner, member, viewer) with one password
shown on the front page, and a "Reset the demo" button that deletes the demo owner's boards and
builds them again. It is switched on only when the app's pool equals DEMO_POOL, so a remix
never shows or honours these accounts.
Page
A stack of tabbed index cards, which is where the name comes from. Each list is a folder with a coloured tab (sage, butter, blush, sky, lilac, in turn); each card is an index card with its number on top. A late card has a red edge and says how many days late. Titles are set in Fraunces with its soft axis on, everything you click in Hanken Grotesk. The logo is three tabbed cards in the list colours. On a phone the lists stack.
Signed out, / is a front page: the headline, a fan of three cards from a board Claude has just
set up with the question "what is blocking the launch?" beside them, then the demo accounts and
how to connect Claude. It holds no board data. Two earlier looks went: a printed gazette with a
blackletter nameplate (it read as a newspaper, not a board) and a plain product look (it read as
a template).
Two HTML files and one stylesheet, no framework, no build step, no outside scripts. Every value is escaped before it reaches the page. Viewers get the same page with editing turned off; the server refuses their writes anyway.