channel / concepts
Platforms
channel_messages.What a platform is
In the channel domain a platform is just a ChannelKind with a delivery mechanism behind it. The kind is an open union: the package ships literal types for the channels it knows about, and any other string is equally valid — your editor keeps autocomplete for the known ones without closing the door on custom kinds.
export const WEB_CHANNEL = "web";
export const EMAIL_CHANNEL = "email";
export const WHATSAPP_CHANNEL = "whatsapp";
/** Open union: known channels get literal types, custom channels are allowed. */
export type ChannelKind = "web" | "email" | "whatsapp" | (string & {});The same openness applies to the platform config: slack, teams, gchat, discord and telegram are typed keys, and any additional string key is accepted. A kind you invent — sms, kiosk, an internal tool — flows through the same schema, the same links, the same UI components.
Configuring platforms
createChannels takes one entry per platform you want live. The shape of each entry is that platform's credentials and settings — bot tokens, signing secrets, app ids — and it is passed through to the underlying delivery runtime untouched. The channel package does not re-model every platform's auth; it forwards your config to the code that actually speaks the protocol.
import { createChannels } from "@ekairos/channel/platforms";
import { db } from "@/lib/db"; // InstantDB admin client
export const channels = await createChannels({
db,
userName: "ekairos",
platforms: {
slack: {
botToken: process.env.SLACK_BOT_TOKEN!,
signingSecret: process.env.SLACK_SIGNING_SECRET!,
},
telegram: { botToken: process.env.TELEGRAM_BOT_TOKEN! },
discord: { botToken: process.env.DISCORD_BOT_TOKEN! },
},
resolveContextId: async ({ channel, threadKey }) => {
const thread = await ensureThread({ key: `${channel}:${threadKey}` });
return thread.contextId;
},
react: async (inbound) => {
const reaction = await reactOnThread(inbound.contextId, inbound.message);
return reaction.text;
},
});A platform you do not configure simply does not exist at runtime: no webhook handler is exposed for it, no state is kept for it. Adding a platform later is adding one key to platforms and pointing its webhook at your route.
Adapters are optional peers
Each platform's wire-level adapter lives in its own @chat-adapter/* package, declared as an optional peer dependency. You install exactly the platforms you run:
pnpm add @chat-adapter/slack @chat-adapter/telegram
# discord, teams, gchat: only if you enable themThis keeps your dependency tree honest — a deployment that only does telegram never downloads slack's SDK — and it keeps versioning per-platform: a breaking change in one platform's API surfaces as one adapter bump, not a channel-package release. If you configure a platform whose adapter is missing, the runtime fails at boot with a clear error rather than at the first webhook.
One inbound pipeline
Every platform feeds the same four-stage pipeline. This is the central design decision of the domain — platforms differ at the edges, never in the middle:
webhook canonical record react reply
| | | |
POST /api/channels/slack -> channel_messages row -> your react(inbound) -> posted on the same
(verified, parsed by persisted on InstantDB handler runs against platform thread and
the delivery runtime) (direction: "inbound") the agent context persisted outboundStage two happens before your code runs: by the time react is invoked, the inbound message is already a persisted channel_messages row linked to its context, and every reactive timeline already shows it. Your handler receives a ChannelInbound — the canonical message, the resolved contextId, and a reply function. Return text for an auto-reply, or call inbound.reply yourself for multi-part responses and return null. Either way the reply is posted on the originating platform thread and persisted as an outbound record.
threadKey: one conversation, one key
Every inbound carries a threadKey: a stable identifier for the platform-side conversation — a slack thread, a telegram chat, a discord channel thread. The delivery runtime guarantees the same conversation always yields the same key, across messages, restarts and deploys.
That stability is what makes resolveContextId a pure mapping: given { channel, threadKey }, return the agent context this conversation belongs to. The idiomatic implementation keys an agent thread as `${channel}:${threadKey}` and returns its context id — getting you one durable, multichannel context per platform conversation with no lookup tables of your own.
Containment: delivery stays inside
Notice what your code never touched in any snippet above: websocket connections, webhook signature verification, platform SDK clients, retry logic, event deduplication. All of that is the delivery runtime — an implementation detail behind createChannels, loaded lazily and owned entirely by the package.
resolveContextId, react). If the delivery internals are ever replaced, none of your code changes — the schema, the webhooks and the two callbacks are the whole surface.