Convert a generative UI tree into an Adaptive Card, send it from a bot, and decode the Action.Submit payload Teams 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/teams converts the same tree into an Adaptive Card and decodes the submit payload a Teams bot receives back.
The subpath is React-free, so a server action, queue worker, or bot handler imports it without pulling React into the bundle.
Sending a card
import { toAdaptiveCard } from "@assistant-ui/generative-ui/teams";
const { card, warnings } = toAdaptiveCard({
$type: "Card",
title: "Order #48213",
children: [{ $type: "Text", value: "Shipped, arriving Thursday." }],
});
await context.sendActivity({
attachments: [
{
contentType: "application/vnd.microsoft.card.adaptive",
content: card,
},
],
});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.
toAdaptiveCard(node) returns { card, warnings } and never throws; an input it cannot convert comes back as an empty card plus one warning. toTeamsAttachments(node) wraps the same conversion in the attachment envelope for you, which is the form you want for a carousel.
Cards are stamped at Adaptive Cards schema 1.5, the version Teams supports on desktop. Teams mobile clients cap at 1.2, so a card using 1.5-only elements (notably Table) may not render there.
Update a message as the spec streams
Feed complete JSONL patch lines into createSpecStream. Send when the root element exists and produces card content, then update that activity 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 { createSpecStream, type Spec } from "generative-frame/spec";
import { specToUISpec } from "@assistant-ui/generative-ui/spec/tree";
import { toAdaptiveCard } from "@assistant-ui/generative-ui/teams";
const stream = createSpecStream();
let activityId: string | undefined;
let lastUpdate = 0;
async function publish(spec: Spec, final = false) {
if (!spec.elements[spec.root]) return;
if (activityId && !final && Date.now() - lastUpdate < 1500) return;
const { card } = toAdaptiveCard(specToUISpec(spec));
if (card.body.length === 0 && card.actions.length === 0) return;
const attachments = [{
contentType: "application/vnd.microsoft.card.adaptive",
content: card,
}];
if (!activityId) {
const sent = await context.sendActivity({ attachments });
if (!sent?.id) throw new Error("Teams did not return an activity ID.");
activityId = sent.id;
lastUpdate = Date.now();
return;
}
await context.updateActivity({ id: activityId, type: "message", attachments });
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 Teams 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:
type TeamsConversionWarning = {
code: "clamped" | "dropped" | "fallback" | "advisory";
component: string; // the IR component name, or "Root" for whole-payload issues
detail: string;
};clamped means content was truncated or capped, dropped means a node or one of its props was discarded and may have left a placeholder note behind (a Chart becomes an omission note), fallback means the node rendered through a different construct than requested (buttons past the sixth moved to secondary mode, a renamed input id), and advisory means nothing was changed at all and the result may still render poorly or be refused (a Row beyond three columns, a card or carousel over the byte budget).
Component mapping
| Component | Adaptive Card output | Fidelity |
|---|---|---|
Header | TextBlock with heading style | size is ignored; the heading always emits large |
Text | TextBlock | Six text sizes collapse to five rendered sizes, four weights to bold or not, and the five subtle color tokens to isSubtle while emphasis is dropped |
Markdown | TextBlock, passed through verbatim | Teams renders a markdown subset; headings, tables, images, and blockquotes render literally |
Caption, Badge | Small subtle TextBlock | The two become identical output |
Image | Image | A numeric size is dropped with a warning; round is dropped silently |
Fact | FactSet | Consecutive facts merge into one set; delta appends as the browser's directional chip in parentheses |
Table | Native Table with the first row as headers | Column formats produce the same formatted text as the browser; sorting and alignment are browser-only; requires schema 1.5, so it will not render on Teams mobile; a column without a string label keeps its position with an empty header and warns |
Card | Container, with the title as a leading heading | Footer buttons become an ActionSet beside the container, not inside it; a form card with its own $action and no confirm footer also gets a "Submit" action |
Alert | Container with a semantic style (accent, good, warning, attention) | Title and description become two text blocks |
Carousel | Multiple attachments through toTeamsAttachments | See Carousels; a non-card child that would have rendered is dropped with a warning |
ListView | One Container per item, separated after the first | A non-item child that would have rendered is dropped with a warning, and never reserves an input id |
ListViewItem | Container, with a select action when it carries an action | |
Tabs | A bolder TextBlock with each label, followed by that panel | Adaptive Cards has no tabs, so every panel shows in order |
Steps | A bolder TextBlock per step, numbered, with a subtle detail TextBlock under it | An entry without a title is dropped with a warning |
Timeline | One Container per item with a small subtle time, a bolder title, and the detail | An entry without a title is dropped with a warning |
Citation | Container with the source or the link's domain, the title, and a subtle snippet | An http or https url makes the title a markdown link; a link written inside the snippet or source stays plain text |
Diagram | A Container with one monospace TextBlock per line of the Mermaid source, then a small subtle title | Teams cannot draw the diagram; a TextBlock breaks lines only in lists, so each line gets its own; a source over 3,000 characters or 200 lines is clamped with a warning |
Map | One Container per place with the label linked to OpenStreetMap and the detail | Teams cannot draw a map, so route is dropped silently; a place without valid coordinates is dropped with a warning |
Button | Action.Submit inside an ActionSet | Consecutive buttons merge into one set; buttonStyle is dropped, since Teams ignores action styling |
Select | Input.ChoiceSet, compact | An option without a string value is dropped with a warning |
RadioGroup | Input.ChoiceSet, expanded | An option without a string value is dropped with a warning; descriptions append to the choice title |
CheckboxGroup | Input.ChoiceSet, expanded, with isMultiSelect | Its value is the checked values joined by commas, so a value that contains a comma cannot be told apart; descriptions append to the choice title |
Checkbox | Input.Toggle | Its value is the string "true" or "false"; the switch variant uses the same element |
Slider | Input.Number | min, max, defaultValue, and label map directly; submitted values are numbers |
Input | Input.Text | Password uses style: "password"; number stays text and submits a string without native numeric validation |
DatePicker | Input.Date, Input.Time, or both | Date mode keeps YYYY-MM-DD values and bounds. Time mode uses HH:mm; nonzero seconds or fractions in the value or maximum are dropped with a dropped warning. Datetime mode pairs a date and time input; instants display in their own offset, shown in the label as UTC or UTC±HH:mm, because the card does not know the viewer's time zone. The date input accepts date-only bounds directly or takes the date part of datetime bounds; the time input has none. Nonzero seconds or fractions in wall clock datetime values are dropped with a dropped warning. An unsupported inputType renders as a date input with a dropped warning. |
Form | Children inline, then a "Submit" ActionSet | Adaptive Cards has no form container; inputs on the card submit together |
Row | ColumnSet with one auto-width column per child | Beyond three columns you get a warning, but all columns are kept |
Col, Box | Container | The two become indistinguishable |
Divider | Nothing; sets separator on the next sibling | See Layout differences |
Spacer | Nothing; sets spacing on the next sibling | See Layout differences |
Chart | Replaced by a subtle note | Always warns |
Icon | Dropped | Silently |
Text's size collapse is narrower than it looks: sm, lg, xl, 2xl, and 3xl map to small, medium, large, extraLarge, and extraLarge, while md emits no size at all and renders at the card's default. So six tokens reach five rendered sizes, and only 2xl and 3xl become indistinguishable. Color splits rather than collapses: secondary, alpha-70, white, white-70, and white-50 all set isSubtle: true, while emphasis maps to nothing the converter emits and is dropped outright, so emphasized text renders identically to unstyled text.
Teams has no undo surface, so a Button with undoable fires immediately.
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; Divider's flush; and Button's block. Button's submit is dropped for a different reason: every input on an Adaptive Card submits together, so a submit button and a click button both convert to the same Action.Submit.
On a Card marked asForm, the confirm action 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 "Submit" action where the confirm action would go, ahead of any cancel action.
Layout differences
Two vocabulary components behave differently here than anywhere else, because Adaptive Cards models separation as a property of an element rather than as an element:
- A
Divideremits nothing and setsseparator: trueon the next element that does emit. - A
Spaceremits nothing and setsspacing: "large"on the next element that does emit.
A component that emits nothing, such as an Icon, does not consume a pending mark; it carries through to the next real element. A Divider or Spacer with nothing after it disappears entirely, without a warning. As a result, a trailing separator you would see on Slack is absent on Teams.
Two consolidations also apply, both run-based rather than global: consecutive Fact siblings merge into one FactSet, and consecutive Button siblings merge into one ActionSet. A different component between them breaks the run and starts a new set.
Inputs
Adaptive Cards merges every input's current value into one submit object keyed by the input's id, which has two consequences.
Ids come from name. Each control's id is its name prop, falling back to a per-type default ("select", "radiogroup", "checkboxgroup", "slider", "input", "datepicker"). Checkbox is the exception: it falls back to its label first and only then to "checkbox", so an unnamed checkbox keys the submit object by its visible label. Two controls that share a name on the same card would collide, so the converter renames the later one and warns. The key aui is reserved for the action envelope; a control named aui is renamed too. The aui:datetime: namespace is reserved for generated datetime inputs. A field name starting with that prefix is escaped with a leading _ and a warning; $field references use the same mapping.
Datetime pickers use aui:datetime:date:<encodeURIComponent(fieldId)> for the date input and aui:datetime:time:<encodeURIComponent(fieldId)>:<encodeURIComponent(previousValue)> for the time input. Each ID identifies the field and its role on its own. The time ID's last segment holds the original instant, including its offset and precision, or is empty for a wall clock or initially empty value. Submit actions carry no temporal field map, so adding companion actions does not duplicate that metadata. Keep these IDs intact when forwarding input values to decodeSubmitData.
There is no change event. Adaptive Cards has no way to dispatch when a control's value changes, so a standalone control carrying $action gets a companion "Submit" ActionSet appended, plus a fallback warning. Five such controls produce five separate submit buttons. The idiomatic shape is to put $action on a Form, a Card marked asForm, or a Card footer and leave the controls actionless, which yields one submit for the whole card.
Actions
Outbound
A node's $action is carried inside the submit payload's reserved aui key, so it never collides with input values:
{
"type": "Action.Submit",
"title": "Approve",
"data": { "aui": { "type": "approve_order", "payload": { "orderId": "48213" } } }
}Unlike Slack, the payload is not serialized to a string and carries no size cap of its own, so the converter never drops or truncates it the way an oversized Slack button value is dropped. Size is checked once, against the whole card, and that check only warns; whether an oversized card is then accepted is decided by Teams.
Inbound
A bot receives the merged submit object as activity.value. decodeSubmitData splits it back into the action your tree dispatched, with the card's input values under $input:
import { decodeSubmitData } from "@assistant-ui/generative-ui/teams";
const action = decodeSubmitData(context.activity.value);
// { type: "approve_order", orderId: "48213", $input: { quantity: "2" } }$input here is an object keyed by input id, which differs from Slack, where a single control's $input is a bare value. It is omitted when no inputs were submitted. A datetime picker appears under its field ID in $input: the decoder combines its date and time inputs into YYYY-MM-DDTHH:mm. If the picker started with an instant, the decoded value keeps its original offset spelling and precision. If only one half is submitted, or either half is "", the field value is "". A newly selected value from an empty picker has no offset because Teams supplies no viewer time zone. A { "$field": name } reference in the payload resolves to this canonical field value, or to its fallback when neither half was submitted. The function returns undefined for a payload without a well-formed aui envelope, malformed temporal IDs or values, conflicting metadata for one field, or temporal IDs targeting an ordinary submitted field, and never throws; type always comes from the envelope, and a $input key smuggled into the payload is stripped.
The converter emits Action.Submit. Teams also offers Action.Execute from schema 1.4, which lets a bot return a replacement card in the invoke response; reaching for that means constructing the action yourself.
Carousels
A carousel is an activity-level construct on Teams rather than a card-level one, so it only works through toTeamsAttachments:
const { attachments, attachmentLayout } = toTeamsAttachments(tree);
await context.sendActivity({ attachments, attachmentLayout });With a Carousel at the root you get one attachment per card child and attachmentLayout: "carousel", capped at ten cards. Anywhere other than the root, a carousel falls back to its cards rendered in sequence, with a fallback warning. Input ids are scoped per card, so two attachments may safely reuse the same name.
Limits
| Budget | Value | Behavior when exceeded |
|---|---|---|
| Carousel attachments | 10 | Truncated |
| Table | 100 rows, 20 columns | Truncated |
| Choice options | 100 | Truncated |
| Primary actions | 6 | Later actions move to secondary mode; none are dropped |
| Payload size | 80,000 serialized bytes, set below Teams' 100 KB bot message limit | Warned, never truncated, so you decide whether to split |
Traversal is bounded as well: 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.
Reference
The generated per-export reference, including every type in the subpath, is at Microsoft Teams. For the Slack equivalent of this page, see Generative UI on Slack.