Benjamin ThaiSoftware Engineer · ML
← Projects
Web2026Complete

AI Kanban Board

A Kanban board with an AI sidebar that moves the cards itself, through the same validated code path the buttons use.

GitHub ↗
Next.js 16React 19FastAPIOpenRouterPlaywright
01THE PROBLEM

A board is mostly data entry. The question is whether you can just say what you want.

Kanban is a good interface for reading work and a tedious one for changing it. Clearing a column means deleting cards one at a time; moving a batch to the next stage means dragging each one; renaming a card means finding it, opening it, editing it, saving it. None of it is hard, and all of it is clicking.

So the premise was to give the board a second interface: say “move everything in review to done” and have it happen. That's a different problem from a chat sidebar that summarizes your board, because the model's output stops being text somebody reads and starts being an instruction something executes. The interesting engineering isn't getting the model to answer. It's deciding what happens when the answer is wrong.

02SCOPE

What the assistant has to get right.

Executable

A turn returns a message plus an ordered list of typed actions: add, move, update, delete, rename, add or remove a column. Prose can't be executed, so prose isn't what the board consumes.

Guarded

Every action is checked against the live board before it runs. An id the model invented, a column that doesn't exist, a delete aimed at nothing: all dropped, without taking the rest of the batch down with it.

Identical

An AI action and a button click end up in the same pure reducer. There is one implementation of what it means to move a card, so the assistant can't reach a state the interface couldn't.

Built as a portfolio project rather than for real users: one SQLite file, one container, a seeded demo account, and the whole board stored as a single JSON blob per row. The AI runs through OpenRouter, so the model is a config value rather than a dependency, which turned out to matter more than expected.

03THE APPROACH

One reducer, two callers.

apply_board_action is a pure function: board in, new board out, never mutating its input. The REST endpoint behind the buttons calls it. The AI path calls it too, once per action the model proposed, after each one has been checked against the ids that actually exist. Nothing about the reducer knows which caller it has.

That's what makes the assistant safe to wire up directly. It isn't trusted, it's funnelled. The model's job ends at proposing a list of actions; the decision about whether any of them is legal belongs to code that was already there for the buttons.

The frontend is a static Next.js export served by the same FastAPI process that owns the API, so the whole app is one container and one port. Drag-and-drop is dnd-kit, and a chat turn that changes the board returns the new board, so the columns repaint from the same payload a drag would have produced.

04HOW IT WORKS

Three stages, one reducer at the end.

01 · ASK

Board state in the prompt

The system prompt carries the action schema and the current board as JSON (every column id, every card id, every title) plus the last twenty messages of history.

02 · VALIDATE

Check each action, drop the bad ones

Ids are matched against the real board. A valid action runs; an invalid one is skipped and the rest of the batch continues, so one bad id can't cost you the other nine edits.

03 · APPLY

Through the same path as a click

Survivors go through apply_board_action, the board is saved with a bumped version, and the response carries the new state back for the UI to repaint.

05INSIDE A CHAT TURN

From a sentence to a moved card.

Every message runs this pass. Step three is the one that decides whether this is a feature or a liability.

01

Assemble the turn

One system message carries the action schema and the live board state as JSON, followed by the last twenty stored messages and the new one. The board has to travel inside that single system message. Sending it as a second one is exactly the bug described further down.

02

Constrain the reply, then parse it forgivingly

The request asks for a JSON object, but a cheap model doesn't always comply. The parser strips code fences, skips leading prose to the first brace, and decodes in a non-strict mode so literal newlines inside strings don't kill the turn. If what comes back still has no message string, the turn fails loudly rather than half-applying.

03

Validate every action against the real board

Each action is checked on its own terms: a move needs a card id that exists and a target column that exists, a rename needs a real column and a non-empty title, a delete needs a card actually on the board. Anything that fails is skipped rather than corrected or guessed at, and the loop keeps going. Column ids are recomputed as it goes, so a card can be added to a column the same batch just created.

04

Apply through the shared reducer and version the save

Surviving actions run through the same pure function the REST endpoint uses, in the order the model listed them. The result is written back as one JSON blob with an incremented version counter, and both the user's message and the assistant's reply are appended to the per-board transcript.

05

Repaint from the response

