Skip to documentation
Documentation

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:

ChannelRenders as
SlackBlock Kit
Microsoft TeamsAdaptive Card
WhatsApp (Meta or Kapso)Reply buttons, a list, a URL button, or text
TelegramText 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 one root.
  • 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.
JSON
{
  "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:

text
[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

WhatsApp has fixed interactive message types, so Keiki chooses one based on the card's actions:

Card actionsWhatsApp messageLabel limit
1–3 ButtonsReply buttons20 characters
4–10 ButtonsList behind a Choose button24 characters
Exactly one LinkButton and no ButtonsURL button20 characters
Anything else (none, more than 10, or mixed)Plain textNo

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. Chart is 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.