Microsoft Teams
Deploy bots to Microsoft Teams with Adaptive Cards, mentions, and conversation threading.
Create a new bot
Scaffold a Next.js bot with the Microsoft Teams adapter using create-chat-sdk:
pnpm create chat-sdk@latest my-bot --adapter teams memory -yThe generated app uses the Memory state adapter for local development. To add the adapter to an existing app, start at Install.
Install
pnpm add @chat-adapter/teams @chat-adapter/state-memoryQuick start
The adapter auto-detects TEAMS_APP_ID, TEAMS_APP_PASSWORD, and TEAMS_APP_TENANT_ID from the environment.
For managed credentials and webhook verification, see Vercel Connect.
The memory state adapter keeps subscriptions and locks in process memory, which suits local development. Use Redis or PostgreSQL in production.
Then create the webhook route:
Platform setup
The Teams CLI handles AAD app registration, client secret generation, bot registration, and Teams channel setup in one command. If you use Vercel Connect, you don't need to copy the generated app ID and password into your environment.
Install the CLI
Create the app
For local development, use a tunnel such as devtunnel, or ngrok to expose your local server.
Credentials (CLIENT_ID, CLIENT_SECRET, TENANT_ID) are written to .env. Rename them to match the adapter:
Install in Teams
Get a direct install link:
Or download the app package for sideloading, then in Teams go to Apps > Manage your apps > Upload an app > Upload a custom app.
Verify
Check the registration, app health, manifest, and endpoint:
Authentication
Use Vercel Connect to manage the bot identity and tokens, or configure Microsoft app credentials directly with a client secret, federated workload identity, or a custom token factory.
Vercel Connect
Use Vercel Connect to resolve the bot's app ID and request short-lived tokens for Bot Framework and Microsoft Graph:
The helper resolves appId during initialization and supplies a scope-aware token callback. It also verifies Connect-forwarded webhooks with Vercel OIDC instead of Microsoft's native JWT. Enable trigger forwarding to /api/webhooks/teams and omit TEAMS_APP_ID and TEAMS_APP_PASSWORD when using the helper.
The callback requests separate tokens for https://api.botframework.com/.default and https://graph.microsoft.com/.default. Graph reads still require the appropriate installation and consent permissions; Connect uses the bot's home tenant for managed tokens.
An appId resolver runs once during successful initialization. Call await bot.initialize() before using adapter methods directly; Chat initializes automatically before handling webhooks. botUserId becomes available after initialization when using a resolver.
A custom webhookVerifier receives the incoming request and its raw body. Return a truthy value to accept, or return a falsy value or throw to reject with HTTP 401. Without a verifier, the adapter retains Microsoft's native JWT verification.
Create the connector and attach it to your project with the Vercel CLI. --trigger-path registers the Teams webhook route as the trigger destination, and vercel env pull downloads a development OIDC token for local runs:
Without --trigger-branch, Connect forwards webhooks to your production deployment. See Vercel Connect for the full setup.
Client secret
Provide appPassword, or set TEAMS_APP_PASSWORD, with the client secret generated during platform setup:
Federated (workload identity)
For environments with managed identities, such as AKS or GitHub Actions, use federated workload identity. federated.clientId maps to managedIdentityClientId in the Teams SDK:
Custom token factory
For runtimes without access to Azure IMDS, such as serverless platforms, provide your own token-minting logic. token maps to AppOptions.token in the Teams SDK:
A custom token factory takes precedence over configured credentials and client-secret environment variables, including CLIENT_SECRET.
Configuration
Prop
Type
appId is required, along with one authentication method (appPassword, federated, or token). If more than one is configured, token takes precedence over federated, which takes precedence over appPassword.
Environment variables
| Variable | Required | Description |
|---|---|---|
TEAMS_APP_ID | Yes, unless you pass appId or use Vercel Connect | Azure Bot App ID. |
TEAMS_APP_PASSWORD | With client secret auth, unless you pass appPassword | Azure Bot App Password (client secret). Ignored when federated or token is set. |
TEAMS_APP_TENANT_ID | For SingleTenant apps and message history | Azure AD Tenant ID. |
TEAMS_API_URL | No | Teams API base URL override, for example for GCC-High or sovereign clouds. |
Mentions
Incoming Teams mentions are supported. Outgoing @name text, including multi-word names, stays plain text and does not notify the user. The adapter does not resolve display names into user identities or generate mention entities. Teams requires matching mention text and entities for a real mention; <at> markup alone is not sufficient.
Message history
Fetching message history requires TEAMS_APP_TENANT_ID and the right permissions depending on the conversation type:
| Context | Permission | Type | Admin consent? |
|---|---|---|---|
| Channel | ChannelMessage.Read.Group | RSC | No |
| Group chat | ChatMessage.Read.Chat | RSC | No |
| DM | Chat.Read.All | Azure AD | Yes |
RSC permissions are set via the Teams CLI (no admin consent needed):
For DM message history, RSC is not sufficient. Add Chat.Read.All via the Azure CLI:
Receiving all messages
By default, Teams bots only receive messages when directly @-mentioned. The RSC permissions above also enable receiving all messages in channels and group chats as a side effect.
The adapter sets isBot for the adapter's own bot, senders with from.role: "bot", and Teams bot account IDs starting with 28:. isMe identifies only the adapter's own bot. The same values appear on message.author and on event.user for reactions, card actions, and dialog events, so handlers can exclude bot activity from human-only conversations.
Bot joins
onMemberJoinedChannel fires when a conversationUpdate activity adds this bot to a channel or group chat. adapter.botUserId is the Teams bot ID (28:<appId>), so the same bot-join guard works across adapters:
For team installations, channelId identifies the channel selected during installation, not the team ID. inviterId comes from the activity sender. Pass your platform's waitUntil to the webhook handler to track asynchronous welcome handlers.
This dispatches bot joins only. Personal installs, ordinary member additions, and removals do not emit this event; use onInstalled for install welcomes that should also cover personal scope. See Microsoft's conversation event documentation for the underlying payloads.
Installation lifecycle
bot.onInstalled fires for installationUpdate actions add and add-upgrade.
bot.onUninstalled fires for remove and remove-upgrade. Both cover personal,
group chat, and team installs and receive the action, the activity id, the
platform conversationId, the actor userId, tenantId, locale, and the raw
activity. These handlers are currently emitted only by Teams.
event.channelId is the Chat SDK destination for the installation: the personal
chat, the group chat, or the channel selected during a team install. It encodes
the conversation's Bot Framework service URL, so you can persist the string and
post to it later from any process with bot.channel(channelId).post(). When the
activity body carries no service URL, the adapter uses the one from the validated
request token, so channelId is normally present.
Keep the installation key separate from the posting destination. For team
installs, use event.raw.channelData.team.id: the add event can identify the
selected channel while removal identifies the team/root conversation. Personal
and group chat installs can use conversationId. Include the bot ID, tenant ID,
and scope in the key so unrelated installations stay separate.
A team or group chat install produces both a conversationUpdate that adds the
bot and an installationUpdate, so onMemberJoinedChannel and onInstalled
both fire for the same conversation. Send install welcomes from one of them. If
you use onMemberJoinedChannel for Slack, skip Teams there by checking
event.adapter.name. Personal installs emit onInstalled only.
add-upgrade means an app upgrade added the bot to the manifest;
remove-upgrade means it removed the bot. Handle them as installation and
removal, respectively, including persistence and cleanup. Routine upgrades that
leave the bot in place do not emit installationUpdate.
Delivery may be retried, so make side effects idempotent, for example keyed on
the installation key and activity ID. The SDK does not subscribe, persist, or
deduplicate installations for you.
Do not send a farewell after removal. Pass your host's waitUntil to the webhook
handler so persistence and onboarding finish after the response.
If you already own routing and only need the connector call, decode the stored
channelId with decodeThreadId and pass its conversationId and serviceUrl
to postTeamsMessage from @chat-adapter/teams/api. Do not pass the
installation activity ID as replyToId; it is not a message. Full team and
selected-channel metadata remain in event.raw.
See Microsoft's installation update documentation.
Starting channel threads
A channel post starts a new Teams thread. Use the returned threadId to post follow-ups to that thread:
Both text and Adaptive Card posts return the created thread's ID. Group chats and personal conversations retain their existing conversation IDs.
Targeted / ephemeral messages
Teams targeted messages are available in public preview. Use thread.postEphemeral() or channel.postEphemeral() to send a native Teams message that only the selected conversation member can see:
The result has usedFallback: false when Teams accepts the targeted message.
AI-generated content labels
Set aiGenerated: true on a message object to show Teams' "AI generated" label on the bot's message. The label works on new, edited, channel, and targeted messages:
The flag works with markdown, raw, ast, and card messages. A plain string, a bare card element, or a JSX <Card> is sent without the label, so static messages such as help text and errors stay unlabeled. To label a card, pass it as { card, aiGenerated: true }, where card is a CardElement. Convert a JSX card with toCardElement() from chat first.
To label a streamed response, wrap the stream in a StreamingPlan:
The label applies to native streaming in DMs, to the buffered message in group chats and channels, and to every edit after the placeholder when you configure post-and-edit fallback streaming. Passing a stream to thread.post() without a StreamingPlan posts it unlabeled.
An edit replaces the whole Teams activity, including its entities. Pass aiGenerated: true again on every edit, or the label is removed:
Messages posted through the built-in AI tools are labeled when the model sends a markdown or raw body. A plain-string body is sent unlabeled.
See Microsoft's AI content labels documentation.
User lookup
bot.getUser() looks up user profiles through the Microsoft Graph API. To enable it:
- Grant the
User.Read.Allapplication permission in your Azure AD app registration. - Grant admin consent for the permission.
The adapter caches each user's Azure AD object ID from incoming activities, so getUser only works for users who have previously interacted with the bot.
Live incoming message authors include email when the Teams conversation members API resolves the sender. This lookup uses the bot's access to the current conversation and does not require Microsoft Graph permissions. Lookup failures leave message.author.email undefined without preventing message delivery. Edited-message events and messages returned by fetchMessages are not hydrated with an email.
Incoming attachments
Incoming inline images and files are exposed through message.attachments with a lazy fetchData() method. The adapter authenticates connector-hosted inline attachments through the configured Teams bot client, while Teams file download cards use their direct download URL without the bot token.
Anonymous downloads require HTTPS URLs that resolve to public addresses; private and internal hosts are refused, including after redirects. Responses are limited to 25 MB and downloads time out after 30 seconds. Inline attachments served by the connector use bot authentication instead, including the Bot Framework Emulator's plain-HTTP loopback connector. To change the limits or route downloads through a proxy, override createFetchDataFn() in a subclass and call downloadAttachment from @chat-adapter/shared with your own options.
Low-level APIs
Use the low-level Teams subpaths when your app already owns routing, state, sessions, or workflow execution and only needs Teams-specific primitives.
| Subpath | Use for |
|---|---|
@chat-adapter/teams/webhook | Parse Bot Framework Activity JSON, classify common payloads, and extract continuation data |
@chat-adapter/teams/api | Fetch-based Bot Connector calls for messages, updates, deletes, typing, and conversations |
@chat-adapter/teams/graph | Fetch-based Microsoft Graph reads for chats, channels, channel messages, and replies |
@chat-adapter/teams/format | Teams HTML, mention, Markdown-ish, and emoji string helpers |
@chat-adapter/teams/cards | Runtime-free conversion from simple card objects and input requests to Adaptive Cards |
@chat-adapter/teams/modals | Runtime-free Task Module Adaptive Card helpers and submit parsing |
The webhook subpath parses Activities only. It does not verify Microsoft Bot Framework JWTs. Authenticate the request with the Microsoft Teams SDK request pipeline before handing the Activity to these helpers, or use createTeamsAdapter. The Connector helpers restrict serviceUrl to Microsoft-owned hosts, but that does not authenticate the Activity.
Webhooks
Teams sends Bot Framework Activity JSON. readTeamsWebhook reads the request body and classifies the Activity, but it intentionally does not perform JWT validation.
parseTeamsWebhookBody returns typed payloads:
| Kind | Teams surface |
|---|---|
message | Message activities |
message_reaction | Reaction activities |
card_action | Adaptive Card actions and Action.Submit message activities |
dialog_open | Task Module task/fetch invokes |
dialog_submit | Task Module task/submit invokes |
conversation_update | Conversation membership and install context updates |
installation_update | App installation updates |
unsupported | Valid Activities not normalized by this helper yet |
Message-like payloads include continuation, which contains provider-native reply context:
This is not a Chat SDK Thread. It is the durable Teams data you need to reply later with @chat-adapter/teams/api.
Bot Connector API
The API subpath calls the Bot Framework Connector REST API with fetch. It does not import @microsoft/teams.apps.
Use accessToken in credentials when your runtime already owns Microsoft token acquisition. A direct accessToken must be scoped for the API you call. The Bot Connector subpath (/api) needs a https://api.botframework.com/.default token, while the Graph subpath (/graph) needs a https://graph.microsoft.com/.default token. Passing the same token to both will fail against one of them. When you supply appId/appPassword instead, each subpath requests the correct scope for you.
Graph
The Graph subpath reads Teams history with explicit Graph IDs. Unlike TeamsAdapter, it does not use the adapter state cache to infer teamId, channelId, or chatId.
Graph reads require the same Microsoft Graph permissions as the full adapter. Channel and group-chat reads can use RSC permissions; DM reads require Azure AD application permissions such as Chat.Read.All.
Formatting
Teams renders message text as HTML. The format subpath provides small helpers for custom runtimes:
Use the full TeamsFormatConverter from @chat-adapter/teams when you need mdast conversion inside Chat SDK.
Cards
The cards subpath converts simple card objects into Adaptive Card JSON without importing the full chat JSX runtime.
Use the full Chat SDK card JSX when you want cross-platform rendering. Use @chat-adapter/teams/cards when you are building a Teams-only runtime and want Adaptive Card output directly.
Table children render as the Adaptive Card Table element with grid lines on by default. The optional align, widths, verticalAlign, gridLines and gridStyle fields tune the grid, a table with empty headers renders without a header row, and a table with no columns emits no element.
A TextInput child renders as an Adaptive Card Input.Text with the same mapping the modal converter uses. Teams merges the card's input values into the Action.Submit payload, and the adapter reports them as event.values on the action. An input is required unless optional is set, and Teams blocks the card's buttons until a required input is filled. Teams enforces that in its own client, so validate event.values in the handler too.
A button with actionType="modal" is the exception. It renders with associatedInputs: "none", so it opens its dialog without waiting for the card's inputs, and its action arrives without event.values.
Teams flattens the inputs into the same payload that carries the button's own actionId, value, and msteams keys, so an input with one of those IDs would overwrite the button's data. Posting a card with a TextInput that uses one of those IDs throws a ValidationError. Modal inputs can use actionId and value, because a dialog's submit data doesn't carry a button's keys.
Modals
Teams Task Modules are invoke-based dialogs backed by Adaptive Cards. The modals subpath builds those cards and parses submit data.
Import boundaries
The low-level Teams subpaths are designed to avoid the full runtime import graph:
- no
chatimport - no
@chat-adapter/sharedimport - no
@microsoft/teams.appsimport - no full adapter import
The package still installs the full Teams adapter dependencies. The subpaths keep your source and bundle imports clean, but they are not a package-size split.
Conversation routing
Incoming thread IDs preserve the Teams conversation type when the legacy ID-prefix heuristic would route it incorrectly. When Teams omits conversationType, the adapter falls back to conversation.isGroup and the activity's team context. This keeps correctly classified IDs stable while selecting the buffered fallback for group chats whose IDs begin with a:. Thread IDs created by older adapter versions remain supported.
Troubleshooting
Run teams app doctor <appId> to diagnose common issues with bot registration, AAD app health, manifest consistency, and endpoint reachability.
Feature support
Messaging
| Feature | Supported |
|---|---|
| Post message | |
| Message replies | |
| Edit message | |
| Delete message | |
| File uploads | |
| Streaming | Native (DMs) / Buffered |
| Scheduled messages |
Rich content
| Feature | Supported |
|---|---|
| Card format | Adaptive Cards |
| Buttons | |
| Link buttons | |
| Select menus | |
| Tables | Adaptive Card Table |
| Charts | |
| Fields | |
| Images in cards | |
| Modals |
Conversations
| Feature | Supported |
|---|---|
| Slash commands | |
| Mentions | |
| Add reactions | |
| Remove reactions | |
| Typing indicator | |
| Mark as read | |
| Message edit events | |
| Message delete events | |
| DMs | |
| Ephemeral messages | Targeted messages |
| User lookup | Cached |
| Parent subject | |
| Native client | |
| Custom API endpoint |
Message history
| Feature | Supported |
|---|---|
| Fetch messages | Requires Graph permissions |
| Fetch single message | |
| Fetch thread info | |
| Fetch channel messages | Requires Graph permissions |
| List threads | Requires Graph permissions |
| Fetch channel info | Requires Graph permissions |
| Post channel message |