sirenamc/stackline

Stackline, a team task board you run from the web or from Claude/ChatGPT. FastAPI + DataK3 + appid + Models + MCP.

A remix template · 0 remixes · Remix sirenamc/stackline

DESIGN.md

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.