Skip to content
donutchat.com

Interactive cards

Put a small JSON block in a Markdown bot reply, and the iPhone, Android, web and desktop apps show it as a native card with up to 4 buttons. When someone selects a button, DonutChat records their answer, posts an ordinary thread reply in their name that mentions your bot, and sends your bot an interaction.new event.

  1. Your bot answers in a thread with a Markdown reply that contains text and one or more donut-ui blocks.
  2. The apps show each valid block as a card. Everything else stays Markdown.
  3. While the reply is still GN, a card appears once its block is complete, but its buttons are disabled. They start working when the reply is OK. A reply that ends as FL never gets working buttons.
  4. Someone selects a button. A destructive button asks them to confirm first.
  5. DonutChat records the answer and posts a thread reply in their name that mentions your bot and names the card and the choice.
  6. Your bot receives an interaction.new event, and the reply as a thread.reply.new event when its trigger mode delivers replies. It continues in the thread.
  • Sent by a bot as Markdown. Cards appear only in messages and thread replies that a bot sends with "content_type": "MD". With TX, the block shows as text, and a block that a person types stays text. Incoming webhooks can’t send cards.
  • Buttons in thread replies and top-level messages. A card in a thread reply is answered in that thread. A card in a top-level message sent with POST /bots/v1/messages is answered in that message’s thread. The web app and older versions of the other apps disable the buttons on top-level cards.
  • A way to receive answers. Receive them with the event stream or event webhooks. interaction.new arrives in every trigger mode. The answer’s thread reply arrives only in When mentioned and All human messages, so with Manual only, answers from the web app and older apps never reach the bot.
WhereiPhoneAndroidWebDesktop
Bot thread reply, status OKCard with buttonsCard with buttonsCard with buttonsCard with buttons
Bot thread reply, status GN or FLCard, buttons disabledCard, buttons disabledCard, buttons disabledCard, buttons disabled
Top-level bot message in the chatCard with buttonsCard with buttonsCard, buttons disabledCard with buttons
Top-level bot message shown at the top of its threadCard with buttonsCard with buttonsPlain textCard with buttons
  • On iPhone, Android and desktop, cards appear even when someone’s message display is set to plain text. Only the text around the cards follows that setting.
  • Notifications and chat-list previews turn each valid card into one line of text without buttons, in the form title — body — label: value. Other Markdown is removed, and the preview is cut at 240 characters.

