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