channel / reference
ChannelMessage
The canonical message crossing any channel. It mirrors the
channel_messages entity; provider payloads live in raw and provider ids in externalId. Every channel — web, email, whatsapp, slack, custom — flows through this one shape.Type
export type ChannelMessage = {
id: string;
channel: ChannelKind;
direction: ChannelDirection;
role?: "user" | "assistant" | "system";
text?: string;
parts?: unknown[];
status?: ChannelMessageStatus;
externalId?: string;
participant?: string;
raw?: unknown;
createdAt: string;
updatedAt?: string;
contextId?: string;
itemId?: string;
};| prop | type | description |
|---|---|---|
| id * | string | Stable message id (UUID in InstantDB deployments). |
| channel * | ChannelKind | Which channel the message belongs to: "web", "email", "whatsapp", or any custom kind. |
| direction * | ChannelDirection | "inbound" (from the counterpart to your system) or "outbound" (from your system out). |
| role | "user" | "assistant" | "system" | Conversational role, when the message participates in an agent thread. |
| text | string | Plain-text body. |
| parts | unknown[] | Structured parts (rich content, attachments, tool results). |
| status | ChannelMessageStatus | Delivery lifecycle status; see the union below. |
| externalId | string | Provider-side id (Twilio SID, email message-id, platform timestamp). |
| participant | string | Resolved identity of the counterpart (phone, email address, user id). |
| raw | unknown | Original provider payload, kept verbatim. |
| createdAt * | string | ISO timestamp; the timeline sort key. |
| updatedAt | string | ISO timestamp of the last update (e.g. a status transition). |
| contextId | string | Links into the agent context, when attached — mirrors the channel_messagesContext link. |
| itemId | string | Anchors the message to one context item, when attached — mirrors the channel_messagesItem link. Anchored messages render inside their event in the thread timeline. |
ChannelKind
export const WEB_CHANNEL = "web";
export const EMAIL_CHANNEL = "email";
export const WHATSAPP_CHANNEL = "whatsapp";
/** Open union: known channels get literal types, custom channels are allowed. */
export type ChannelKind = "web" | "email" | "whatsapp" | (string & {});The (string & {}) trick keeps the union open: the three known kinds autocomplete, and any other string (a custom "sms" or "push" channel) still typechecks. Prefer the exported constants over string literals when referring to the built-in kinds.
ChannelDirection
export type ChannelDirection = "inbound" | "outbound";| prop | type | description |
|---|---|---|
| "inbound" | ChannelDirection | Received from the counterpart (a webhook delivery, an incoming email). |
| "outbound" | ChannelDirection | Sent by your system (an agent reply, a broadcast). |
ChannelMessageStatus
export type ChannelMessageStatus =
| "pending"
| "sending"
| "sent"
| "delivered"
| "read"
| "failed"
| (string & {});| prop | type | description |
|---|---|---|
| "pending" | status | Created, not yet handed to a provider. |
| "sending" | status | In flight to the provider. |
| "sent" | status | Accepted by the provider. |
| "delivered" | status | Confirmed delivered to the counterpart. |
| "read" | status | Read receipt received. |
| "failed" | status | Delivery failed; details usually in raw. |
| (string & {}) | status | Open for provider-specific statuses that do not map to the six above. |
createChannelMessage
export function createChannelMessage(
input: Omit<ChannelMessage, "id" | "createdAt"> & { id?: string; createdAt?: string },
): ChannelMessageBuilds a complete ChannelMessage from a partial input, filling in the two defaults:
| prop | type | description |
|---|---|---|
| id | string | Defaults to crypto.randomUUID() (with a timestamp-random fallback when the Web Crypto API is unavailable). |
| createdAt | string | Defaults to new Date().toISOString(). |
import { createChannelMessage, WHATSAPP_CHANNEL } from "@ekairos/channel";
const message = createChannelMessage({
channel: WHATSAPP_CHANNEL,
direction: "inbound",
role: "user",
text: "Hola, ¿tienen stock del modelo XR-200?",
participant: "+5491155550123",
externalId: "SMa4c9f2e8d7b64f1aa0c3",
contextId: "ctx_01HZX4Q8",
});
// → { id: "9f4b...", createdAt: "2026-06-10T14:03:21.000Z", ...input }ChannelMessageStore
export interface ChannelMessageStore {
saveChannelMessage(message: ChannelMessage): Promise<ChannelMessage>;
getChannelMessages(params: { contextId: string }): Promise<ChannelMessage[]>;
}| prop | type | description |
|---|---|---|
| saveChannelMessage * | (message: ChannelMessage) => Promise<ChannelMessage> | Persists one canonical message (and its context/item links, when contextId/itemId are set). Returns the persisted record. |
| getChannelMessages * | (params: { contextId: string }) => Promise<ChannelMessage[]> | Reads every message attached to a context. |
Who implements it:
| prop | type | description |
|---|---|---|
| Instant-backed store | internal to the runtime | InstantDB deployments back the interface with the channel_messages entity. You get it as channels.store from createChannels — you never construct it yourself. |
| MemoryAgentStore | @ekairos/agent | In-memory implementation for local/embedded runtimes (Electron, CLIs, tests). It implements ChannelMessageStore alongside the context and thread stores, so the same agent code runs without InstantDB. |
Real-world records
{
id: "6c1f6a3e-2d44-4d8a-9f0b-7a3c5e91d2b8",
channel: "whatsapp",
direction: "inbound",
role: "user",
text: "Hola, ¿tienen stock del modelo XR-200?",
status: "delivered",
externalId: "SMa4c9f2e8d7b64f1aa0c3",
participant: "+5491155550123",
raw: { MessageSid: "SMa4c9f2e8d7b64f1aa0c3", From: "whatsapp:+5491155550123", Body: "..." },
createdAt: "2026-06-10T14:03:21.000Z",
contextId: "ctx_01HZX4Q8"
}{
id: "b8e2c7d1-90af-4a36-8c52-f41d0e6a9b77",
channel: "email",
direction: "outbound",
role: "assistant",
text: "Yes — the XR-200 is in stock. I attached the quote you asked for.",
status: "sent",
externalId: "re_8GqkPzVx3JmN2cTd",
participant: "purchasing@acme-industries.com",
raw: { id: "re_8GqkPzVx3JmN2cTd", to: ["purchasing@acme-industries.com"] },
createdAt: "2026-06-10T14:05:02.000Z",
updatedAt: "2026-06-10T14:05:04.000Z",
contextId: "ctx_01HZX4Q8",
itemId: "itm_01HZX4RM"
}Note
itemId on the outbound record: the reply is anchored to the agent item that produced it, so the timeline renders it inside that event instead of as a standalone entry.