channel docs

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

@ekairos/channel
export interface ChannelAdapter {
  readonly kind: ChannelKind;
  send(message: ChannelOutboundMessage): Promise<ChannelSendResult>;
}
proptypedescription
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

type
export type ChannelOutboundMessage = Omit<ChannelMessage, "id" | "createdAt" | "direction"> & {
  direction?: "outbound";
};

Everything a ChannelMessage has, minus the fields the send path owns:

proptypedescription
id, createdAtomittedAssigned at persistence time (e.g. by createChannelMessage), not by the caller of send.
direction"outbound" — optionalNarrowed to "outbound" and optional: an outbound message cannot be anything else, so you may omit it.
channel, text, parts, participant, ...inherited from ChannelMessageAll remaining fields carry over unchanged. channel picks the adapter; participant is the recipient (phone, email address, user id).

ChannelSendResult

type
export type ChannelSendResult = {
  externalId?: string;
  status: ChannelMessageStatus;
  raw?: unknown;
};
proptypedescription
externalIdstringThe provider's id for the sent message (Twilio SID, Resend id). Persist it on the message so later status webhooks can be correlated.
status *ChannelMessageStatusStatus reported by the provider — typically "sent" on success or "failed" on a provider-side rejection.
rawunknownThe provider's raw response, kept for audit and debugging.

ChannelRegistry

class
export class ChannelRegistry {
  register(adapter: ChannelAdapter): this;
  get(kind: ChannelKind): ChannelAdapter | null;
  kinds(): ChannelKind[];
  send(message: ChannelOutboundMessage): Promise<ChannelSendResult>;
}
proptypedescription
register(adapter: ChannelAdapter) => thisRegisters an adapter under its kind. Returns this, so calls chain.
get(kind: ChannelKind) => ChannelAdapter | nullLooks 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

proptypedescription
Write an adaptercustom channelsChannels 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 adapterbuilt-in platformsSlack, 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

lib/channels/email-adapter.ts
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 };
  },
};
lib/channels/registry.ts
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.

Next

database…