Every interface you add to an AI-powered app comes with a tax: an intent router written by hand, for that interface, parsing the same instructions your other interfaces already handle. CLI gets one. Telegram gets another. MCP gets a third. By the third interface, you're not building a product — you're maintaining parsers.
ExoKanban is a personal Kanban board where all three interfaces — CLI, Telegram bot, and MCP server — share the same object with zero duplicated routing logic. Here's how it's built, and why the architecture looks the way it does.
The foundation: an active Card
Every task in ExoKanban is a Card. Nothing remarkable about the fields:
class Card(ExoSQLModel, table=True):
title: "str = \"\""
description: "str = \"\""
priority: str = "medium" # low | medium | high | critical
tag: Optional[str] = None
due_date: Optional[str] = None # ISO 8601
archived: bool = False
column_id: Optional[int] = Field(default=None, foreign_key="column.id")
What's different is the base class. ExoSQLModel inherits from ExoModel and adds SQLModel persistence — so the same object that speaks to SQLite also speaks to an LLM.
Creating a card from natural language:
card = await Card.create("schedule annual medical checkup, high priority, due next Friday")
# title="Annual medical checkup", priority="high", due_date="2026-06-13", ...
No parser. No form. No if "priority" in text: extract_priority(text). The schema is the prompt — ExoModel uses the field types and names to infer what to populate.
Updating an existing card is the same pattern:
await card.update_object("change priority to critical and add a tag for health")
# card.priority == "critical", card.tag == "health"
The object updates its own fields. The LLM doesn't return a dict you map by hand — it returns a validated instance.
The architecture decision that makes everything else simple
ExoKanban has a KanbanService class that wraps all board operations: create card, move card, search cards, list overdue items, export to CSV. This service has no knowledge of how the user is communicating with the board.
class KanbanService:
async def new_card(self, prompt: str) -> Card:
card = await Card.create(prompt)
await card.save()
return card
async def move_card(self, card_id: int, column_name: str) -> Card:
card = await Card.get(card_id)
column = await Column.find(name=column_name)
await card.update_object(f"move to column {column.name}")
await card.save()
return card
Three interfaces call the same service. None of them implement intent routing.
Interface 1: CLI
The CLI reads raw user input and passes it to KanbanService. The adapter layer is thin by design:
/new schedule annual medical checkup
/move Today
/update change priority to critical
/due
A UserInteraction class handles the CLI I/O loop. It doesn't parse commands — it dispatches them by prefix to the right service method. The understanding happens inside Card.create() and card.update_object(), not in the interface.
Interface 2: Telegram bot
The Telegram interface reuses KanbanService without modification. A single bot handler:
@bot.message_handler(func=lambda m: m.text.startswith("/new"))
async def handle_new(message):
prompt = message.text[4:].strip()
card = await kanban.new_card(prompt)
await bot.reply_to(message, card.to_ui())
to_ui() is a method on the Card object itself. The Telegram handler doesn't format the response — it renders what the object already knows how to render.
The same commands work over Telegram that work in the CLI, because both are calling the same service, which calls the same object.
Interface 3: MCP server
The MCP layer exposes 11 tools over HTTP using FastMCP, making ExoKanban usable directly from Claude Desktop or Claude Code:
@mcp.tool()
async def create_card(prompt: str) -> dict:
"""Create a new card from a natural language description."""
card = await kanban.new_card(prompt)
return card.model_dump()
@mcp.tool()
async def search_cards(query: str) -> list[dict]:
"""Semantic search across all cards."""
cards = await Card.search(query)
return [c.model_dump() for c in cards]
The MCP tools are thin wrappers. The intelligence is still in the object — ExoModel's native RAG handles semantic search across card content without a separate vector store.
Starting the server:
python mcp_server.py # runs on port 8000 with Bearer auth
After that, Claude Desktop can create, update, move, and search cards through natural conversation — because the tool signatures are just prompt: str and the object knows what to do with it.
What this actually demonstrates
ExoKanban is an intentionally simple project. The board logic — columns, card state, priorities — is not the interesting part. What's interesting is the surface area of the integration code.
Three interfaces (CLI, Telegram, MCP) talking to an SQLite-backed board with natural language input and semantic search. The total integration code is thin wrappers and I/O adapters. There's no intent router because the router lives inside the object, not the interface.
That's the ExoModel premise: put the understanding at the object level, and every interface above it becomes a thin adapter instead of a parallel implementation.
Try it
git clone https://github.com/exomodel-ai/exokanban
cd exokanban
pip install -r requirements.txt
cp .env.example .env # add your LLM provider key
python cli.py
Supports Google Gemini (default), Anthropic Claude, and OpenAI. Swap providers without touching application code.
ExoModel itself:
pip install exomodel
GitHub: exomodel-ai/exomodel — star it if this is useful.
ExoKanban: exomodel-ai/exokanban
Top comments (0)