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

Action.Execute vs Action.Submit in Teams Adaptive Cards

Action.Submit and Action.Execute look similar but behave very differently in Teams bots. Here's when to use each, with invoke handling and card refresh detail.

  • #microsoft-teams
  • #adaptive-cards
  • #bot-framework
  • #actions
  • #chatops

The first time I shipped an Adaptive Card to a Teams channel, I used Action.Submit because it was the example at the top of the docs and it worked on the first try. Six months later I was staring at a deploy-approval card that twelve people could click, and discovering — in production, in front of the on-call lead — that all twelve of them saw the same stale “Approve” button even after the deploy had already gone out. The card had no idea anything had happened. That bug is the whole reason this post exists. Action.Submit and Action.Execute look like siblings in the JSON, but they belong to two different eras of the Bot Framework, and picking the wrong one will quietly cost you a coherent multi-user experience.

The two actions are not the same generation

Action.Submit is the original. When a user clicks it, Teams gathers the card’s input fields plus whatever you put in data, and sends it back to your bot as an ordinary message activity. From the bot’s perspective it looks almost exactly like the user typed something. There is no built-in concept of “respond to this one click” — you get a message, you process it, and if you want to change anything you have to proactively send a new message or update the original activity by ID.

Action.Execute is part of the Universal Action Model, the newer, host-agnostic design that also powers Outlook Actionable Messages and other Adaptive Card hosts. When a user clicks an Action.Execute, Teams does not send a message. It sends an invoke activity of type adaptiveCard/action, and — critically — it waits synchronously for your bot to answer with an InvokeResponse. That response is the reply to the click. You can hand back a brand-new card, a per-user updated card, or an error, and Teams renders it in place.

That synchronous, per-click contract is the entire difference. Submit fires and forgets. Execute asks a question and expects an answer.

What Action.Submit actually sends

Here is a minimal Submit card. Notice there is no verb — Submit identifies itself purely through whatever you stuff into data.

{
  "type": "AdaptiveCard",
  "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
  "version": "1.4",
  "body": [
    { "type": "TextBlock", "text": "Approve deploy of api-gateway v2.9.1?", "wrap": true }
  ],
  "actions": [
    {
      "type": "Action.Submit",
      "title": "Approve",
      "data": { "action": "approve", "release": "api-gateway-v2.9.1" }
    }
  ]
}

When clicked, your onMessage handler receives context.activity.value containing { "action": "approve", "release": "api-gateway-v2.9.1" }. To change the card after the fact you must call updateActivity() with the original activity ID, and that update is global — every viewer sees the same replacement. There is no native path for “show Alice the approved state but leave Bob’s card interactive.” For a single-user DM flow that’s fine. For a shared channel it is a footgun.

What Action.Execute sends — and how you answer it

Execute uses two fields you should treat as the contract: verb (a string you route on, the way you’d route an HTTP path) and data (the payload). Here’s the same approval as an Execute card, plus a refresh block I’ll explain in a moment.

{
  "type": "AdaptiveCard",
  "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
  "version": "1.4",
  "refresh": {
    "action": {
      "type": "Action.Execute",
      "verb": "approvalRefresh",
      "data": { "release": "api-gateway-v2.9.1" }
    },
    "userIds": ["29:1abcUserAad...", "29:1defLeadAad..."]
  },
  "body": [
    { "type": "TextBlock", "text": "Approve deploy of api-gateway v2.9.1?", "wrap": true }
  ],
  "actions": [
    {
      "type": "Action.Execute",
      "title": "Approve",
      "verb": "approveDeploy",
      "data": { "release": "api-gateway-v2.9.1" }
    }
  ]
}

When the button is clicked, Teams shows an inline loading spinner on the action while it waits for your bot. That spinner is free UX you don’t get with Submit, and it’s also a reason to keep your handler fast — the user is literally watching it spin. Your bot receives an invoke activity (context.activity.name === "adaptiveCard/action"), with the verb and data under context.activity.value.action.

import { TeamsActivityHandler, InvokeResponse, TurnContext } from "botbuilder";

