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 generative UI tree your assistant renders in the browser is plain JSON, so it does not have to stay in the browser. @assistant-ui/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/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 });A spec from spec mode converts with specToUISpec from the React-free @assistant-ui/generative-ui/spec/tree first, as Moving to spec mode describes.
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.
Update a message as the spec streams
Feed complete JSONL patch lines into createSpecStream. Post when the root element exists and produces blocks, then update that message at most once every 1500 ms, and once more when the stream ends. specLines below is an async iterable of lines from your model:
import { WebClient } from "@slack/web-api";
import { createSpecStream, type Spec } from "generative-frame/spec";
import { specToUISpec } from "@assistant-ui/generative-ui/spec/tree";
import { toSlackBlocks } from "@assistant-ui/generative-ui/slack";
const slack = new WebClient(process.env.SLACK_BOT_TOKEN);
const stream = createSpecStream();
const channel = "#orders";
let message: { channel: string; ts: string } | undefined;
let lastUpdate = 0;
async function publish(spec: Spec, final = false) {
if (!spec.elements[spec.root]) return;
if (message && !final && Date.now() - lastUpdate < 1500) return;
const { blocks } = toSlackBlocks(specToUISpec(spec));
if (blocks.length === 0) return;
if (!message) {
const posted = await slack.chat.postMessage({ channel, blocks });
if (!posted.channel || !posted.ts) throw new Error("Slack did not return a message location.");
message = { channel: posted.channel, ts: posted.ts };
lastUpdate = Date.now();
return;
}
await slack.chat.update({ ...message, blocks });
lastUpdate = Date.now();
}
for await (const line of specLines) {
await publish(stream.push(`${line}\n`).spec);
}
await publish(stream.result().spec, true);The host owns the stream transport and Slack rate limits. A control can be clicked as soon as an update shows it, so your action handler has to accept a click for an element a later update removed, or for one whose params had not streamed yet.
Warnings
Conversion is total. Every downgrade is reported rather than thrown, so one unsupported node does not fail 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 or one of its props was discarded and may have left a placeholder note behind (an oversized button payload is dropped while the button itself is kept, a Chart becomes an omission note), and fallback means the node rendered through a different construct than requested. Warnings arrive in traversal order and are not deduplicated. Log them in development to see why a composition looks different on Slack than in the browser.
Component mapping
| Component | Slack output | Fidelity |
|---|---|---|
Header | header block | Only text survives; size is dropped silently, since Slack's header block has no size |
Text | section block with mrkdwn text | Only value survives; size, weight, and color are dropped silently |
Markdown | markdown block | Downgrades to a section once the payload's markdown budget is spent; outside code, the < of Slack mention and labeled link syntax (<!here>, <@U…>, <#C…>, <url|label>) is written as < so it shows as text |
Caption, Badge | context block with one element | The two become identical output and cannot be told apart coming back |
Image | image block | Only src and alt survive; size and round are dropped silently |
Divider | divider block | flush is dropped silently |
Fact | Merged into one section's fields, as *label* then value | Consecutive facts merge; every 10 fields start a new section; delta appends as the browser's directional chip in parentheses |
Table | data_table block, first row as header | Column formats produce the same formatted text as the browser; sorting and alignment are browser-only; rows are padded to a uniform width; a column without a string label keeps its position with an empty header and warns |
Card | Native card block, or a header plus inline blocks | See Cards |
Carousel | carousel block of card elements | A non-card child that would have rendered is dropped with a warning; a card that cannot map cleanly is reshaped to title and body, and separately reports the images, tables, charts, and controls that reshape loses |
Alert | Native alert block on a modal; a context plus section pair on a message | Slack supports alert only in modals |
ListView | One section per item, with divider blocks between them | Item children collapse into concatenated text; a non-item child that would have rendered is dropped with a warning |
ListViewItem | section, plus an "Open" button accessory when it carries an action | |
Tabs | A section with each bold label, followed by that panel's blocks | Slack has no tabs, so every panel shows in order |
Steps | One section per step, with the bold numbered title and the detail under it | An entry without a title is dropped with a warning |
Timeline | One section per item, with the bold time, then the title and the detail | An entry without a title is dropped with a warning |
Citation | A section with the title, linked to its url, and the snippet under it, then a context block with the source or the link's domain | Only an http or https url becomes a link |
Diagram | A section with the Mermaid source in a code block, then a context block with the title | Slack cannot draw the diagram; a source over the section limit is clamped with a warning |
Map | One section per place, with the label linked to OpenStreetMap and the detail under it | Slack cannot draw a map, so route is dropped silently; a place without valid coordinates is dropped with a warning |
Button | button element inside an actions block | primary and danger styles survive; other styles are dropped |
Select | static_select element | An option without a string label and value is dropped with a warning |
RadioGroup | radio_buttons element | An option without a string label and value is dropped with a warning |
CheckboxGroup | checkboxes element | An option without a string label and value is dropped with a warning; Slack allows 10 options |
Checkbox | checkboxes element with a single option | The switch variant uses the same element |
Slider | Its own input block with number_input | min, max, and defaultValue map to number bounds and initial value; fractional steps allow decimal input |
DatePicker | datepicker, timepicker, or datetimepicker elements | inputType selects the mode; local datetimes use a date and time pair. See Date and time pickers |
Input | Its own input block for text and number; an omission note for password | Number uses plain_text_input, stays a string, and has no native numeric validation; Slack shows no password field, and an action drops the omitted password's value |
Form | Children inline, then a "Submit" button | Slack has no form container |
Row | One context block when every child is a Badge or Caption, otherwise flattened | Horizontal layout is lost in the flattened case |
Col, Box | Flattened into the sibling block stream | Slack has no nesting container |
Chart | Replaced by a note block | Always warns |
Spacer, Icon | Dropped | Silently, since neither has a Slack equivalent |
RadioGroup and CheckboxGroup option descriptions map to Slack option description text.
Slack has no undo surface, so a Button with undoable fires immediately.
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.
Presentation props the converter does not map are dropped silently, without a warning, because the node itself still renders. That covers Box's width, height, radius, and background; Card's padding and background; gap on Row, Col, and Form, align on Row and Col, and justify on Row; Badge's variant; Carousel's label; CheckboxGroup's progress; and Button's block. Slack has no client-side form model to submit into, so Button's submit is dropped: a submit button and a click button both convert to the same button element carrying the node's $action. A Card marked asForm also cannot collect its child values into a browser-style form payload, but it keeps child Input actions passive so those inputs do not dispatch separately.
Date and time pickers
DatePicker.inputType defaults to "date", which emits a datepicker with a YYYY-MM-DD value. "time" emits a timepicker with HH:mm; nonzero seconds in an HH:mm:ss value are dropped with a dropped warning. Neither time-only pickers nor the time half of a local datetime set Slack's timezone field.
With "datetime", a value carrying Z or an offset such as +02:00 is an instant. It emits a datetimepicker initialized with Unix seconds, which Slack displays in the viewer's zone. Decoding preserves the original offset spelling at seconds precision. An empty datetime also emits a datetimepicker; once selected, it decodes with Z at seconds precision because Slack's payload carries no viewer offset. Nonzero fractional seconds are dropped with a dropped warning because Slack's instant picker only supports second precision.
A datetime without an offset, such as 2025-12-15T17:00, is local wall time. It emits separate datepicker and timepicker controls for the same field and decodes to YYYY-MM-DDTHH:mm. Nonzero seconds or fractions are dropped with a warning. A field reference resolves to "" when either half is empty; an action from either half returns undefined until both have a value. Pass the companion state values to decodeBlockAction so it can merge the selections.
min and max are dropped in all modes. A value that does not match its mode is omitted with a dropped warning. The mode, original offset, and split-pair identity travel in the existing block ID metadata, including for unnamed controls. The same 255-character cap and omission warning apply.
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.
On a Card marked asForm, the confirm button carries the confirm footer's $action, or the Card's own $action when the footer has none. A form card with its own $action and no confirm footer gets a primary "Submit" button where the confirm button would go, ahead of any cancel button.
Inside a Carousel that fallback is unavailable, so an over-full card is reshaped to title and body text instead, with a fallback warning. Text carried in a text, value, label, title, or description prop survives the reshape at any depth, which covers a Caption's text and a Button's label. An image, a Table, a Chart, and any control do not, since none of them is text, and those are reported separately as dropped so the two facts stay distinguishable. A control here is a Button, Select, Slider, DatePicker, Checkbox, RadioGroup, CheckboxGroup, Input, or Form, plus a ListViewItem or a nested Card footer that carries an action, and the "Submit" button a form card gets from its own $action. An $action on a layout node such as Box, Col, or Row does not count, because those render no control on the clean path either.
Limits
The converter clamps to Slack's published budgets rather than letting the API reject the payload. Caps on mrkdwn fields count the escaped text, where &, <, and > take five, four, and four characters (&, <, >), and a plain @here, @channel, or @everyone gains an invisible word joiner after its @ so Slack shows it as text instead of notifying the channel. Markdown blocks count their escaped text against the markdown budget the same way. The budgets you are most likely to hit:
| Budget | Value | Behavior when exceeded |
|---|---|---|
| Blocks per message | 50 (100 in a modal) | Extra blocks are dropped and a note block reports the count |
| Section text | 3,000 characters | Truncated |
| Section fields | 10 per section, 2,000 characters each | Chunked into further sections; text truncated |
| Actions elements | 25 per block | Chunked into further actions blocks |
| Select options | 100 | Truncated |
| Radio options | 10 | Truncated |
| Button label | 75 characters | Truncated |
| Button action payload | 2,000 characters | Dropped entirely, not truncated, so a partial payload never round-trips |
| Card title | 150 characters, body and subtext 200 | Truncated |
| Carousel cards | 10 | Truncated; a carousel with no renderable card is dropped |
| Table | 200 data rows, 20 columns, 20,000 characters across all tables in one payload | Rows truncated; a table whose header alone busts the budget is dropped |
| Markdown | 12,000 characters across the payload | Every markdown block from that point on becomes a section |
Traversal itself is bounded too: 200 children per level, 5,000 nodes per call, and 32 levels of element nesting, each reported as a Root warning. These bounds exist because the tree arrives from a model.
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\"}".
Warning
Only buttons carry value. Select, Input, Slider, DatePicker, Checkbox, RadioGroup, and CheckboxGroup 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. A { "$field": name } reference in a button payload resolves from the current state values when the decoder receives them. Named controls carry their field names in their block IDs; controls sharing an actions block use a per-element mapping. Slack limits block IDs to 255 characters, so a mapping that exceeds that limit is omitted with a dropped warning.
Inbound
A single-line Input outside a Form or Card with asForm, with a non-empty $action.type, enables block_actions on its containing input block and configures on_enter_pressed, so Slack sends its typed text when the user presses Enter. Inputs inside either form remain passive rather than firing their own action. Multiline inputs also remain passive: Slack's dispatch configuration does not expose the browser vocabulary's Ctrl/Cmd+Enter shortcut. A modal's view_submission handler can collect passive input values, including inputs without an actionable type.
Slack posts interactions to your request URL as a block_actions payload. decodeBlockAction takes one entry from its actions array and the payload's state values, then rebuilds the action your tree dispatched. Messages carry these values in payload.state.values, while modals and Home tabs carry them in payload.view.state.values. A control's runtime selection appears under $input when supplied; a button with a JSON-serialized value contributes its payload fields directly. { "$field": name } references in the action payload resolve from named controls in that state:
import { decodeBlockAction } from "@assistant-ui/generative-ui/slack";
const action = decodeBlockAction(payload.actions[0], payload.state?.values ?? payload.view?.state?.values);
// { type: "approve_order", orderId: "48213" }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_buttons | The selected option's value, as a string |
datepicker | The selected date, as YYYY-MM-DD |
timepicker | The selected time, as HH:mm |
datetimepicker | The selected instant at seconds precision, with the original offset spelling or Z for an initially empty value |
checkboxes | An array of selected values, empty when nothing is checked |
plain_text_input | The typed text |
number_input | The entered number |
button | The raw value string, when it is not a serialized object |
A { "$field": name } reference to a named Checkbox resolves from state values to true when checked or false when unchecked.
For the two halves of a local datetime, $input is the merged YYYY-MM-DDTHH:mm value rather than the individual selection. A { "$field": name } reference uses the same canonical value. Keep the picker's block_id on the action entry passed to the decoder so it can recover the temporal metadata.
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, a CheckboxGroup with a single option returns as a Checkbox, 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. An input keeps its action_id as $action only when its block sets dispatch_action or the input is multiline, so a passive single-line input, including one from a form, returns without an action and stays passive when converted back.
Native timepicker and datetimepicker elements return as DatePicker nodes with inputType: "time" and inputType: "datetime". A native datetime's initial Unix timestamp becomes a Z value at seconds precision. Reverse conversion reads each control separately, so a local datetime pair returns as separate date and time nodes.
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_actionspayload there. - An acknowledgement within 3 seconds. Return HTTP 200 first and do the work afterwards; the payload's
response_urlaccepts 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 maps JSON to JSON.
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.