Generative UI on Slack

Convert a generative UI tree into Slack Block Kit, post it, and decode the block_actions payload Slack sends back into your action handlers.

The $type tree your assistant renders in the browser is plain JSON, so it does not have to stay in the browser. @assistant-ui/react-generative-ui/slack converts the same tree into Block Kit JSON, decodes the interactions Slack posts back, and parses Block Kit payloads into the tree.

The subpath is React-free, so a server action, queue worker, or webhook handler imports it without pulling React into the bundle.

Posting a tree

import { WebClient } from "@slack/web-api";
import { toSlackBlocks } from "@assistant-ui/react-generative-ui/slack";

const slack = new WebClient(process.env.SLACK_BOT_TOKEN);

const { blocks, warnings } = toSlackBlocks({
  $type: "Card",
  title: "Order #48213",
  children: [{ $type: "Text", value: "Shipped, arriving Thursday." }],
});

await slack.chat.postMessage({ channel: "#orders", blocks });

toSlackBlocks(node, options?) returns { blocks, warnings } and never throws: an input it cannot convert at all comes back as empty blocks plus one warning rather than an exception. Pass { surface: "modal" } to target a modal instead of a message; the surface changes the block budget and unlocks the native alert block.

Warnings

Conversion is total. Every downgrade is reported rather than thrown, so one unsupported node never costs you the whole message:

type SlackConversionWarning = {
  code: "clamped" | "dropped" | "fallback";
  component: string; // the IR component name, or "Root" for whole-payload issues
  detail: string;
};

clamped means content was truncated to fit a Slack limit, dropped means a node produced no output, and fallback means the node rendered through a simpler construct than its ideal one. Warnings arrive in traversal order and are not deduplicated. Logging them in development is the fastest way to see why a composition looks different on Slack than in the browser.

Component mapping

ComponentSlack outputFidelity
Headerheader blockFaithful
Textsection block with mrkdwn textFaithful
Markdownmarkdown blockDowngrades to a section once the payload's markdown budget is spent
Caption, Badgecontext block with one elementThe two become identical output and cannot be told apart coming back
Imageimage blockOnly src and alt survive; size and round are dropped silently
Dividerdivider blockFaithful
FactMerged into one section's fields, as *label* then valueConsecutive facts merge; every 10 fields start a new section
Tabledata_table block, first row as headerCells become raw_number or raw_text; rows are padded to a uniform width
CardNative card block, or a header plus inline blocksSee Cards
Carouselcarousel block of card elementsNon-card children are dropped; cards that cannot map cleanly degrade to title and body
AlertNative alert block on a modal; a context plus section pair on a messageSlack supports alert only in modals
ListViewOne section per item, with divider blocks between themItem children collapse into concatenated text
ListViewItemsection, plus an "Open" button accessory when it carries an action
Buttonbutton element inside an actions blockprimary and danger styles survive; other styles are dropped
Selectstatic_select element
RadioGroupradio_buttons element
Checkboxcheckboxes element with a single option
DatePickerdatepicker elementmin and max are dropped; a non-YYYY-MM-DD value is dropped with a warning
InputIts own input blockNot grouped into an actions block
FormChildren inline, then a "Submit" buttonSlack has no form container
RowOne context block when every child is a Badge or Caption, otherwise flattenedHorizontal layout is lost in the flattened case
Col, BoxFlattened into the sibling block streamSlack has no nesting container
ChartReplaced by a note blockAlways warns
Spacer, IconDroppedSilently, since neither has a Slack equivalent

An unknown component and a bare string child are both handled: the unknown one is dropped with a warning, and the string becomes a section.

Cards

Card takes the native card block only when its children fit that block's fixed fields, because Slack's card carries hero_image, title, body, subtext, and up to three action buttons rather than nesting arbitrary blocks. The converter fills those from the first Image, the first Text or Markdown, and the first Caption.

Anything else in the card, including a second image, a Fact, or a loose Button, makes the card fall back to a header plus the children rendered inline plus an actions block, and reports a fallback warning. A card carrying none of an image, title, body, or actions is dropped outright.

Inside a Carousel the fallback is unavailable, so an over-full card degrades to title and body text instead.

