channel / guides / components
UI components.
channel_messages shape on InstantDB, the components render whatsapp, slack and web identically — and they read reactively, so there is no fetch layer to build. Install with shadcn, hand them db and contextId, done.The plug & play contract
Three of the four components are pure render: they take data and draw it. ChannelTimeline goes one step further — give it an InstantDB react client and a contextId and it subscribes to channel_messages itself. A telegram reply persisted by the webhook appears in the browser the same instant, with no API route, no polling, no state management in between. The only component that touches code you own is ChannelComposer, because sending is an action your app must authorize.
ChannelTimeline
pnpm dlx shadcn@latest add https://registry.ekairos.dev/r/channel-timeline.jsonThe whole conversation across every channel, interleaved chronologically with day separators. Channels mix freely in one stream because they share one canonical schema.
| prop | type | description |
|---|---|---|
| db | InstantDB react client | null | When provided with contextId, the timeline queries channel_messages reactively via db.useQuery. This is the plug & play path. |
| contextId | string | null | Agent context to read the conversation from. |
| messages | ChannelMessage[] | Static rows (demos, tests, server snapshots). Used when db is absent. |
| channels | string[] | Restrict to specific channels (default: all). |
| className | string | Extra classes on the root. |
| emptyState | ReactNode | Rendered when there are no messages. |
"use client";
import { db } from "@/lib/db.client"; // InstantDB react client
import { ChannelTimeline } from "@/components/ekairos/channel/channel-timeline";
export function Conversation({ contextId }: { contextId: string }) {
return <ChannelTimeline db={db} contextId={contextId} />;
}db reference — the module-level client from init(), not one created during render. The timeline calls db.useQuery as a hook, so the client must be the same object on every render of a mounted timeline.ChannelMessageBubble
pnpm dlx shadcn@latest add https://registry.ekairos.dev/r/channel-message.jsonOne bubble for any canonical message: direction decides the side, role decides the tone, the badge tells the platform. It renders the canonical model — never provider payloads — which is why it works for every channel, including custom ones.
| prop | type | description |
|---|---|---|
| message * | ChannelMessage | The canonical record. Text comes from message.text or, failing that, the text parts in message.parts. |
| className | string | Extra classes on the root. |
| hideChannel | boolean | Hide the channel badge (e.g. in single-channel views). |
<ChannelMessageBubble message={message} hideChannel />ChannelBadge
pnpm dlx shadcn@latest add https://registry.ekairos.dev/r/channel-badge.jsonThe platform identity chip: a colored dot and the channel name. Known channels (web, email, whatsapp, slack, teams, gchat, discord, telegram) get their brand accent; unknown channels render with a neutral accent, so custom channels work out of the box.
| prop | type | description |
|---|---|---|
| channel * | string | Channel kind: web, email, whatsapp, slack, teams, discord, telegram, ... |
| className | string | Extra classes on the chip. |
<ChannelBadge channel="whatsapp" />ChannelComposer
pnpm dlx shadcn@latest add https://registry.ekairos.dev/r/channel-composer.jsonPick the channel, write, send. This is the only component that calls code you own: it POSTs { channel, text, contextId?, threadKey? } as JSON to your send endpoint (see Send messages) and handles the channel picker, busy state, Enter-to-send and error reporting itself. It never writes to InstantDB directly — your endpoint delivers and persists server-side, and the timeline updates reactively.
| prop | type | description |
|---|---|---|
| endpoint * | string | Your send endpoint — the one piece of custom code an app owns for outbound. |
| contextId | string | Agent context the message belongs to; forwarded in the POST body. |
| threadKey | string | Platform conversation key; forwarded in the POST body. |
| channels | string[] | Channels the user can send through. Defaults to ["web"]; with more than one, a picker appears. |
| placeholder | string | Textarea placeholder. Defaults to "Send a message". |
| className | string | Extra classes on the form. |
| onSent | ({ channel, text }) => void | Called after a successful send. |
| onError | (error: Error) => void | Called when the endpoint responds non-2xx or the request fails. |
Compose them: a full inbox
Timeline plus composer is a complete multichannel inbox — reads are reactive, writes go through your endpoint, and the loop closes itself when the webhook persists the reply:
"use client";
import { db } from "@/lib/db.client";
import { ChannelTimeline } from "@/components/ekairos/channel/channel-timeline";
import { ChannelComposer } from "@/components/ekairos/channel/channel-composer";
export function Inbox({ contextId }: { contextId: string }) {
return (
<div className="grid gap-4">
<ChannelTimeline db={db} contextId={contextId} />
<ChannelComposer
endpoint="/api/channels/send"
contextId={contextId}
channels={["web", "whatsapp", "email"]}
/>
</div>
);
}That is the entire client. Everything else — delivery, persistence, agent reactions — happens in the channel domain on the server.