Generative UI on Microsoft Teams

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 $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/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/react-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,
    },
  ],
});

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.

Warnings

Conversion is total. Every downgrade is reported rather than thrown:

type TeamsConversionWarning = {
  code: "clamped" | "dropped" | "fallback";
  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 produced no output, and fallback means the node rendered through a simpler construct. Teams uses clamped for two advisory cases where nothing is actually removed: a Row beyond three columns, and buttons past the sixth being moved to secondary mode.

Component mapping

ComponentAdaptive Card outputFidelity
HeaderTextBlock with heading styleFaithful
TextTextBlockSix text sizes collapse to four, four weights to bold or not, and every non-default color to isSubtle
MarkdownTextBlock, passed through verbatimTeams renders a markdown subset; headings, tables, images, and blockquotes render literally
Caption, BadgeSmall subtle TextBlockThe two become identical output
ImageImageA numeric size is dropped with a warning; round is dropped silently
FactFactSetConsecutive facts merge into one set
TableNative Table with the first row as headersRequires schema 1.5, so it will not render on Teams mobile
CardContainer, with the title as a leading headingFooter buttons become an ActionSet beside the container, not inside it
AlertContainer with a semantic style (accent, good, warning, attention)Title and description become two text blocks
CarouselMultiple attachments through toTeamsAttachmentsSee Carousels
ListViewOne Container per item, separated after the first
ListViewItemContainer, with a select action when it carries an action
ButtonAction.Submit inside an ActionSetConsecutive buttons merge into one set; buttonStyle is dropped, since Teams ignores action styling
SelectInput.ChoiceSet, compact
RadioGroupInput.ChoiceSet, expanded
CheckboxInput.ToggleIts value is the string "true" or "false"
InputInput.Text
DatePickerInput.Datevalue, min, and max are kept only in YYYY-MM-DD form, otherwise dropped silently
FormChildren inline, then a "Submit" ActionSetAdaptive Cards has no form container; inputs on the card submit together
RowColumnSet with one auto-width column per childBeyond three columns you get a warning, but all columns are kept
Col, BoxContainerThe two become indistinguishable
DividerNothing; sets separator on the next siblingSee Layout differences
SpacerNothing; sets spacing on the next siblingSee Layout differences
ChartReplaced by a subtle noteAlways warns
IconDroppedSilently

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 Divider emits nothing and sets separator: true on the next element that does emit.
  • A Spacer emits nothing and sets spacing: "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. The practical consequence is that a trailing separator you would see on Slack is simply 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 worth knowing.

Ids come from name. Each control's id is its name prop, falling back to a per-type default. 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.

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 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 Teams' call, not the converter's.

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/react-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 the card had no inputs. The function returns undefined for a payload without a well-formed aui envelope 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

BudgetValueBehavior when exceeded
Carousel attachments10Truncated
Table100 rows, 20 columnsTruncated
Choice options100Truncated
Primary actions6Later actions move to secondary mode; none are dropped
Payload size80,000 serialized bytes, set below Teams' 100 KB bot message limitWarned, never truncated, so you decide whether to split

Traversal is bounded as well: 200 children per level, 5,000 nodes per call, and a depth ceiling of 64, 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.