How I built one shared core for Telegram, Discord and Slack: a manifest, an adapter, and no fourth copy
Ankita AI is a desktop AI assistant by Krish Verma, and until last week it talked to Telegram in two different voices. The terminal daemon had its own Telegram code, and the desktop app had its own β same bot, same user,
Ankita AI is a desktop AI assistant by Krish Verma, and until last week it talked to Telegram in two different voices. The terminal daemon had its own Telegram code, and the desktop app had its own β same bot, same user, two implementations that could disagree about who was allowed to talk to it. PR #73 deleted that duplication and replaced it with one shared channel core that Telegram, Discord, and Slack all run through. The project lives at Ankita on GitHub, and the website is ankita-sable.vercel.app.
The problem: one bot, two brains
Before the change, the daemon's src/automation/daemon.mjs carried its own Telegram handling, and desktop/electron/channels.mjs carried another. A fix to approval handling in one place did not reach the other, and adding a second channel would have meant writing the same plumbing a third time. The merge deleted 355 lines from the daemon file and rewrote the desktop channels file in a 533-line diff β not by moving code, but by deleting both copies and pointing both surfaces at the same host.
That host is src/channels/core/: sixteen small modules, one per concern. contract.mjs holds the shared limits, registry.mjs validates channel manifests, host.mjs owns runtimes, policy.mjs owns who may speak and what a "yes" means, and runtime.mjs drives each turn. The desktop and the terminal daemon are just two surfaces on top of it β DaemonChannelSurface hands the same runtime the daemon's agent pool, and the desktop hands it the Electron engine. Neither surface re-implements receiving, approving, or replying.
The contract: a manifest, an adapter, and nothing else
Adding a channel now means writing two things: a manifest and an adapter. The manifest declares the channel's id, its settings fields, and the capabilities it supports, drawn from a fixed list β typing, react, edit, upload, threads, buttons, voice. The adapter implements start, stop, and send, plus one method per capability it claims.
The registry enforces that deal at load time. assertAdapter throws if an adapter advertises a capability it does not implement, so a channel can never promise a button it cannot render. Manifest fields are validated too: secrets are capped at 512 characters, lists at 1,024, and a secret field is forbidden from shipping with a default value β an empty default is the only honest one for a credential. Every envelope the core passes around must be JSON-serialisable, enforced by assertSerializable, which rejects functions, symbols, and bigints outright.
The three shipped manifests show what the contract buys. Discord advertises typing, reactions, edits, uploads, threads, and buttons, and requires a token plus an application id. Slack advertises reactions, edits, uploads, threads, and buttons β no typing, because its adapter does not implement it β and requires a bot token plus an app token. Telegram advertises typing, reactions, uploads, threads, and voice, and requires only a token. No channel can claim a capability the list does not know, and no capability is silently missing: the absence is declared in the manifest, not discovered at runtime.
The safety rules live in the core, not the adapter
This is the part that made the shared core worth building. Every safety rule that used to be duplicated β or worse, slightly different β now sits in policy.mjs and contract.mjs, where no adapter can override it.
A message is only answered if it passes an allowlist: the chat id must be listed, or the sender must be listed, or the server must be listed. Bots, webhooks, and system messages are rejected before any of that. In a group chat the default mention policy is mention β the bot ignores group messages unless it is explicitly mentioned. Approvals are bounded: a confirmation window must be between 30 seconds and 3,600 seconds, defaulting to 300, so a typo can never make an approval impossible and an approval can never stay live forever.
Pairing a new sender is deliberately unforgiving. PairingCodes issues an 8-character code from 6 random bytes, valid for 120 seconds, with at most 32 outstanding grants. consume() deletes the grant on the first attempt β a wrong guess burns it, and the owner issues a fresh code. That is harsh on a typo, and it is the point: a code that survives guessing is not a gate.
The execution budgets are shared too. A channel turn gets 24 tool calls, the host runs at most 4 agent turns concurrently, and progress messages are capped at 80 characters containing tool names only β never arguments, never model text. At most 5 files may leave the workspace per turn, totalling 15 MB, and a Telegram photo over 10 MB is sent as a document instead. Delivery receipts are written to channel-deliveries.json on disk, so a restart cannot turn a delivered reply into a duplicate: the durable inbound claim happens before dispatch, and a replayed input is refused.
What it cannot do yet
The verification doc is honest about the gaps, and so am I. The 246 affected checks pass and the merge ran 1,825 serial tests green, but the real-account acceptance checks β a live text, approval, file, and reconnect round-trip against a real Telegram, Discord, and Slack account β remain open. The tests prove the core against fixtures, not against the platforms. And you must not run the same bot from the terminal and the desktop at the same time: the receipts prevent duplicate replies, but two consumers fighting over one bot token is explicitly not a supported mode.
The question I am still arguing with myself
The shared core is a ceiling as much as a floor. Any channel that cannot live inside 24 tool calls per turn, 4 concurrent turns, and a 5-minute approval default simply cannot ship as an Ankita channel β the core will not bend for it. That is what makes the safety rules real instead of advisory, and it is also what will block the first channel that genuinely needs more.
Tell me I'm wrong: is a manifest contract that refuses to grow per-channel escape hatches a safety boundary, or have I built a ceiling that the next channel will have to break through? Where exactly would it break first β the call budget, the concurrency cap, or the approval window?
β Krish Verma, creator of Ankita AI
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.