Stop Building Your AI Assistant Three Times
Most SaaS products build their AI assistant three times. Once for the web widget. Once for the chat bot. Once for the API. Three conversation stores. Three sets of tools. Three auth paths. Three places to drift. We did this. It was bad. We replaced it with one AssistantChat entity and three adapters that read and write the same data. Reply in Telegram, continue on web, agent picks up via MCP. One brain, three surfaces.
The “AI assistant” is the most bolted-on feature in modern SaaS. The default implementation pattern is to build it once per surface — a web widget here, a Telegram bot there, an API endpoint for partners — and end up with three assistants that do not know about each other. The user replies to the Telegram bot, then opens the web app and the conversation is not there. The agent via MCP has no idea what the user said in chat.
This is the architecture we landed on after living with the three-assistant pattern for a year, and the concrete decisions that made it work.
The “three assistants” anti-pattern
What it looks like in practice:
- Web assistant lives in a Vue/React component with its own message store. Great UX, but conversations do not leave the browser session.
- Telegram bot has its own conversation state, often just a flat array of recent messages keyed by chat ID. No runs, no proposals, no structured tool calls.
- API/agent endpoint is a stateless “ask the LLM” function. No continuity.
Each one re-implements: prompt construction, tool definitions, auth checks, model selection, retry, streaming, error classification. Four places. Four drift surfaces. When you add a new tool, you add it four times. When you fix a bug in one, you forget to fix it in the others.
The single-entity pattern (AssistantChat)
The fix is one entity that represents a conversation, regardless of which surface started it:
AssistantChat
id
scope (board / namespace / account)
userId
messages[]
role (user / assistant / tool)
content
toolCalls[]
adapterOrigin (web | telegram | mcp)
AssistantRun
chatId
model
status (running | completed | failed)
weightedTokens
proposals[]
type (task_create | task_update | ...)
payload
appliedAt
A run is one assistant turn. A proposal is a structured change the assistant wants to make (create a task, update a status). Adapters render the same chats, runs, and proposals differently — but they all read and write the same rows.
Adapter responsibilities
Each adapter is four small things:
- Resolver — figures out the scope from the surface context. Telegram DM points to active board/namespace. Group points to linked board. Web points to current route. MCP reads from session.
- Streamer — how tokens reach the user. Web: SSE. Telegram DM:
sendMessageDraft(Bot API 9.3+). Telegram group: placeholder + throttlededitMessageText. MCP: tool-call result. - Reader/Actions — read chat history, apply proposals, start new sessions.
- Auth binding — maps the surface identity to a user. Telegram: chat ID + message ID points to conversation via a binding row. Web: session. MCP: API key + session.
The assistant core (model call, tool dispatch, proposal generation) is adapter-agnostic. It does not know or care which surface started the turn.
Telegram-specific gotchas
Three things bit us shipping the Telegram adapter:
sendMessageDraft is private chats only. Confirmed against GramIO and official docs. Group chats do not support it. Our fallback: send a placeholder message, then editMessageText every 500ms (env-tunable). Not as smooth as DM streaming, but keeps one-message-per-turn — group chats hate message spam.
Reply chains bind sessions. A user can resume an old conversation by replying to the bot’s earlier message. We persist an AssistantTelegramBinding (chat ID + message ID points to conversation ID) and look it up on every reply. Without this, every DM is a fresh session and continuity is lost.
Telegraf 4.16.3 predates sendMessageDraft. We call callApi directly with a type cast. Not pretty, isolated in one file (TelegramStreamSender.ts).
Decision note: we considered building a separate “Telegram assistant” that did not share state with the web assistant. Rejected because the moment a user uses both surfaces, they have two assistants that do not know about each other. That is the bug we were trying to fix.
Approval/apply flow shared across surfaces
Every assistant turn can produce proposals (e.g. “create task X,” “update status of DEV-123 to Done”). On web, these render as diff cards with Apply/Cancel buttons. On Telegram, they render as inline keyboards (asst_apply_<id> / asst_cancel_<id>).
Both invoke the same applyBoardChanges code path. The audit log records the proposal, who applied it, and from which surface. No drift.
This is the part most products get wrong — the “agent suggested something, what happens next” experience. If web has rich diff cards but Telegram has nothing, users on mobile cannot act on proposals. Same data, same apply path, different rendering.
FAQ
Q: How do I build an AI assistant for multiple platforms? A: Build one conversation entity (AssistantChat) and write per-platform adapters that read and write to it. Never build the assistant logic three times — the model call, tool dispatch, and proposal generation should be adapter-agnostic.
Q: What is the AssistantChat pattern? A: A single database entity representing one assistant conversation. Each turn creates a run with messages. Adapters (web widget, Telegram bot, MCP server) render the same data differently but read and write the same rows.
Q: Does Telegram support streaming bot messages?
A: Telegram’s sendMessageDraft API supports streaming but only in private chats. Group chats require a fallback: send a placeholder message and edit it in place with a throttle.
Q: How do I handle approval flows across surfaces? A: Persist proposals as structured rows on the run. Each surface renders its own UI for them (web: diff card; Telegram: inline keyboard), but the apply action invokes the same backend code path.
Q: Is this approach more expensive than a single-surface assistant? A: Slightly more up-front engineering (the adapter abstraction), materially cheaper to maintain (one set of tools, one auth path, one audit log). The cost crosses over within the first month.
Conclusion
The assistant is not a feature. It is a layer. Once you stop building it per-surface and start building it as one entity with adapters, every surface benefits from every improvement. Telegram gets the same proposals as web. MCP gets the same tool set. Mobile users stop being second-class citizens.
→ Read about our MCP vs REST API strategy · How we replaced our paywall with a quota · Get started with Kangram