export class DeployBot extends TeamsActivityHandler {
  async onInvokeActivity(context: TurnContext): Promise<InvokeResponse> {
    if (context.activity.name !== "adaptiveCard/action") {
      return super.onInvokeActivity(context);
    }

    const action = context.activity.value?.action ?? {};
    const verb: string = action.verb;
    const data = action.data ?? {};
    const userAadId = context.activity.from?.aadObjectId;

    try {
      if (verb === "approveDeploy") {
        const approvedCard = buildApprovedCard(data.release, userAadId);
        return {
          status: 200,
          body: {
            statusCode: 200,
            type: "application/vnd.microsoft.card.adaptive",
            value: approvedCard, // fresh card rendered in place for this user
          },
        };
      }

      if (verb === "approvalRefresh") {
        // Auto-refresh path: return the current state per viewer.
        const currentCard = buildCurrentStateCard(data.release, userAadId);
        return {
          status: 200,
          body: {
            statusCode: 200,
            type: "application/vnd.microsoft.card.adaptive",
            value: currentCard,
          },
        };
      }
    } catch (err) {
      // Surface a clean error card instead of a silent failure.
      return {
        status: 200,
        body: {
          statusCode: 400,
          type: "application/vnd.microsoft.error",
          value: { code: "BadRequest", message: "Could not process this action." },
        },
      };
    }

    return { status: 200, body: { statusCode: 200, type: "application/vnd.microsoft.activity.message", value: "OK" } };
  }
}

The body shape is the part people get wrong. You return HTTP status: 200 on the outer InvokeResponse almost always — even for logical errors — and signal the real outcome through the inner statusCode and type. Use application/vnd.microsoft.card.adaptive to return a new card, and application/vnd.microsoft.error for an error card. The returned card replaces what that specific user sees, which is exactly the per-user behavior Submit can’t give you.

Pro Tip: route on verb, never on the contents of data. Treat verb as a stable command name and data as arguments. It keeps your invoke handler readable and lets you add new actions without rewriting your dispatch logic.

The refresh element and why you limit userIds

The refresh object is the auto-update mechanism. When a card with refresh lands in a chat, Teams will automatically fire that refresh Action.Execute — without anyone clicking — for the users listed in userIds, so their card re-renders with current state when they open the chat. This is how you make a stale approval card “heal” itself: the refresh verb runs, your handler returns the up-to-date card, and the viewer never sees the dead button.

But there are hard limits, and they exist for a reason. Auto-refresh only fires for users explicitly listed in userIds — leave the list empty and you get manual-refresh-only behavior. And the list is capped at roughly five user IDs. Teams enforces this to avoid a refresh storm: imagine a card in a 5,000-person channel auto-refreshing for everyone the moment it’s posted — that’s thousands of synchronous invokes hammering your bot. So you scope userIds to the people who actually matter for that card: the approver, the requester, the on-call lead. Everyone else still gets a working card; they just refresh it by interacting.

Pro Tip: populate userIds dynamically from the people involved in the workflow, not a hardcoded list. For an approval, that’s usually the requester plus the designated approvers — two or three IDs, comfortably under the cap, and exactly the humans who need the card to stay live.

Errors, spinners, and the failure modes

Because Execute is synchronous, a slow or crashing handler is visible: the spinner hangs and Teams eventually times out the invoke. Always wrap your handler in try/catch and return an explicit error type rather than letting an exception bubble into a generic failure. With Submit, by contrast, a failure is invisible to the user — the message went through, your processing died silently, and they’re left wondering whether the click registered. Execute’s synchronous contract is more work but far more honest.

One more practical note: Submit still has a place. For pure data collection where you don’t need to mutate the card per-user — a feedback form, a one-shot DM survey — Submit is simpler and perfectly correct. The decision rule I use: if more than one person can see and act on this card, or the card needs to reflect changing state, use Execute.

Where AI fits — and where it doesn’t

I now draft these cards and invoke handlers with an AI assistant, and it’s genuinely fast at it — point Claude or GitHub Copilot at “give me an Action.Execute approval card with a refresh block and a TypeScript invoke handler” and you get a solid first pass in seconds. Treat that output like a pull request from a quick junior engineer: it nails the boilerplate but it doesn’t know your tenant, your verbs, or your security posture. I review every line before it goes near a real tenant — especially the userIds logic and anything touching auth.

That review step is non-negotiable for two reasons. First, connector and webhook security: if your bot is fronted by an incoming webhook or a connector, validate signatures and verify the activity actually came from the Bot Framework channel, not a forged POST. An AI draft will happily skip that. Second, never hand the model real tenant credentials, bot secrets, or AAD object IDs — let it generate the shape with placeholders and wire the real values in yourself from your secret store. If you’re building this kind of approval flow as part of a larger ChatOps push, the patterns in our prompt packs and the reusable prompts library cover the review checklist, and the broader Microsoft Teams collection has the surrounding bot setup.

Conclusion

Action.Submit sends a message and forgets. Action.Execute asks your bot a synchronous question and renders whatever card you hand back — per user, with a loading spinner, with auto-refresh for a scoped set of userIds, and with real error responses. For anything multi-user or stateful, Execute is the right tool, and the Universal Action Model is worth the extra handler code. Let AI draft the JSON and the invoke handler to save the boring minutes; just keep a human between that draft and your tenant.

action-execute-vs-action-submit-in-teams-adaptive-cards

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.