Skip to main content

Rich messages

A chativa conversation is rendered out of messages, each dispatched to a renderer by its type: "image", "card", "buttons", "carousel", and so on (chativa's MessageTypeRegistry). mekik carries one as a rich message frame — the text frame's envelope under the renderer's name, with the renderer's payload as data:

{ "type": "image", "id": "msg-7", "seq": 12, "from": "bot",
"data": { "src": "https://…/receipt.png", "caption": "Your receipt" },
"timestamp": 1750000000000 }

These frames are persistent: same seq, transcript, replay, and watermark rules as text (PROTOCOL.md §4.5). They are the one open extension to the persistent-frame list — a client with no renderer for the type simply ignores the frame.

Messages or components?

Both render UI; they differ in what they are.

Components (genui)Messages (this page)
livesinside one turn's evolving messageas its own transcript entry
updatableyes — re-emit the same chunk idno — it's a transcript entry
use forprogress bars, charts the node fills in, forms the run waits onimages, cards, carousels, file attachments

Rule of thumb: a component when the thing evolves with the turn, a message when it stands on its own in the conversation.

Emitting one

// The low-level form: any renderer name + its payload.
mekik.message(ctx, "image", { src: receiptUrl, caption: "Your receipt" });
mekik.message(ctx, "image", { src: receiptUrl }, { id: "receipt-ORD-42" }); // a stable message id

// Bind a custom type once, so its data is compiler-checked at every call site.
const receipt = mekik.messageKind<{ orderId: string; totalCents: number }>("receipt");
receipt(ctx, { orderId: "ORD-42", totalCents: 24990 });

Omit the id and mekik mints one; supply it when you want messages deterministically addressable (keyed by an order id, say).

Two rules to know before reaching for a message:

  • Reserved types are refused. The type may not be one of the protocol's own frame types (genui, interrupt, run, welcome, typing, …) — the helper throws. "text" is the deliberate exception: it emits a regular text frame, so the text renderer's extras (like urls for link previews) go in data.
  • Interaction comes back as input, not as a special frame. A tapped button, chip, or card action arrives as the next user turn — the action's value, or its label when there is none. To pause the run on the buttons and resume with the pick, use mekik.choose instead.

Describing one without a node: the greeting

Emitting needs a node's ctx. Some places that send a message have none — the greeting is the standing example: it fires on connect, before any run. So a message can also be described as a value and handed to mekik, which turns it into the frame:

const app = mekik({
graph: g,
greeting: (conv) => [
`Hi ${conv.userId}! What can I do for you?`,
mekik.messages.buttons.spec({
buttons: [
{ label: "Track an order", value: "/track" },
{ label: "Start a return", value: "/return" },
],
}),
],
});

Every message type has this second form: messages.card.spec(data, opts?) in TypeScript, Messages.CardSpec(…) in .NET — same parameters as the emitter, minus the ctx, returning { type, data, id? }. For a custom type, mekik.messageSpec(type, data) / Messages.Spec(type, data), or messageKind("receipt").spec({…}).

The greeting takes a bare string (one text frame — the original form), one spec, or a list mixing both, delivered in order. Each item lands as its own persistent frame, so all of them replay on reconnect and the conversation is never greeted twice.

:::note Why not a GenUI component here? A GenUI chunk belongs to a turn's stream, and the greeting fires outside any run — there's no stream to attach it to. Send the same thing as a message instead; that's exactly the distinction in the table above. :::

chativa's built-in message types

mekik.messages.* / Messages.* bind the types chativa renders out of the box.

The plain text message — worth using explicitly when you want link previews under the bubble. previewVariant: compact (default) | expanded.

mekik.messages.text(ctx, {
text: "Here's the policy you asked about:",
urls: ["https://example.com/returns-policy"],
previewVariant: "expanded",
});

Image

mekik.messages.image(ctx, {
src: "https://cdn.example/receipts/ORD-42.png",
alt: "Refund receipt for ORD-42",
caption: "Your receipt",
});

Card

A hero card with an optional image and action buttons. A tapped button sends its value as the next user turn.

mekik.messages.card(ctx, {
title: "ORD-42",
subtitle: "2 items · $249.90 · delivered",
image: "https://cdn.example/orders/ORD-42.png",
buttons: [
{ label: "Track", value: "/track ORD-42" },
{ label: "Return", value: "/return ORD-42" },
],
});

Buttons

A vertical list of full-width buttons under a text bubble. By default the list collapses to the choice once tapped; persistent keeps them tappable so the user can change their mind.

mekik.messages.buttons(ctx, {
text: "What would you like to do?",
buttons: [
{ label: "Track an order", value: "/track" },
{ label: "Start a return", value: "/return" },
{ label: "Something else" }, // no value → the label is sent
],
});

mekik.messages.buttons(ctx, {
text: "Delivery speed",
persistent: true, // stays re-selectable
buttons: [{ label: "Standard", value: "std" }, { label: "Express", value: "exp" }],
});

Quick reply

A text bubble with chips. One-time by default; keepActions leaves them rendered so the transcript still reads as a record of what was asked and answered.

mekik.messages.quickReply(ctx, {
text: "Did that solve it?",
actions: [{ label: "Yes, thanks" }, { label: "No", value: "/agent" }],
keepActions: true,
});

File

A downloadable attachment card. size is in bytes; the renderer formats it and picks an icon from the name and MIME type.

mekik.messages.file(ctx, {
url: "https://cdn.example/invoices/ORD-42.pdf",
name: "invoice-ORD-42.pdf",
size: 248_320,
mimeType: "application/pdf",
});

Video

mekik.messages.video(ctx, {
src: "https://cdn.example/howto/return-packing.mp4",
poster: "https://cdn.example/howto/return-packing.jpg",
caption: "How to pack your return",
});

A horizontally scrollable row of cards — the natural shape for search results or a product list.

mekik.messages.carousel(ctx, {
cards: products.map((p) => ({
title: p.name,
subtitle: `$${p.price}`,
image: p.imageUrl,
buttons: [{ label: "Add to cart", value: `/add ${p.sku}` }],
})),
});

A message-driven turn, end to end

.node("order-status", async (s, ctx) => {
const order = await mekik.tool(ctx, "get_order", { id: s.input }, () => Orders.get(s.input));

mekik.messages.card(ctx, {
title: order.id,
subtitle: `${order.items.length} items · $${order.total}`,
buttons: [{ label: "Track", value: `/track ${order.id}` }],
}, { id: `card-${order.id}` });

if (order.invoiceUrl) {
mekik.messages.file(ctx, { url: order.invoiceUrl, name: `invoice-${order.id}.pdf`, mimeType: "application/pdf" });
}

return { reply: `Here's ${order.id}.` };
})

Each message lands as its own persistent frame, in emit order, before the turn's consolidated reply — and all of them replay on reconnect.

Where to go next