When anything changed, the new board travels back with the reply and the UI swaps its state wholesale. No refetch, and no second code path for “the AI did it” versus “I dragged it”.

06INTERFACE DECISIONS

Choices behind the copilot.

The model proposes, the server disposes
Structured actions validated against real ids mean a hallucinated card can't corrupt the board. The alternative, letting the model return a whole new board state, would make every hallucination a data-loss event.
Invalid actions are dropped, not rejected wholesale
“Move everything to done” is ten actions, and one bad id shouldn't cost the user the other nine. Each action succeeds or fails on its own.
The whole board is one JSON blob
Columns, cards, and their order are one document that's always internally consistent, and the reducer can be a pure function over it. Normalized tables would have bought per-card concurrency this app has no use for.
The model is an environment variable
OpenRouter behind a single config value meant that when the pinned free model was retired, swapping it was one line, and it makes “use a better model” a deployment decision rather than a rewrite.
07SCREENS
▶kanban.mp4
Boards on the left, columns in the middle, the copilot down the right. Press play for the full walkthrough, including the turns where the assistant gets it wrong.
The board at rest: multiple boards per account, columns you can rename or add to, and a copilot that starts by telling you what it can do rather than waiting to be guessed at
The board at rest: multiple boards per account, columns you can rename or add to, and a copilot that starts by telling you what it can do rather than waiting to be guessed at
The manual path: title, details, priority, and a due date, straight into the column
The manual path: title, details, priority, and a due date, straight into the column
Cards carry a priority and a due date; the first instruction goes out to the copilot
Cards carry a priority and a due date; the first instruction goes out to the copilot
“Delete all the items in the to do column”, then a move, then “can you delete all items” — one sentence per bulk edit that would otherwise be a dozen clicks
“Delete all the items in the to do column”, then a move, then “can you delete all items” — one sentence per bulk edit that would otherwise be a dozen clicks
Session-cookie auth over bcrypt, with a seeded demo account
Session-cookie auth over bcrypt, with a seeded demo account
08TRADEOFFS

What I chose, and what it cost.

Chose

Letting the assistant execute without confirmation

Cost

“Delete all items” empties the board the moment you press enter. There's no confirmation step and no undo, which is survivable on a demo board and would not be on real work. The validation layer is the natural place to hold a destructive batch pending approval, and it doesn't.

Chose

The cheapest model that could hold the contract

Cost

This is the real limitation of the project. On a small free-tier model the assistant misfires often enough to notice: it will occasionally return a bare action instead of the required envelope, or reply “Moved the card to Done” while proposing nothing at all, so the board doesn't change while the transcript says it did. The architecture is model-agnostic and the fix is a config value, so a stronger model with real structured-output support would take most of this away. The demo runs on the weak one, and the recording shows a “try again” for exactly that reason.

The assistant looked stupid. It was actually blindfolded.

The symptoms all pointed at a bad model. Ask it what was on the board and it invented cards. Ask it to move everything out of Backlog and it asked for a column id, one that had been sent to it in the payload. Deletes did nothing. The obvious read was that a small free model simply couldn't follow the schema, and the obvious fix was a better model. What was actually happening is that the board state was being sent as a *second* system message, separate from the prompt, and the provider drops that second message most of the time. Same question, six runs each: with two system messages the model could see the board in two of six replies; with the state folded into the single system message, six of six. It had not been failing to follow instructions. Most turns, it genuinely had no board to look at, and everything it said afterwards was an honest guess. Merging the state into one message is a three-line change. Finding it took measuring the thing I had assumed, which is the part I'd want to have done sooner: I spent a while improving prompt wording against a model that was, most of the time, never receiving the data the wording referred to.

09WHAT'S NEXT

Where I'd take it.

→

Reconcile the reply against what actually happened. The server already knows whether any action applied, so a turn that claims a move while changing nothing should say so instead of passing the model's optimism through to the user unedited.

→

Move to a model with real structured-output support and drop the forgiving parser. The fence-stripping and prose-skipping in the parser exist to paper over a weak model, and they'd be dead code behind a schema-constrained one.

→

A confirmation step for destructive batches. Deleting every card on the board should not be indistinguishable, at the point of execution, from moving one card left.