Limits

The converter clamps to Slack's published budgets rather than letting the API reject the payload. The ones you are most likely to hit:

BudgetValueBehavior when exceeded
Blocks per message50 (100 in a modal)Extra blocks are dropped and a note block reports the count
Section text3,000 charactersTruncated
Section fields10 per section, 2,000 characters eachChunked into further sections; text truncated
Actions elements25 per blockChunked into further actions blocks
Select options100Truncated
Radio options10Truncated
Button label75 charactersTruncated
Button action payload2,000 charactersDropped entirely, not truncated, so a partial payload never round-trips
Card title150 characters, body and subtext 200Truncated
Carousel cards10Truncated; a carousel with no renderable card is dropped
Table100 data rows, 20 columns, 10,000 characters across all tables in one payloadRows truncated; a table whose header alone busts the budget is dropped
Markdown12,000 characters across the payloadEvery markdown block from that point on becomes a section

Traversal itself is bounded too: 200 children per level, 5,000 nodes per call, and a depth ceiling, each reported as a Root warning. These bounds exist because the tree arrives from a model.

The table budgets above are the converter's. Slack has since raised the platform ceiling for data_table to 200 data rows plus a header and 20,000 characters, so trees larger than the converter's limit are clamped even though Slack would now accept them.

Actions

Outbound

A node's $action is split across two Block Kit fields. $action.type becomes the element's action_id, and the remaining keys are JSON-serialized into the element's value.

{
  "$type": "Button",
  "label": "Approve",
  "$action": { "type": "approve_order", "orderId": "48213" }
}

becomes a button with action_id: "approve_order" and value: "{\"orderId\":\"48213\"}".

Only buttons carry value. Select, Input, DatePicker, Checkbox, and RadioGroup emit action_id alone, so any extra keys on their $action are dropped. Keep those controls' actions to a bare type, or put the payload on a button that submits alongside them.

Inbound

Slack posts interactions to your request URL as a block_actions payload. decodeBlockAction takes one entry from its actions array and rebuilds the action your tree dispatched, with the user's runtime selection under $input:

import { decodeBlockAction } from "@assistant-ui/react-generative-ui/slack";

const action = decodeBlockAction(payload.actions[0]);
// { type: "approve_order", orderId: "48213", $input: "…" }

It returns undefined for anything without a usable action_id, and never throws. type always comes from action_id, so a payload key named type cannot override it, and a $input key smuggled into the serialized payload is always stripped.

What lands in $input depends on the element:

Element$input
static_select, radio_buttonsThe selected option's value, as a string
datepickerThe selected date, as YYYY-MM-DD
checkboxesAn array of selected values, empty when nothing is checked
plain_text_inputThe typed text
buttonThe raw value string, when it is not a serialized object

Reading a tree back

fromSlackBlocks is the inverse direction, mapping a Block Kit payload into vocabulary nodes and returning { nodes, warnings }. It accepts a bare array or a { blocks } wrapper.

The round trip is faithful on the plain building blocks (text, images, facts, controls, tables, simple cards) and documented-lossy elsewhere: context elements all return as Caption, so the Badge distinction is gone, button styles beyond primary and danger are dropped, an alert's title and description come back merged into the description, and card layouts flatten to the fields the card block carries. Card footers are re-derived from button style, with the primary-styled button becoming confirm; when style cannot decide it, position does, and that emits a fallback warning.

Before interactions work

Converting and posting a tree needs only a bot token with chat:write. Making its buttons do anything additionally needs, on the Slack app side:

  • Interactivity enabled with a request URL, under Interactivity & Shortcuts. Slack posts every block_actions payload there.
  • An acknowledgement within 3 seconds. Return HTTP 200 first and do the work afterwards; the payload's response_url accepts up to five follow-up posts within 30 minutes if you need to update or replace the message.
  • Request signature verification on that endpoint, using your signing secret.

Receiving the webhook, verifying it, and routing the decoded action to your handler stay your application's responsibility. The converter only speaks JSON in and JSON out.

Reference

The generated per-export reference, including every type in the subpath, is at Slack Block Kit. For the Teams equivalent of this page, see Generative UI on Microsoft Teams.