Design message cards
Everything a bot posts, from a webhook, a command reply or a button update, is a card. A good card says what happened in one glance: a coloured edge, a title, a status, the details underneath.
What you can do
- Show a statusA coloured pill like Open, Merged or Passing, with lines added and removed.
- List the detailsLabel and value rows that members can copy in one tap.
- Write in markdownBold, inline code, code blocks, quotes and checkboxes.
- Show that you are workingA spinner while your bot thinks, and its reasoning behind a toggle afterwards.
In the app
Four cards as members see them. Each one is a few lines of JSON.
Anatomy of a card
The parts of a card, top to bottom. Leave out what you do not need: a card with only a title is fine.
- Header"Message from" and a name: the webhook's name, or your community's name for a command reply. A reply can add a
badge, such as the repository. - Title and statusThe
title, a link when you settitle_url, with thestatuspill beside it. - SubtitleA second bold line,
sub_title. - DescriptionThe body, in markdown.
- FieldsLabel and value rows, with a copy button.
- FooterThe time, and lines added and removed if you send them.
{
"message_container": {
"type": "embed_message",
"badge": "acme/web",
"color": "purple",
"title": "Pull request #212 opened",
"title_url": "https://github.com/acme/web/pull/212",
"status": { "label": "Open", "color": "green", "icon": "pull_request" },
"sub_title": "Faster search in the channel list",
"description": "Search now runs **per keystroke** with a 120 ms debounce.",
"fields": [
{ "field": "Author", "value": "maya" },
{ "field": "Reviewers", "value": "dani, sam" }
],
"additions": 86,
"deletions": 12,
"files_changed": 3
}
}All fields
These go in message_container. A card needs a description, or a loader. Fields marked "command replies" are dropped when you post through a webhook.
| Field | Type | What it does |
|---|---|---|
type | string | embed_message (the default) or system_message. |
badge | string | A small chip after the name in the header, such as acme/web. Command replies |
avatar_url | string | An image over the card's icon. |
color | string | The edge colour. See colours below. |
title | string | The bold first line. |
title_url | string | Turns the title into a link. |
sub_title | string | A second bold line under the title. |
description | string | The body, in markdown. |
fields | array | [{ "field": "…", "value": "…" }]: label and value rows. |
image_url, image_base64 | string | An image on the card. |
images | array | [{ "image_url": "…" }]: a gallery of several images. |
status | object or string | A coloured pill next to the title. See below. Command replies |
additions, deletions, files_changed | number | Diff stats in the footer. Command replies |
loader, loader_text, loader_sub_text | boolean, string | A spinner instead of the body. |
thinking | string | Reasoning behind a Show thinking toggle. Command replies |
thinking understand markdown: **bold**, _italic_, ~~strike~~, `inline code`, fenced code blocks, > quotes, - [x] checkboxes, @mentions and :emoji:.Status and diff stats
A status pill tells the story before anyone reads the text. It sits next to the title, or in the footer when there is no title; diff stats show next to the time. Both work in command replies, and the built-in GitHub integration uses them. A webhook drops them.
{
"message_container": {
"color": "red",
"title": "Build failed on main",
"status": { "label": "Failing", "color": "red" },
"description": "`search.test.js`: 2 of 212 tests failed."
}
}| Field | Type | What it does |
|---|---|---|
status | object or string | A bare string is the label: "status": "Open". |
status.label | string | The pill text. Without it there is no pill. |
status.color | string | green, purple, red, orange, yellow, blue or gray. |
status.icon | string | An optional icon from the list below. |
additions | number | Lines added, shown as green +86. |
deletions | number | Lines removed, shown as red -12. |
files_changed | number | Files touched, shown as 3 files. |
Icons
| Value | Icon | Typical use |
|---|---|---|
pull_request | git-pull-request | A pull request opened |
pull_request_closed | git-pull-request-closed | Closed without merging |
merge, merged | git-merge | Merged |
commit | git-commit | A pushed commit |
issue | circle-dot | An issue opened |
issue_closed | circle-check | An issue closed |
check | circle-check | Tests passed, a job succeeded |
A mapping that works for GitHub
The built-in GitHub integration uses these; copy them for your own tools.
| Event | Label | Colour | Icon |
|---|---|---|---|
| Pull request opened | Open | green | pull_request |
| Draft | Draft | gray | pull_request |
| Merged | Merged | purple | merged |
| Closed unmerged | Closed | red | pull_request_closed |
| Issue opened | Open | green | issue |
| Issue closed | Closed | purple | issue_closed |
| Commit pushed | Commit | gray | commit |
| Tests passed | Passing | green | check |
| Tests failed | Failing | red | none |
Loader and thinking
For anything that takes a moment, like an AI answer or a long job, post a card with a spinner first, then replace it with the result. The loader works from webhooks and command replies. In a command reply you can also put the model's reasoning in thinking: members see a Show thinking toggle instead of a wall of text.
First: the loader
Then: the answer, reasoning folded away
{
"message_container": {
"type": "embed_message",
"color": "blue",
"loader": true,
"loader_text": "Thinking…",
"loader_sub_text": "Reading the last 50 messages"
}
}{
"message_container": {
"type": "embed_message",
"color": "blue",
"sub_title": "maya: when is the standup?",
"description": "Standup is at **09:30**, in #daily.",
"thinking": "Checked the pinned messages and the recurring event in #daily…"
}
}| Field | Type | What it does |
|---|---|---|
loader | boolean | true shows the spinner instead of the body. |
loader_text | string | The line next to the spinner, such as "Thinking…". |
loader_sub_text | string | A smaller line under it. |
thinking | string | Folded reasoning under the description, in markdown. |
To swap the loader for the answer, update the message with a new card that leaves out loader. How is on live updates.
Long descriptions
A description can be up to 50,000 bytes. Past the first 1,000, members see the start and a Show more button that loads the rest, so a long report does not flood the channel.
System messages
Set "type": "system_message" for a notice rather than a bot post: maintenance windows, policy changes, anything that speaks for the community itself. It takes the same fields and buttons.
{
"message_container": {
"type": "system_message",
"color": "orange",
"title": "Maintenance tonight",
"description": "The build servers are down from 22:00 to 23:00."
}
}Colours
The edge colour is the fastest signal on a card. Use the same colour for the same kind of news, every time.
| Colour | Use it for |
|---|---|
green | Success: passed, deployed, done |
red | Failure: failed, down, rejected |
orange | A warning that needs a look |
yellow | Waiting on someone: approvals, questions |
blue | Information, the default |
purple | Code events, or something special |
Build your embed
Edit the fields or the JSON payload. Both stay in sync. Watch the message render exactly as it will in a channel. This is the real webhook body; copy it when it looks right.