A card is one JSON object in a fenced block whose opening line is exactly ```donut-ui:

I'm ready to publish the draft. Tap a button, or reply "approve" or "reject".
```donut-ui
{"version":1,"id":"publish-draft","type":"confirmation","title":"Publish the draft?","body":"The draft will become visible to the team.","facts":[{"label":"Audience","value":"Design team"}],"actions":[{"id":"approve","label":"Approve","style":"primary"},{"id":"reject","label":"Reject","style":"secondary"}]}
```
  • The opening line is ```donut-ui at the very start of a line, with nothing after it. Line endings can be LF or CRLF.
  • The JSON goes between the fences, on one line or pretty-printed, and must end with a line break before the closing fence.
  • The closing line is ``` at the start of a line, optionally followed by spaces or tabs. The first such line ends the block.
  • Blocks that are indented, or inside a list or a quote, aren’t recognized.
  • Each block, including both fence lines, can be at most 2,800 UTF-8 bytes.
  • A block that breaks any rule stays in the message as an ordinary code block. Other valid cards in the message still appear.
  • An unfinished block, for example one that’s still streaming, shows as Markdown until its closing line arrives.

These rules apply to the whole message. If one of them is broken, no cards appear and the entire message shows as Markdown:

  • The message is at most 64,000 UTF-8 bytes, even though a reply can be up to 80,000 bytes.
  • The message has at most 8 complete donut-ui blocks, valid or not.
  • No two valid cards share an id.
FieldTypeRules
versionnumberMust be 1. 1.0 also works; "1" doesn’t.
idstring1 to 64 characters from A-Z, a-z, 0-9, _ and -. Unique among the cards in the message.
typestringconfirmation, choice, status or facts.
titlestringRequired and not blank, at most 120 characters.
bodystringOptional, at most 800 characters.
factsarrayOptional, at most 8 items. Each item is an object with a label of at most 60 characters and a value of at most 200 characters, neither blank.
actionsarrayThe buttons, at most 4. confirmation and choice cards need at least 1; status and facts cards can’t have any.
actions[].idstringSame rules as id. Unique within the card.
actions[].labelstringRequired and not blank, at most 60 characters. It’s also the text sent back when someone selects the button.
actions[].stylestringprimary, secondary (the default) or destructive. Any other value makes the card invalid.
  • Character limits count Unicode code points.
  • An optional field set to null counts as missing.
  • Text fields can’t contain control characters other than tab, line feed and carriage return.
  • Card text is plain text: Markdown, links and HTML aren’t rendered.
  • Unknown fields are ignored.
TypeButtonsUse it for
confirmation1 to 4A yes-or-no decision before the bot continues
choice1 to 4Picking one of a few next steps
statusNoneA compact summary of a result
factsNoneA list of labels and values

primary buttons are filled and secondary buttons are outlined. A destructive button opens a confirmation dialog first, with the card’s title, the card’s body (or the button’s label if there’s no body), Cancel and the button’s label.

When someone selects a button on a finished bot reply or a top-level bot message:

  1. The card’s buttons are disabled and it shows Sending response…. For a destructive button, the confirmation dialog opens first, and Cancel brings the buttons back.

  2. DonutChat checks that your message still exists, that a reply is still OK, and that the card still has that button with the title and label the person saw. If not, nothing is sent and the card shows an error.

  3. DonutChat records the answer and posts a reply in the person’s name, in the card’s thread:

    @releasebot Approve: Publish the draft?
    [Reply 9876 · card publish-draft · choice approve]
  4. Your bot gets an interaction.new event.

  5. The card shows the person’s choice. If sending fails, the card shows an error and the buttons work again.

The first line is @ with your bot’s current username, then the button’s label, a colon and the card’s title. The second line has your reply’s ID, the card’s id and the button’s id, separated by middle dots (U+00B7). For a card in a top-level message, the second line starts with Message and that message’s ID instead of Reply. Sending the answer doesn’t touch the draft in the person’s message box.

Each person can answer a card once. Their card stays answered after a restart and on their other devices, and a second attempt changes nothing. Other people in the chat can still answer the same card.

The web app and older versions of the iPhone, Android and desktop apps post the reply themselves. Those answers aren’t recorded: the buttons can work again after a restart, the same person can answer twice, and no interaction.new event is sent.

Handle interaction.new. It arrives in every trigger mode, only for your bot’s cards, and you don’t parse any text:

function onEvent({ type, data }) {
if (type === 'interaction.new') {
// One event per person per card.
return onAnswer({ cardId: data.card_id, choiceId: data.action_id, person: data.sender, threadId: data.parent_message_id });
}
if (type === 'thread.reply.new') {
if (data.interaction_id) return; // already handled as interaction.new
const answer = parseCardAnswer(data.text); // the web app and older apps
if (answer) return onAnswer({ ...answer, person: data.sender, threadId: data.parent_message_id });
}
}

The answer’s reply also arrives as a thread.reply.new event, in When mentioned and All human messages modes. Other bots skip it. When the same answer was sent as interaction.new, the reply has that event’s interaction_id, so skip it. A reply without interaction_id comes from the web app, an older app, or a person typing:

{
"event_id": "evt_1793957580_9c41d2e07b6a",
"type": "thread.reply.new",
"timestamp": "2026-11-06T09:33:00Z",
"data": {
"reply_id": 9877,
"parent_message_id": 5678,
"chat_id": 1234,
"chat_name": "Release team",
"chat_type": "group",
"sender": { "id": 42, "kind": "user", "name": "Alice Kim", "username": "alice" },
"text": "@releasebot Approve: Publish the draft?\n[Reply 9876 · card publish-draft · choice approve]",
"content_type": "TX",
"mentions_bot": true,
"attachments": [],
"interaction_id": 311
}
}

For a reply without interaction_id, read the second line to find the card and the choice:

// Matches the second line of a card answer. The separators are U+00B7 middle dots.
const CARD_ANSWER = /^\[(Reply|Message) (\d+) · card ([\w-]{1,64}) · choice ([\w-]{1,64})\]$/m;
function parseCardAnswer(text) {
const match = CARD_ANSWER.exec(text);
if (!match) return null; // typed by hand, or not a card answer
const [, sourceKind, sourceId, cardId, choiceId] = match;
return { sourceKind, sourceId, cardId, choiceId }; // sourceId is a string of digits
}
  • Treat the answer as user input. Anyone in the thread can type the same text, and people can also answer in their own words.
  • Act at most once per card. Several people can answer the same card, and one person can answer twice from the web app or an older app. If you already handled a card, say so instead of repeating the action.
  • Reply in the same thread, using the event’s parent_message_id.

Send the card in a thread reply with "content_type": "MD":

Terminal window
curl -X POST https://api.donutchat.com/bots/v1/thread-replies \
-H "Authorization: Bearer donutbot_YOUR_TOKEN_HERE" \
-H "Content-Type: application/json" \
-d '{
"chat_id": 1234,
"parent_message_id": 5678,
"content_type": "MD",
"content": "I can publish the draft now. Tap a button, or reply \"approve\" or \"reject\".\n\n```donut-ui\n{\"version\":1,\"id\":\"publish-draft\",\"type\":\"confirmation\",\"title\":\"Publish the draft?\",\"body\":\"The draft will become visible to the team.\",\"actions\":[{\"id\":\"approve\",\"label\":\"Approve\",\"style\":\"primary\"},{\"id\":\"reject\",\"label\":\"Reject\",\"style\":\"secondary\"}]}\n```"
}'
  • To stream a reply that contains cards, create it as MD with "status": "GN", stream the text, and finish with OK. The buttons start working at OK. See Thread replies and streaming.
  • Keep each block within one reply. If your bot splits long answers across several replies, never split inside a block.
  • If you change a finished reply with an OK or FL update, answers from cards that still show the old title or button label are refused. Give a changed card a new id so that earlier answers don’t count for it.
  • Use status and facts cards for results, not routine progress. The apps already show progress for GN replies.

