channel / reference
Adapters & registry
ChannelAdapter is the provider plugin contract for custom channels: implementations (Resend email, Twilio whatsapp, push, ...) live in apps or provider packages and register by kind on a ChannelRegistry. The built-in platforms do not need adapters — the delivery runtime handles them end to end.ChannelAdapter
export interface ChannelAdapter {
readonly kind: ChannelKind;
send(message: ChannelOutboundMessage): Promise<ChannelSendResult>;
}| prop | type | description |
|---|---|---|
| kind * | ChannelKind (readonly) | The channel this adapter delivers for. One adapter per kind — registering a second adapter with the same kind replaces the first. |
| send * | (message: ChannelOutboundMessage) => Promise<ChannelSendResult> | Delivers one outbound message through the provider and reports the result. Throw on hard failures; return status: "failed" for provider-reported failures you want recorded. |
ChannelOutboundMessage
export type ChannelOutboundMessage = Omit<ChannelMessage, "id" | "createdAt" | "direction"> & {
direction?: "outbound";
};Everything a ChannelMessage has, minus the fields the send path owns:
| prop | type | description |
|---|---|---|
| id, createdAt | omitted | Assigned at persistence time (e.g. by createChannelMessage), not by the caller of send. |
| direction | "outbound" — optional | Narrowed to "outbound" and optional: an outbound message cannot be anything else, so you may omit it. |
| channel, text, parts, participant, ... | inherited from ChannelMessage | All remaining fields carry over unchanged. channel picks the adapter; participant is the recipient (phone, email address, user id). |
ChannelSendResult
export type ChannelSendResult = {
externalId?: string;
status: ChannelMessageStatus;
raw?: unknown;
};| prop | type | description |
|---|---|---|
| externalId | string | The provider's id for the sent message (Twilio SID, Resend id). Persist it on the message so later status webhooks can be correlated. |
| status * | ChannelMessageStatus | Status reported by the provider — typically "sent" on success or "failed" on a provider-side rejection. |
| raw | unknown | The provider's raw response, kept for audit and debugging. |
ChannelRegistry
export class ChannelRegistry {
register(adapter: ChannelAdapter): this;
get(kind: ChannelKind): ChannelAdapter | null;
kinds(): ChannelKind[];
send(message: ChannelOutboundMessage): Promise<ChannelSendResult>;
}| prop | type | description |
|---|---|---|
| register | (adapter: ChannelAdapter) => this | Registers an adapter under its kind. Returns this, so calls chain. |
| get | (kind: ChannelKind) => ChannelAdapter | null | Looks an adapter up by kind; null when none is registered. |
| kinds | () => ChannelKind[] | Every kind with a registered adapter. |
| send | (message: ChannelOutboundMessage) => Promise<ChannelSendResult> | Routes the message to the adapter matching message.channel and delegates to its send. |
registry.send throws Error("channel_adapter_not_registered:<kind>") when no adapter is registered for message.channel. Register every kind you send on at boot, before the first send.When to write an adapter — and when not to
| prop | type | description |
|---|---|---|
| Write an adapter | custom channels | Channels you deliver yourself: your own transactional email (Resend, SES), SMS or whatsapp through Twilio, mobile push, an in-house gateway. The adapter is the only provider-specific code; everything else (persistence, timeline, agent reaction) stays canonical. |
| Do not write an adapter | built-in platforms | Slack, Teams, Google Chat, Discord and Telegram are delivered by the runtime itself: configure them in createChannels platforms and reply via react / inbound.reply. No registry involved. |
Complete example: an email adapter
import { Resend } from "resend";
import {
EMAIL_CHANNEL,
type ChannelAdapter,
type ChannelOutboundMessage,
type ChannelSendResult,
} from "@ekairos/channel";
const resend = new Resend(process.env.RESEND_API_KEY!);
export const emailAdapter: ChannelAdapter = {
kind: EMAIL_CHANNEL,
async send(message: ChannelOutboundMessage): Promise<ChannelSendResult> {
if (!message.participant) {
throw new Error("email_adapter: message.participant (recipient) is required");
}
const { data, error } = await resend.emails.send({
from: "Ekairos <agent@updates.ekairos.dev>",
to: [message.participant],
subject: "Re: your conversation",
text: message.text ?? "",
});
if (error) {
return { status: "failed", raw: error };
}
return { externalId: data?.id, status: "sent", raw: data };
},
};import { ChannelRegistry, createChannelMessage, EMAIL_CHANNEL } from "@ekairos/channel";
import { emailAdapter } from "./email-adapter";
import { channels } from "@/lib/channels"; // ChannelsRuntime from createChannels
export const registry = new ChannelRegistry().register(emailAdapter);
/** Send an email from the product and persist the canonical record. */
export async function sendEmail(params: { to: string; text: string; contextId: string }) {
const outbound = {
channel: EMAIL_CHANNEL,
role: "assistant" as const,
text: params.text,
participant: params.to,
contextId: params.contextId,
};
const result = await registry.send(outbound);
return await channels.store.saveChannelMessage(
createChannelMessage({
...outbound,
direction: "outbound",
status: result.status,
externalId: result.externalId,
raw: result.raw,
}),
);
}The pattern is always the same: registry.send delivers, createChannelMessage fills id/createdAt, and store.saveChannelMessage persists the record — which makes it appear reactively in every timeline bound to that context.