Skip to content

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 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

ComponentAdaptive Card outputFidelity
HeaderTextBlock with heading stylesize is ignored; the heading always emits large
TextTextBlockSix 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
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; delta appends as the browser's directional chip in parentheses
TableNative Table with the first row as headersColumn 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
CardContainer, with the title as a leading headingFooter 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
AlertContainer with a semantic style (accent, good, warning, attention)Title and description become two text blocks
CarouselMultiple attachments through toTeamsAttachmentsSee Carousels; a non-card child that would have rendered is dropped with a warning
ListViewOne Container per item, separated after the firstA non-item child that would have rendered is dropped with a warning, and never reserves an input id
ListViewItemContainer, with a select action when it carries an action
TabsA bolder TextBlock with each label, followed by that panelAdaptive Cards has no tabs, so every panel shows in order
StepsA bolder TextBlock per step, numbered, with a subtle detail TextBlock under itAn entry without a title is dropped with a warning
TimelineOne Container per item with a small subtle time, a bolder title, and the detailAn entry without a title is dropped with a warning
CitationContainer with the source or the link's domain, the title, and a subtle snippetAn http or https url makes the title a markdown link; a link written inside the snippet or source stays plain text
DiagramA Container with one monospace TextBlock per line of the Mermaid source, then a small subtle titleTeams 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
MapOne Container per place with the label linked to OpenStreetMap and the detailTeams cannot draw a map, so route is dropped silently; a place without valid coordinates is dropped with a warning
ButtonAction.Submit inside an ActionSetConsecutive buttons merge into one set; buttonStyle is dropped, since Teams ignores action styling
SelectInput.ChoiceSet, compactAn option without a string value is dropped with a warning
RadioGroupInput.ChoiceSet, expandedAn option without a string value is dropped with a warning; descriptions append to the choice title
CheckboxGroupInput.ChoiceSet, expanded, with isMultiSelectIts 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
CheckboxInput.ToggleIts value is the string "true" or "false"; the switch variant uses the same element
SliderInput.Numbermin, max, defaultValue, and label map directly; submitted values are numbers
InputInput.TextPassword uses style: "password"; number stays text and submits a string without native numeric validation
DatePickerInput.Date, Input.Time, or bothDate 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.
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

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 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. 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

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 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.