channel docs

channel / guides / send

Send messages.

Outbound is deliberately explicit: your app owns one endpoint that decides who can send what, through which channel. Everything after that decision — delivery, persistence, linking — is the domain's job.

Why an endpoint at all

Reads never need APIs here: timelines are reactive InstantDB queries. Writes are different — sending a message is an action with authorization, rate limits and business rules only your app knows. So the contract is: one POST endpoint, owned by you, as small as it can be.

The endpoint

app/api/channels/send/route.ts
import { createChannelMessage, ChannelRegistry } from "@ekairos/channel";
import { channels } from "@/lib/channels";
import { db } from "@/lib/db";

export async function POST(request: Request) {
  const { channel, text, contextId, threadKey } = await request.json();

  // 1. Your rules: who can send, to which thread, on which channel.
  await assertCanSend({ request, contextId, channel });

  // 2. Web channel: persist the canonical message; the timeline updates
  //    reactively, and the agent can react to it like any other input.
  if (channel === "web") {
    await channels.store.saveChannelMessage(
      createChannelMessage({
        channel,
        direction: "outbound",
        role: "user",
        text,
        contextId,
      }),
    );
    return Response.json({ ok: true });
  }

  // 3. Platform channels: deliver through the registered adapter, then
  //    persist the canonical outbound record on the same context.
  const result = await registry.send({ channel, text, contextId, participant: threadKey });
  await channels.store.saveChannelMessage(
    createChannelMessage({
      channel,
      direction: "outbound",
      role: "assistant",
      text,
      status: result.status,
      externalId: result.externalId,
      contextId,
    }),
  );
  return Response.json({ ok: true });
}
Replies inside an inbound flow are even simpler: the react callback of createChannels receives inbound.reply(text), which posts on the same platform conversation and persists the outbound record in one call. The endpoint above is for product-initiated sends.

Custom delivery: the adapter contract

Email through your provider, SMS, push — anything can be a channel. Implement ChannelAdapter and register it; the canonical model stays identical, so timelines and components need zero changes:

lib/channels/email-adapter.ts
import { ChannelRegistry, type ChannelAdapter } from "@ekairos/channel";

const emailAdapter: ChannelAdapter = {
  kind: "email",
  async send(message) {
    const sent = await resend.emails.send({
      to: message.participant!,
      subject: "Re: your thread",
      text: message.text ?? "",
    });
    return { externalId: sent.data?.id, status: "sent" };
  },
};

export const registry = new ChannelRegistry().register(emailAdapter);

Wire the composer

ui
<ChannelComposer
  endpoint="/api/channels/send"
  contextId={contextId}
  channels={["web", "email", "whatsapp"]}
/>

ChannelComposer POSTs { channel, text, contextId, threadKey } to your endpoint and nothing else — the channel picker, busy state and keyboard handling come built in. Install it with shadcn add https://registry.ekairos.dev/r/channel-composer.json.

Next

database…