Cards
Rich messages your app posts into chat, with buttons and menus it handles itself.
Cards¶
A card is a rich message your app posts into chat: a headline, some structure, and — if you want — buttons that call your app back.
```python from juhjuh import cards
api.post_message( channel_id, card=cards.card( cards.header("Build failed on main"), cards.fields({"Duration": "4m 12s", "Failed": "3 tests"}), cards.actions( cards.button("View logs", url="https://ci.example.com/builds/812"), cards.button("Retry", action_id="retry_build", value="812", style="primary"), ), level="danger", ), ) ```
Needs chat:write, and the app must have been added to the channel.
Anatomy¶
json
{
"level": "danger",
"title": "optional headline above the blocks",
"title_url": "optional link on that headline",
"blocks": [ ],
"footer": "optional small print"
}
level tints the card's edge: info (the default), success, warning,
danger, or none for no tint at all. Use it for what the card means, not for
decoration — a danger card that is merely informative trains people to ignore
the colour.
blocks is an ordered list. The card renders them top to bottom, exactly as
given.
Two things to know before you start¶
Unknown block types are dropped, not rejected. A card containing a block type
we do not recognise is posted without it — no error, no warning. If part of your
card is silently missing, a typo in type is the first thing to check.
Text is clamped, not rejected. Each field has a maximum length and anything over it is cut. A header is a line, not a paragraph; a table is a summary, not a report. Link out for the detail.
Blocks¶
header¶
A bold line. One short statement of what happened.
json
{ "type": "header", "text": "Build failed on main" }
| Field | Required | Notes |
|---|---|---|
text |
yes | Up to 200 characters |
section¶
A paragraph of body text, optionally with a small image floated to its right.
json
{ "type": "section", "text": "3 tests failed after the last push.", "accessory_image": "https://…/thumb.png" }
| Field | Required | Notes |
|---|---|---|
text |
yes, unless accessory_image is set |
Up to 3,000 characters |
accessory_image |
no | An http(s) image URL |
fields¶
A two-column label/value grid. The natural home for status, owner, branch, duration — anything a reader scans rather than reads.
json
{
"type": "fields",
"fields": [
{ "label": "Status", "value": "Failed" },
{ "label": "Branch", "value": "main" }
]
}
| Field | Required | Notes |
|---|---|---|
fields[].label |
yes | Up to 80 characters |
fields[].value |
yes | Up to 500 characters |
Up to 20 pairs. Beyond that it stops being scannable anyway.
context¶
Small, muted text under the main content. Provenance, timestamps, counts.
json
{ "type": "context", "elements": [ { "text": "pipeline 812" }, { "text": "2 minutes ago" } ] }
Up to 10 elements, each up to 200 characters. Each may carry an icon_url.
image¶
A full-width image with alt text.
json
{ "type": "image", "image_url": "https://…/graph.png", "alt": "Response times over the last hour" }
The URL must be http(s) and reachable by the people reading the message — a
link only your build server can fetch renders as a broken image for everyone
else. Always write alt; a card is read by people using screen readers too.
divider¶
A horizontal rule. { "type": "divider" }.
table¶
A compact table. Up to 6 columns; wider than that does not fit the surfaces a card renders on.
json
{
"type": "table",
"columns": ["Test", "Duration"],
"rows": [["test_auth", "0.4s"], ["test_billing", "1.2s"]]
}
Rows shorter than the column list are padded; extra cells are dropped. The SDK's
cards.table() raises instead, so a mismatch is a bug in your process rather
than a quietly malformed table in someone's chat.
actions¶
A row of interactive elements — up to 5.
json
{
"type": "actions",
"elements": [
{ "type": "button", "text": "View logs", "url": "https://…" },
{ "type": "button", "text": "Retry", "action_id": "retry_build", "value": "812", "style": "primary" }
]
}
A button either opens a link or calls your app back — never both.
| Element | Purpose | Key fields |
|---|---|---|
button (link) |
Opens a URL | text, url, style |
button (callback) |
Calls your app | text, action_id, value, style |
static_select |
Dropdown | action_id, options[], placeholder |
overflow |
Compact "…" menu | action_id, options[] |
datepicker |
Date choice | action_id, initial_date (YYYY-MM-DD) |
style is default, primary or danger. One primary per row: if everything
is primary, nothing is.
action_id is how your app knows which control was used — letters, digits,
_, ., : and -, up to 128 characters. value is up to 2,000 characters
of your own context, handed back to you verbatim.
value is not trustworthy. It comes back through a client. Put a reference
in it — a ticket key, a build number — and re-check permissions and state on your
side before acting. Never put a secret, a signed decision, or anything you would
be unhappy to see altered.
input¶
A labelled field, for cards and modals that collect something.
json
{
"type": "input",
"label": "Reason",
"optional": true,
"element": { "type": "plain_text_input", "action_id": "reason", "multiline": true, "placeholder": "Why are you retrying?" }
}
Element types: plain_text_input, static_select, checkboxes,
radio_buttons, datepicker. Each needs its own action_id.
The interaction round-trip¶
Someone presses a button your app posted. Four steps.
1. The click reaches your app.
json
{
"type": "interaction",
"action_id": "retry_build",
"value": "812",
"interaction_token": "it_…",
"message_id": "…",
"channel_id": "…",
"user_id": "…"
}
2. Decide. user_id is who pressed it — not necessarily who the card was
posted for. If the action matters, check that this person is allowed to take it.
A button is visible to everyone in the channel.
3. Answer, using the interaction_token. It addresses this one click; hold it
just long enough to reply.
json
{
"type": "interaction_response",
"interaction_token": "it_…",
"response_action": "update",
"card": { }
}
response_action |
Effect |
|---|---|
update |
Replace the card in place — send the new card |
ephemeral |
Reply visible only to the person who clicked — send text or card |
open_modal |
Open a dialog — send a view (a title plus blocks) |
clear |
Remove the card's interactive elements, leaving the content |
4. Answer promptly. If the work takes a while, update the card to say so
first, then update it again when you are done. A button that appears to do
nothing gets pressed again.
python
@app.action("retry_build")
async def retry(interaction, conn):
await conn.respond(
interaction.interaction_token,
"update",
card=cards.card(cards.section(f"Retrying build {interaction.value}…")),
)
...
Updating a card later¶
Interactions are not the only way to change a card. A card your app posted can be replaced at any time:
python
api.update_card(message_id, cards.card(cards.header("Build passed"), level="success"))
Only your own messages, and it replaces the card wholesale rather than merging.
This is what makes a status card work: post one card when a long job starts and edit it as the job progresses, instead of stacking five messages that each say something slightly different.
Design notes¶
- One card, one thing. If it needs two headers, it is two cards.
- Put the outcome in the header. People read the first line and act.
- Link out rather than paste in. Cards are summaries; the detail lives where it lives.
- Do not rebuild an interface in a card. A card that needs eight fields and three buttons wants to be a link to a page.
- Assume it will be read on a phone. Narrow, dark, and in a hurry.