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.
How it works
Section titled “How it works”- Your bot answers in a thread with a Markdown reply that contains text and one or more
donut-uiblocks. - The apps show each valid block as a card. Everything else stays Markdown.
- 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 isOK. A reply that ends asFLnever gets working buttons. - Someone selects a button. A
destructivebutton asks them to confirm first. - DonutChat records the answer and posts a thread reply in their name that mentions your bot and names the card and the choice.
- Your bot receives an
interaction.newevent, and the reply as athread.reply.newevent when its trigger mode delivers replies. It continues in the thread.
Requirements
Section titled “Requirements”- Sent by a bot as Markdown. Cards appear only in messages and thread replies that a bot sends with
"content_type": "MD". WithTX, 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/messagesis 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.newarrives 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.
Where cards appear
Section titled “Where cards appear”| Where | iPhone | Android | Web | Desktop |
|---|---|---|---|---|
Bot thread reply, status OK | Card with buttons | Card with buttons | Card with buttons | Card with buttons |
Bot thread reply, status GN or FL | Card, buttons disabled | Card, buttons disabled | Card, buttons disabled | Card, buttons disabled |
| Top-level bot message in the chat | Card with buttons | Card with buttons | Card, buttons disabled | Card with buttons |
| Top-level bot message shown at the top of its thread | Card with buttons | Card with buttons | Plain text | Card 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.
The donut-ui block
Section titled “The donut-ui block”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"}]}```Block rules
Section titled “Block rules”- The opening line is
```donut-uiat 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-uiblocks, valid or not. - No two valid cards share an
id.
Fields
Section titled “Fields”| Field | Type | Rules |
|---|---|---|
version | number | Must be 1. 1.0 also works; "1" doesn’t. |
id | string | 1 to 64 characters from A-Z, a-z, 0-9, _ and -. Unique among the cards in the message. |
type | string | confirmation, choice, status or facts. |
title | string | Required and not blank, at most 120 characters. |
body | string | Optional, at most 800 characters. |
facts | array | Optional, 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. |
actions | array | The buttons, at most 4. confirmation and choice cards need at least 1; status and facts cards can’t have any. |
actions[].id | string | Same rules as id. Unique within the card. |
actions[].label | string | Required and not blank, at most 60 characters. It’s also the text sent back when someone selects the button. |
actions[].style | string | primary, secondary (the default) or destructive. Any other value makes the card invalid. |
- Character limits count Unicode code points.
- An optional field set to
nullcounts 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.
Card types and button styles
Section titled “Card types and button styles”| Type | Buttons | Use it for |
|---|---|---|
confirmation | 1 to 4 | A yes-or-no decision before the bot continues |
choice | 1 to 4 | Picking one of a few next steps |
status | None | A compact summary of a result |
facts | None | A 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.
What an answer sends
Section titled “What an answer sends”When someone selects a button on a finished bot reply or a top-level bot message:
-
The card’s buttons are disabled and it shows Sending response…. For a
destructivebutton, the confirmation dialog opens first, and Cancel brings the buttons back. -
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. -
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] -
Your bot gets an
interaction.newevent. -
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 the answer
Section titled “Handle the answer”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 a card
Section titled “Send a card”Send the card in a thread reply with "content_type": "MD":
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```" }'// Build the reply text. JSON.stringify takes care of escaping.const card = { 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' }, ],};const block = '```donut-ui\n' + JSON.stringify(card) + '\n```';if (Buffer.byteLength(block) > 2800) throw new Error('The card block is over 2,800 bytes');
const content = 'I can publish the draft now. Tap a button, or reply "approve" or "reject".\n\n' + block;// Send `content` with "content_type": "MD" to POST /bots/v1/thread-replies.- To stream a reply that contains cards, create it as
MDwith"status": "GN", stream the text, and finish withOK. The buttons start working atOK. 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
OKorFLupdate, answers from cards that still show the old title or button label are refused. Give a changed card a newidso that earlier answers don’t count for it. - Use
statusandfactscards for results, not routine progress. The apps already show progress forGNreplies.
Copyable prompt for your bot
Section titled “Copyable prompt for your bot”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.
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.Limits
Section titled “Limits”| Limit | Value |
|---|---|
| Block, including fence lines | 2,800 UTF-8 bytes |
| Blocks per message | 8 |
| Message size for cards to appear | 64,000 UTF-8 bytes |
Card and button id | 1 to 64 characters |
| Title; body | 120; 800 characters |
| Facts per card; label; value | 8; 60; 200 characters |
| Buttons per card; label | 4; 60 characters |