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) | |
|---|---|---|
| lives | inside one turn's evolving message | as its own transcript entry |
| updatable | yes — re-emit the same chunk id | no — it's a transcript entry |
| use for | progress bars, charts the node fills in, forms the run waits on | images, 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
- TypeScript
- .NET
// 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 });
// The low-level form: any renderer name + its payload.
Shuttle.Message(ctx, "image", new Dictionary<string, object?>
{
["src"] = receiptUrl,
["caption"] = "Your receipt",
});
// …with a stable message id
Shuttle.Message(ctx, "image", props, id: "receipt-ORD-42");
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
typemay 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 regulartextframe, so the text renderer's extras (likeurlsfor link previews) go indata. - 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, usemekik.chooseinstead.
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:
- TypeScript
- .NET
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" },
],
}),
],
});
var app = new MekikApp(new MekikOptions
{
Graph = g,
Greeting = conv => new object[]
{
$"Hi {conv.UserId}! What can I do for you?",
Messages.ButtonsSpec(
[
Messages.Button("Track an order", "/track"),
Messages.Button("Start a return", "/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.
Text (with link previews)
The plain text message — worth using explicitly when you want link previews under the bubble. previewVariant: compact (default) | expanded.
- TypeScript
- .NET
mekik.messages.text(ctx, {
text: "Here's the policy you asked about:",
urls: ["https://example.com/returns-policy"],
previewVariant: "expanded",
});
Messages.Text(ctx,
"Here's the policy you asked about:",
urls: ["https://example.com/returns-policy"],
previewVariant: "expanded");
Image
- TypeScript
- .NET
mekik.messages.image(ctx, {
src: "https://cdn.example/receipts/ORD-42.png",
alt: "Refund receipt for ORD-42",
caption: "Your receipt",
});
Messages.Image(ctx,
"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.
- TypeScript
- .NET
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" },
],
});
Messages.Card(ctx,
title: "ORD-42",
subtitle: "2 items · $249.90 · delivered",
image: "https://cdn.example/orders/ORD-42.png",
buttons: [Messages.Button("Track", "/track ORD-42"), Messages.Button("Return", "/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.
- TypeScript
- .NET
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" }],
});
Messages.Buttons(ctx,
buttons:
[
Messages.Button("Track an order", "/track"),
Messages.Button("Start a return", "/return"),
Messages.Button("Something else"), // no value → the label is sent
],
text: "What would you like to do?");
Messages.Buttons(ctx,
buttons: [Messages.Button("Standard", "std"), Messages.Button("Express", "exp")],
text: "Delivery speed",
persistent: true); // stays re-selectable
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.
- TypeScript
- .NET
mekik.messages.quickReply(ctx, {
text: "Did that solve it?",
actions: [{ label: "Yes, thanks" }, { label: "No", value: "/agent" }],
keepActions: true,
});
Messages.QuickReply(ctx,
"Did that solve it?",
actions: [Messages.Button("Yes, thanks"), Messages.Button("No", "/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.
- TypeScript
- .NET
mekik.messages.file(ctx, {
url: "https://cdn.example/invoices/ORD-42.pdf",
name: "invoice-ORD-42.pdf",
size: 248_320,
mimeType: "application/pdf",
});
Messages.File(ctx,
"https://cdn.example/invoices/ORD-42.pdf",
"invoice-ORD-42.pdf",
size: 248_320,
mimeType: "application/pdf");
Video
- TypeScript
- .NET
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",
});
Messages.Video(ctx,
"https://cdn.example/howto/return-packing.mp4",
poster: "https://cdn.example/howto/return-packing.jpg",
caption: "How to pack your return");
Carousel
A horizontally scrollable row of cards — the natural shape for search results or a product list.
- TypeScript
- .NET
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}` }],
})),
});
Messages.Carousel(ctx, products
.Select(p => (object)Messages.CarouselCard(
p.Name,
subtitle: $"${p.Price}",
image: p.ImageUrl,
buttons: [Messages.Button("Add to cart", $"/add {p.Sku}")]))
.ToList());
A message-driven turn, end to end
- TypeScript
- .NET
.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}.` };
})
.Node("order-status", async (State s, IContext ctx) =>
{
var id = s.Get<string>("input");
var order = await Shuttle.Tool(ctx, "get_order", new Dictionary<string, object?> { ["id"] = id }, () => Orders.Get(id));
Messages.Card(ctx,
title: order.Id,
subtitle: $"{order.Items.Count} items · ${order.Total}",
buttons: [Messages.Button("Track", $"/track {order.Id}")],
id: $"card-{order.Id}");
if (order.InvoiceUrl is not null)
{
Messages.File(ctx, order.InvoiceUrl, $"invoice-{order.Id}.pdf", mimeType: "application/pdf");
}
return Update.Of("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
- Typed components — the other rendering path, for UI that evolves with the turn.
- Human-in-the-loop — buttons that pause the run and resume with the pick.
- Protocol → Frames — where rich message frames sit among the rest.