channel / concepts
Messages
channel_messages. One canonical shape, indexed and queryable, with the provider payload preserved verbatim alongside it.One entity for every channel
The channel domain refuses per-provider message tables. A Twilio webhook body and a Resend delivery event describe the same domain fact — a message crossed a channel — so they normalize into the same record. Provider-specific records (a Resend email row, a Twilio message row) stay in app or provider domains and link back via externalId and raw.
export type ChannelMessage = {
id: string;
channel: ChannelKind; // "web" | "email" | "whatsapp" | (string & {})
direction: ChannelDirection; // "inbound" | "outbound"
role?: "user" | "assistant" | "system";
text?: string;
parts?: unknown[];
status?: ChannelMessageStatus; // "pending" | "sending" | "sent" | "delivered" | "read" | "failed" | ...
externalId?: string;
participant?: string;
raw?: unknown;
createdAt: string;
updatedAt?: string;
// Links into the agent context, when attached.
contextId?: string;
itemId?: string;
};The type mirrors the channel_messages entity one-to-one. What you read with an InstantDB query is what the runtime persisted — there is no translation layer between storage and your UI.
Field reference
| prop | type | description |
|---|---|---|
| channel * | ChannelKind | Which channel the message crossed. Open union: web, email, whatsapp get literal types; any string is a valid custom kind. Indexed. |
| direction * | "inbound" | "outbound" | Inbound entered your system from the outside world; outbound left it. Indexed — this is how a timeline decides which side a bubble renders on. |
| role | "user" | "assistant" | "system" | Conversational role, when the message participates in an agent conversation. Inbound is usually user; replies the agent posts are assistant. |
| text | string | Plain-text body. The lowest common denominator every platform supports. |
| parts | unknown[] | Structured content (rich parts, attachments, tool output) for channels that carry more than text. Stored as JSON. |
| status | ChannelMessageStatus | Delivery lifecycle, mostly meaningful outbound: pending → sending → sent → delivered → read, or failed. Open union — providers with extra states fit. Indexed. |
| externalId | string | The provider's id for this message (a Meta wamid, a Resend email id, a slack ts). Indexed — the join key for delivery receipts and provider-side records. |
| participant | string | Resolved identity of the counterpart: a phone number, an email address, a platform user id. Indexed. |
| raw | unknown | The provider payload, verbatim. Never normalized, never required for rendering. |
| createdAt * | date | When the message was persisted. Indexed — timelines order by it. |
| updatedAt | date | Last mutation, typically a status transition from a delivery receipt. |
| context | link → event_contexts | contextId on the type. Attaches the message to the agent context that owns the conversation. One context has many channel messages. |
| item | link → event_items | itemId on the type. Optionally pairs the message with the agent item it produced or mirrors (the trigger item for an inbound, the reaction item for a reply). |
Inbound and outbound
The two directions have different lifecycles. An inbound message is born complete: the delivery runtime receives a webhook, persists the canonical record, and only then hands it to your react handler — by the time your code runs, the message is already on InstantDB and already visible in every subscribed timeline.
An outbound message is born pending and earns its way through the status union: the adapter send yields an externalId and a first status, and later delivery receipts (matched by that externalId) advance it to delivered or read. Because the UI reads the entity reactively, every transition repaints without any polling.
Links into the context
Two links wire channel messages into the agent's world, both defined in the channel domain schema:
channel_messagesContext: {
forward: { on: "channel_messages", has: "one", label: "context" },
reverse: { on: "event_contexts", has: "many", label: "channelMessages" },
},
channel_messagesItem: {
forward: { on: "channel_messages", has: "one", label: "item" },
reverse: { on: "event_items", has: "many", label: "channelMessages" },
},The context link is the load-bearing one: it is what makes { channel_messages: { $: { where: { "context.id": contextId } } } } return the whole multichannel conversation. The item link is finer-grained — it ties a specific message to the specific event_items row it corresponds to, so an agent reaction and the whatsapp text that delivered it can be navigated in both directions.
Raw payloads vs the canonical model
The split is deliberate and strict. The canonical fields carry everything the rest of the system is allowed to depend on: rendering, ordering, filtering, agent reactions, status logic. The raw payload carries everything else — provider metadata, signature material, fields you have not modeled yet — untouched, for audit and for the day you need one more field.
raw. Never make UI code reach into raw — that is how provider coupling leaks back in.Anatomy of an inbound record
The createChannelMessage helper fills in id and createdAt so call sites stay declarative:
import { createChannelMessage, WHATSAPP_CHANNEL } from "@ekairos/channel";
const message = createChannelMessage({
channel: WHATSAPP_CHANNEL,
direction: "inbound",
role: "user",
text: payload.text.body,
externalId: payload.id,
participant: payload.from,
raw: payload,
contextId,
});
await store.saveChannelMessage(message);This is what an inbound whatsapp message looks like at rest:
{
"id": "8b1f6f0a-3d4c-4f2e-9a71-5c0d2e9b6f10",
"channel": "whatsapp",
"direction": "inbound",
"role": "user",
"text": "Do you have SKU 4411 in stock?",
"externalId": "wamid.HBgNNTQ5MTE1NTU1MDEyMxUCABIYFjNFQjBEMUE4QkY3RkY1RDhEN0M2AA==",
"participant": "+5491155550123",
"raw": {
"from": "5491155550123",
"id": "wamid.HBgNNTQ5MTE1NTU1MDEyMxUCABIYFjNFQjBEMUE4QkY3RkY1RDhEN0M2AA==",
"timestamp": "1781445801",
"type": "text",
"text": { "body": "Do you have SKU 4411 in stock?" }
},
"createdAt": "2026-06-10T14:03:21.000Z",
"contextId": "c4a9e2d1-7b30-4e8f-b2c5-1f6d8a0e3b42"
}Note what the record does not contain: no whatsapp-specific columns, no provider-shaped nesting outside raw. Swap whatsapp for telegram and only channel, externalId and raw change shape — everything downstream keeps working.