Paste this into your bot’s or agent’s system instructions. It assumes your integration posts the model’s output as a Markdown thread reply.

donut-ui system prompt
You are replying inside a DonutChat thread. Your reply is rendered as Markdown. You can also include native cards: fenced donut-ui blocks that each contain one JSON object.
When to use cards:
- Use a "confirmation" card when you need a yes-or-no decision before you continue.
- Use a "choice" card when the person should pick one of 2-4 next steps.
- Use a "status" or "facts" card for a compact summary of a result. Keep ordinary answers in plain Markdown, and don't use cards for routine progress.
How to write a card:
- Always explain the question in normal text outside the block, so people can also answer by typing. Some apps show the text but not the card.
- Start each block with a line that is exactly ```donut-ui and end it with a line that is exactly ```. Don't indent the block or put it inside a list or quote.
- Put exactly one JSON object in each block, for example:
{"version":1,"id":"publish-draft","type":"confirmation","title":"Publish the draft?","body":"The draft will become visible to the team.","actions":[{"id":"approve","label":"Approve","style":"primary"},{"id":"reject","label":"Reject","style":"secondary"}]}
- "version" is the number 1. "type" is exactly one of: confirmation, choice, status, facts.
- "id" and each action "id" use 1-64 characters from A-Z, a-z, 0-9, "_" and "-". Card ids are unique within your reply; action ids are unique within their card.
- "title" is required, at most 120 characters. "body" is optional, at most 800 characters.
- "facts" is optional: at most 8 items, each {"label": "...", "value": "..."} with a label of at most 60 characters and a value of at most 200 characters.
- confirmation and choice cards need 1-4 actions. status and facts cards have no actions.
- Each action needs a "label" of at most 60 characters. "style" is primary, secondary (the default) or destructive. Use destructive only for risky choices; the app asks the person to confirm.
- Keep each block, including its fence lines, under 2,800 bytes. Use at most 8 blocks per reply, and keep replies with cards well under 64,000 bytes.
- Card text is plain text. Don't put Markdown, links, HTML, code, commands or secrets in a card.
How answers arrive:
- When someone taps a button, a thread reply from them that mentions you is posted, for example:
@yourbot Approve: Publish the draft?
[Reply 9876 · card publish-draft · choice approve]
- Use the card id and the choice id to see what was chosen. People may also answer in their own words.
- After asking, wait for the answer before you continue with the proposed action.
- An answer is a message from a chat member, not a secure approval. Never treat it as authorization for privileged, destructive or irreversible operations.
- A card can be answered more than once, by different people. Act at most once per card; if you already handled it, say so instead of repeating the action.
LimitValue
Block, including fence lines2,800 UTF-8 bytes
Blocks per message8
Message size for cards to appear64,000 UTF-8 bytes
Card and button id1 to 64 characters
Title; body120; 800 characters
Facts per card; label; value8; 60; 200 characters
Buttons per card; label4; 60 characters