Native cards
Have the agent send one card spec that renders as Block Kit on Slack, an Adaptive Card on Teams, an interactive message on WhatsApp and an inline keyboard on Telegram.
Native cards let the agent send structured content, such as a title, fields, a table and
buttons, without writing a separate payload for each channel. The agent calls send_card
with one json-render spec, and Keiki renders
it natively for the conversation's channel:
| Channel | Renders as |
|---|---|
| Slack | Block Kit |
| Microsoft Teams | Adaptive Card |
| WhatsApp (Meta or Kapso) | Reply buttons, a list, a URL button, or text |
| Telegram | Text with an inline keyboard |
send_card is a built-in tool, so it needs no setup. It is offered only in Slack, Teams,
WhatsApp and Telegram conversations. On other channels it isn't in the tool list, and a call
returns an unsupported-channel error so the agent answers with reply instead. It is
also blocked in steered and preview turns, which never deliver to the contact.
These cards are different from Rich cards, which render image carousels for iMessage and Telegram.
The tool
send_card takes two arguments:
spec: a json-render spec, a flat map of elements with oneroot.text(required): a plain-text version of the card. Slack and Teams use it for notifications and previews, and WhatsApp uses it when the card has no visible content.
{
"text": "Order #1042 is ready for pickup. Approve or reject it.",
"spec": {
"root": "card",
"elements": {
"card": { "type": "Card", "props": { "title": "Order #1042" }, "children": ["fields", "actions"] },
"fields": { "type": "Fields", "props": {}, "children": ["store", "total"] },
"store": { "type": "Field", "props": { "label": "Store", "value": "Market St" }, "children": [] },
"total": { "type": "Field", "props": { "label": "Total", "value": "$42.10" }, "children": [] },
"actions": { "type": "Actions", "props": {}, "children": ["approve", "reject"] },
"approve": { "type": "Button", "props": { "id": "approve", "label": "Approve", "style": "primary" }, "children": [] },
"reject": { "type": "Button", "props": { "id": "reject", "label": "Reject", "style": "danger" }, "children": [] }
}
}
}The components are json-render's standard chat components, without Chart: Card,
Section, Text, Fields, Field, Table, Divider, Image, Actions, Button and
LinkButton. The tool description the model sees is generated from this catalog, so the
props it lists always match what Keiki validates.
An invalid spec is not sent. The tool returns { success: false, error, issues }, with
one entry in issues per problem, so the agent can fix the spec and call it again.
Button presses
A Button's id is what comes back, not its label. When a contact presses Approve
on the card above, the agent's next turn starts with:
[Button pressed by Jane Doe; action=approve]The format is the same on every channel, so one prompt handles presses everywhere.
LinkButton opens its URL and doesn't report a press.
How each channel renders it
Slack
The card is converted to Block Kit and sent as a Slack Block Kit message. Button presses, outbound history and the one-press-per-button rule
work the same as for any other Slack card. The card title becomes a header, Fields
becomes a section with fields, and each Button id becomes the block's action_id.
Teams
The card is converted to an Adaptive Card and sent as a Teams card. Each Button becomes an Action.Submit, and a press reports the
button's id (data.actionId) instead of its title.
WhatsApp has fixed interactive message types, so Keiki chooses one based on the card's actions:
| Card actions | WhatsApp message | Label limit |
|---|---|---|
1–3 Buttons | Reply buttons | 20 characters |
4–10 Buttons | List behind a Choose button | 24 characters |
Exactly one LinkButton and no Buttons | URL button | 20 characters |
| Anything else (none, more than 10, or mixed) | Plain text | No |
The message body flattens the card: the title, subtitle, Text, each Field as
Label: value, and each Table as rows separated by |. Image and Divider are
dropped. An interactive body can be up to 1,024 characters, and a plain-text one up to
4,096.
Keiki never truncates a card. When the body or a label is over its limit, the tool returns
issues that name the limit, and the agent shortens the card and retries. If WhatsApp
refuses the send, for example outside the 24-hour customer service window, the error goes
back to the agent instead of being retried as text.
Conversation history and the agent's memory of the turn record the body the contact actually saw.
Telegram
The card's title, subtitle, text, fields and table rows become the message text, and each
Button and LinkButton gets its own row in an inline keyboard under it. A button's id
travels as Telegram callback data, which allows 64 bytes, so a longer id comes back in
issues. Keiki acknowledges every press at once, so the button stops spinning.
In a group, the agent can answer a press privately: reply with ephemeral: true sends a
message only the person who pressed sees. It needs a press in the current turn, and
otherwise the tool returns an error and sends nothing. When several members pressed in
one turn, reply_to_message_id names the press being answered. The group's history
keeps only a placeholder for the reply, so other members' turns never see its text.
Native card JSON
On Slack and Teams, when the catalog can't express a layout, the agent can send the
channel's own card JSON instead: send_card with native: true takes spec as a Block Kit
payload ({ "blocks": [...] }) on Slack, or an Adaptive Card (version 1.4) on Teams. The
format guide is the built-in native-cards skill, which the agent loads with read_skill
only when it needs it, so it adds nothing to an ordinary prompt. Buttons that don't open a
URL come back as presses, as with catalog cards. Other channels have no native format and
refuse native.
Reply guards
A card goes through the same reply guards as a plain answer. They read everything the card
shows: its text, title, subtitle, text blocks, fields, table rows, button labels, and each
link button's label and URL. With native: true they read the whole card JSON, since it's
sent as written. If a guard rejects any of it, nothing is sent and the agent is told why.
A button press reaches the agent as a note like [Button pressed by Ada; action=approve].
Brackets, semicolons and line breaks are removed from the presser's name and the action, so
a crafted display name can't make the note look like a second press.
Limits
- No charts.
Chartis not in the catalog. - No streaming. A card is sent once, complete.
- The dashboard conversation view shows the card's text, not the rendered card.