channel docs

channel / concepts

Platforms

A platform is a place where conversations happen — slack, teams, telegram, discord, google chat, or anything you name yourself. The channel domain gives every one of them the same contract: a config entry, a webhook, and one unified inbound pipeline that ends in 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.

@ekairos/channel — ChannelKind
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.

lib/channels.ts
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:

terminal
pnpm add @chat-adapter/slack @chat-adapter/telegram
# discord, teams, gchat: only if you enable them

This 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:

the unified inbound pipeline
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 outbound

Stage 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.

The contract is deliberately small: your application knows platforms (config keys), canonical messages (the entity), and two functions (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.

Next

database…