channel docs

channel / guides / components

UI components.

Four components, one canonical model. Because every channel persists the same 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

terminal
pnpm dlx shadcn@latest add https://registry.ekairos.dev/r/channel-timeline.json

The whole conversation across every channel, interleaved chronologically with day separators. Channels mix freely in one stream because they share one canonical schema.

proptypedescription
dbInstantDB react client | nullWhen provided with contextId, the timeline queries channel_messages reactively via db.useQuery. This is the plug & play path.
contextIdstring | nullAgent context to read the conversation from.
messagesChannelMessage[]Static rows (demos, tests, server snapshots). Used when db is absent.
channelsstring[]Restrict to specific channels (default: all).
classNamestringExtra classes on the root.
emptyStateReactNodeRendered when there are no messages.
app/threads/[key]/conversation.tsx
"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} />;
}
Pass a stable 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

terminal
pnpm dlx shadcn@latest add https://registry.ekairos.dev/r/channel-message.json

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

proptypedescription
message *ChannelMessageThe canonical record. Text comes from message.text or, failing that, the text parts in message.parts.
classNamestringExtra classes on the root.
hideChannelbooleanHide the channel badge (e.g. in single-channel views).
usage
<ChannelMessageBubble message={message} hideChannel />

ChannelBadge

terminal
pnpm dlx shadcn@latest add https://registry.ekairos.dev/r/channel-badge.json

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

proptypedescription
channel *stringChannel kind: web, email, whatsapp, slack, teams, discord, telegram, ...
classNamestringExtra classes on the chip.
usage
<ChannelBadge channel="whatsapp" />

ChannelComposer

terminal
pnpm dlx shadcn@latest add https://registry.ekairos.dev/r/channel-composer.json

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

proptypedescription
endpoint *stringYour send endpoint — the one piece of custom code an app owns for outbound.
contextIdstringAgent context the message belongs to; forwarded in the POST body.
threadKeystringPlatform conversation key; forwarded in the POST body.
channelsstring[]Channels the user can send through. Defaults to ["web"]; with more than one, a picker appears.
placeholderstringTextarea placeholder. Defaults to "Send a message".
classNamestringExtra classes on the form.
onSent({ channel, text }) => voidCalled after a successful send.
onError(error: Error) => voidCalled 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:

app/inbox/[key]/inbox.tsx
"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.

Next

database…