Cards
Developers

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

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.