Skip to content
CloudOps
Newsletter
All guides
AI for Microsoft Teams By James Joyner IV · · 9 min read

Adaptive Card Universal Actions for Stateful Teams Workflows

Universal actions let a card update itself for everyone after a button press. Here's how to use Action.Execute and refresh to build real approval and ack flows.

  • #microsoft-teams
  • #adaptive-cards
  • #universal-actions
  • #approvals
  • #bot-framework
  • #devops

The first generation of adaptive card buttons had an embarrassing limitation: when someone clicked “Approve,” the card didn’t change. For them maybe, but everyone else in the channel still saw the original “waiting for approval” state. Two people would approve the same deploy because neither saw the other’s click. Universal Actions fix this. With Action.Execute and the refresh model, a single button press can update the card for everyone who can see it — which is exactly what you need for approvals, acknowledgements, and any stateful DevOps flow in Teams.

Action.Submit vs Action.Execute

The old way was Action.Submit: it sends data back, but the response only reshapes the card for the person who clicked, and the routing differs across hosts. Action.Execute is the universal action. It always routes to your bot, carries a verb you define, and — critically — the card you return replaces the card in the channel for all members.

{
  "type": "AdaptiveCard",
  "version": "1.5",
  "body": [
    { "type": "TextBlock", "text": "Deploy checkout v2.4.1 to prod?", "weight": "Bolder" }
  ],
  "actions": [
    { "type": "Action.Execute", "title": "Approve", "verb": "approve", "data": { "deployId": "d-8812" } },
    { "type": "Action.Execute", "title": "Reject", "verb": "reject", "data": { "deployId": "d-8812" } }
  ]
}

Handle the verb in your bot

Universal actions land in the bot’s invoke handler as an adaptiveCard/action. You read the verb, mutate state, and return a fresh card:

async onInvokeActivity(context) {
  if (context.activity.name === "adaptiveCard/action") {
    const { verb, data } = context.activity.value.action;

    if (verb === "approve") {
      const result = await deploys.approve(data.deployId, context.activity.from.aadObjectId);
      return cardResponse(approvedCard(result));
    }
    if (verb === "reject") {
      const result = await deploys.reject(data.deployId, context.activity.from.aadObjectId);
      return cardResponse(rejectedCard(result));
    }
  }
}

function cardResponse(card) {
  return {
    status: 200,
    body: {
      statusCode: 200,
      type: "application/vnd.microsoft.card.adaptive",
      value: card,
    },
  };
}

The card you return replaces the original message for everyone. The “waiting” card becomes an “Approved by Jordan at 02:14” card in front of the whole channel. No more double-approvals.

The refresh model keeps cards live

Universal actions also support refresh — the card can auto-update when a user views it, so even someone who scrolls up to an old card sees current state. You declare a refresh verb and the user IDs allowed to trigger it:

{
  "refresh": {
    "action": {
      "type": "Action.Execute",
      "verb": "refreshDeploy",
      "data": { "deployId": "d-8812" }
    },
    "userIds": []
  }
}

An empty userIds means “refresh for everyone” — but be careful: that fans out an invoke to your bot per viewer, which at channel scale is a lot of calls. For high-traffic channels, populate userIds with just the approvers so only their clients refresh, and let everyone else see the state set by the last Action.Execute. This is the single most overlooked performance knob in adaptive cards.

Idempotency is mandatory here

Because multiple people can press buttons near-simultaneously and refreshes fire repeatedly, your handlers must be idempotent. Approving an already-approved deploy should be a no-op that returns the current state, not a second approval:

async approve(deployId, userId) {
  const d = await store.get(deployId);
  if (d.status !== "pending") return d;       // already decided — return as-is
  return store.update(deployId, { status: "approved", approver: userId, at: now() });
}

Without this, two clicks at the same millisecond create a race that double-deploys. The state machine, not the UI, is your source of truth.

Authorize the action, not just the click

A button being visible isn’t authorization. Anyone in the channel can press “Approve.” Check the clicker’s identity server-side against who’s allowed to approve:

if (verb === "approve" && !approvers.has(context.activity.from.aadObjectId)) {
  return cardResponse(deniedCard("You're not on the approvers list for prod deploys."));
}

context.activity.from.aadObjectId is trustworthy — it comes from the Teams platform, not the card payload — so it’s the right thing to gate on. Never trust an identity field you put in the card’s data; a crafted client could change it.

Graceful host fallback

Older clients may not support Action.Execute. The adaptive card spec lets you provide a fallback so those users get an Action.Submit path instead of a dead button. In practice most managed tenants are current, but if you support guests or mobile-heavy audiences, declaring the fallback costs nothing and saves a support ticket.

Where this fits

Universal actions are what make Teams a real workflow surface instead of a notification board. The pattern is consistent: define a verb, handle it in the bot’s invoke path, mutate an idempotent state machine, authorize against the trustworthy caller identity, and return a card that updates for the whole channel. Tune refresh.userIds so you don’t melt your bot at channel scale. For approval and acknowledgement card templates, see the prompt library, and find more adaptive card patterns in the Microsoft Teams category.

Adaptive card schema versions and invoke response formats evolve; validate Action.Execute and refresh behavior against current adaptive card docs before deploying approval flows.

Free download · 368-page PDF

Download the Free 500-Prompt DevOps AI Toolkit

500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.

  • 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
  • Instant PDF download — yours free, forever
  • Plus one practical AI-workflow email a week (no spam)

Single opt-in · unsubscribe anytime · no spam.