Teams Features
Send mentions, adaptive cards, and suggested actions using TeamsActivity and TeamsActivityBuilder.
Overview
While CoreActivity handles basic Bot Service messaging, Microsoft Teams adds rich features — mentions, adaptive cards, channel metadata, quick-reply buttons — that bots frequently use.
TeamsActivity extends CoreActivity with strongly-typed Teams-specific properties. TeamsActivityBuilder provides a fluent API for constructing replies with mentions, cards, and suggested actions.
For the full specification, see specs/teams-activity.md.
Mentions
Mention a user in a reply by combining withText() (with <at>Name</at> markup) and addMention() (which creates the mention entity).
WARNING
addMention() does not modify the activity text — you must include the <at>Name</at> markup yourself. This is intentional: explicit is better than magic.
var sender = ctx.Activity.From!;
var reply = new TeamsActivityBuilder()
.WithConversationReference(ctx.Activity)
.WithText($"<at>{sender.Name}</at> said: {text}")
.AddMention(sender)
.Build();
await ctx.SendAsync(reply, ct);// npm: import from 'botas-core'
// Deno/JSR: import from '@botas/core'
import { TeamsActivityBuilder } from 'botas-core'
const sender = ctx.activity.from
const reply = new TeamsActivityBuilder()
.withConversationReference(ctx.activity)
.withText(`<at>${sender.name}</at> said: ${text}`)
.addMention(sender)
.build()
await ctx.send(reply)from botas import TeamsActivityBuilder
sender = ctx.activity.from_account
reply = (
TeamsActivityBuilder()
.with_conversation_reference(ctx.activity)
.with_text(f"<at>{sender.name}</at> said: {text}")
.add_mention(sender)
.build()
)
await ctx.send(reply)Adaptive Cards
Send rich interactive cards using addAdaptiveCardAttachment() (appends) or withAdaptiveCardAttachment() (replaces all attachments with one card).
Both methods accept a JSON string or a pre-parsed object (to avoid double serialization), and wrap it in an attachment with contentType: "application/vnd.microsoft.card.adaptive".
We recommend using FluentCards to build Adaptive Cards with a fluent, strongly-typed API instead of raw JSON. FluentCards is available for all three languages: NuGet FluentCards, npm fluent-cards, and PyPI fluent-cards.
using FluentCards;
var card = AdaptiveCardBuilder.Create()
.WithVersion(AdaptiveCardVersion.V1_5)
.AddTextBlock(tb => tb
.WithText("Hello from TeamsSample!")
.WithSize(TextSize.Large)
.WithWeight(TextWeight.Bolder))
.AddTextBlock(tb => tb
.WithText("Click the button below to trigger an invoke action.")
.WithWrap(true))
.AddInputText(it => it
.WithId("userInput")
.WithPlaceholder("Type something here..."))
.AddAction(a => a
.Execute()
.WithTitle("Submit")
.WithVerb("submitAction")
.WithData("{\"action\":\"submit\"}"))
.Build();
var reply = new TeamsActivityBuilder()
.WithConversationReference(ctx.Activity)
.WithAdaptiveCardAttachment(card.ToJsonElement())
.Build();
await ctx.SendAsync(reply, ct);import { AdaptiveCardBuilder, TextSize, TextWeight, toObject } from 'fluent-cards'
const card = AdaptiveCardBuilder.create()
.withVersion('1.5')
.addTextBlock(tb => tb
.withText('Hello from TeamsSample!')
.withSize(TextSize.Large)
.withWeight(TextWeight.Bolder))
.addTextBlock(tb => tb
.withText('Click the button below to trigger an invoke action.')
.withWrap(true))
.addInputText(it => it
.withId('userInput')
.withPlaceholder('Type something here...'))
.addAction(a => a
.execute()
.withTitle('Submit')
.withVerb('submitAction')
.withData({ action: 'submit' }))
.build()
const reply = new TeamsActivityBuilder()
.withConversationReference(ctx.activity)
.withAdaptiveCardAttachment(toObject(card))
.build()
await ctx.send(reply)from botas import TeamsActivityBuilder
from fluent_cards import AdaptiveCardBuilder, TextSize, TextWeight, to_dict
card = (
AdaptiveCardBuilder.create()
.with_version("1.5")
.add_text_block(
lambda tb: tb.with_text("Hello from TeamsSample!")
.with_size(TextSize.Large)
.with_weight(TextWeight.Bolder)
)
.add_text_block(
lambda tb: tb.with_text("Click the button below to trigger an invoke action.")
.with_wrap(True)
)
.add_input_text(lambda it: it.with_id("userInput").with_placeholder("Type something here..."))
.add_action(
lambda a: a.execute().with_title("Submit").with_verb("submitAction").with_data({"action": "submit"})
)
.build()
)
reply = (
TeamsActivityBuilder()
.with_conversation_reference(ctx.activity)
.with_adaptive_card_attachment(to_dict(card))
.build()
)
await ctx.send(reply)Invoke Handling (Action.Execute)
When a user clicks an Action.Execute button on an Adaptive Card, Teams sends an invoke activity with name: "adaptiveCard/action". The invoke payload contains the verb and data you specified when building the card.
Register an invoke handler to process the action and return a response card:
using FluentCards;
app.OnInvoke("adaptiveCard/action", async (ctx, ct) =>
{
var valueJson = ctx.Activity.Value?.ToString() ?? "{}";
var valueDoc = System.Text.Json.JsonDocument.Parse(valueJson);
var verb = valueDoc.RootElement.TryGetProperty("action", out var actionProp)
? actionProp.TryGetProperty("verb", out var verbProp) ? verbProp.GetString() ?? "unknown" : "unknown"
: "unknown";
var responseCard = AdaptiveCardBuilder.Create()
.WithVersion(AdaptiveCardVersion.V1_5)
.AddTextBlock(tb => tb
.WithText("✅ Action received!")
.WithSize(TextSize.Large)
.WithWeight(TextWeight.Bolder)
.WithColor(TextColor.Good))
.AddTextBlock(tb => tb
.WithText($"Verb: {verb}")
.WithWrap(true))
.Build();
return new InvokeResponse
{
Status = 200,
Body = new
{
statusCode = 200,
type = "application/vnd.microsoft.card.adaptive",
value = responseCard.ToJsonElement()
}
};
});import { AdaptiveCardBuilder, TextSize, TextWeight, TextColor, toJson, toObject } from 'fluent-cards'
app.onInvoke('adaptiveCard/action', async (ctx) => {
const value = ctx.activity.value as any
const verb = value?.action?.verb ?? 'unknown'
const card = AdaptiveCardBuilder.create()
.withVersion('1.5')
.addTextBlock(tb => tb
.withText('✅ Action received!')
.withSize(TextSize.Large)
.withWeight(TextWeight.Bolder)
.withColor(TextColor.Good))
.addTextBlock(tb => tb
.withText(`Verb: ${verb}`)
.withWrap(true))
.build()
return {
status: 200,
body: {
statusCode: 200,
type: 'application/vnd.microsoft.card.adaptive',
value: toObject(card)
}
}
})from botas import InvokeResponse
from fluent_cards import AdaptiveCardBuilder, TextSize, TextWeight, TextColor, to_dict
@app.on_invoke("adaptiveCard/action")
async def on_card_action(ctx):
value = ctx.activity.value or {}
action_info = value.get("action", {})
verb = action_info.get("verb", "unknown")
response_card = (
AdaptiveCardBuilder.create()
.with_version("1.5")
.add_text_block(
lambda tb: tb.with_text("✅ Action received!")
.with_size(TextSize.Large)
.with_weight(TextWeight.Bolder)
.with_color(TextColor.Good)
)
.add_text_block(lambda tb: tb.with_text(f"Verb: {verb}").with_wrap(True))
.build()
)
return InvokeResponse(
status=200,
body={
"statusCode": 200,
"type": "application/vnd.microsoft.card.adaptive",
"value": to_dict(response_card),
},
)Invoke flow
Card (Action.Execute with verb + data)
→ User clicks button in Teams
→ Teams sends invoke activity (name="adaptiveCard/action")
→ Bot invoke handler reads verb + data from activity.value.action
→ Handler returns an Adaptive Card responseAction.Submit Handling
Unlike Action.Execute (which sends an invoke activity), Action.Submit sends a message activity with activity.value set and activity.text empty. The bot detects this pattern inside its regular message handler.
For the full spec comparison, see Action.Submit vs Action.Execute.
Sending an Action.Submit Card
using FluentCards;
var card = AdaptiveCardBuilder.Create()
.WithVersion(AdaptiveCardVersion.V1_5)
.AddTextBlock(tb => tb
.WithText("Action.Submit Test Card")
.WithWeight(TextWeight.Bolder))
.AddTextBlock(tb => tb
.WithText("Click the button to send a message with value."))
.AddAction(a => a
.Submit()
.WithTitle("Send")
.WithData("{\"source\":\"e2e\",\"action\":\"submit\"}"))
.Build();
var reply = new TeamsActivityBuilder()
.WithConversationReference(ctx.Activity)
.WithAdaptiveCardAttachment(card.ToJsonElement())
.Build();
await ctx.SendAsync(reply, ct);import { AdaptiveCardBuilder, TextWeight, toObject } from 'fluent-cards'
const card = AdaptiveCardBuilder.create()
.withVersion('1.5')
.addTextBlock(tb => tb.withText('Action.Submit Test Card').withWeight(TextWeight.Bolder))
.addTextBlock(tb => tb.withText('Click the button to send a message with value.'))
.addAction(a => a.submit().withTitle('Send').withData({ source: 'e2e', action: 'submit' }))
.build()
await ctx.send({
type: 'message',
attachments: [{
contentType: 'application/vnd.microsoft.card.adaptive',
content: toObject(card)
}]
})from fluent_cards import AdaptiveCardBuilder, TextWeight, to_dict
from botas import TeamsActivityBuilder
card = (
AdaptiveCardBuilder.create()
.with_version("1.5")
.add_text_block(lambda tb: tb.with_text("Action.Submit Test Card").with_weight(TextWeight.Bolder))
.add_text_block(lambda tb: tb.with_text("Click the button to send a message with value."))
.add_action(lambda a: a.submit().with_title("Send").with_data({"source": "e2e", "action": "submit"}))
.build()
)
reply = (
TeamsActivityBuilder()
.with_conversation_reference(ctx.activity)
.with_adaptive_card_attachment(to_dict(card))
.build()
)
await ctx.send(reply)Handling the Submit
When the user clicks the button, Teams sends a message with value and no text. Detect this in your message handler:
app.On("message", async (ctx, ct) =>
{
if (ctx.Activity.Value is not null && string.IsNullOrWhiteSpace(ctx.Activity.Text))
{
var json = System.Text.Json.JsonSerializer.Serialize(ctx.Activity.Value);
await ctx.SendAsync($"Submit received: {json}", ct);
return;
}
// ... handle normal messages
});app.on('message', async (ctx) => {
if (ctx.activity.value && !ctx.activity.text) {
await ctx.send(`Submit received: ${JSON.stringify(ctx.activity.value)}`)
return
}
// ... handle normal messages
})@app.on("message")
async def on_message(ctx):
if ctx.activity.value is not None and not ctx.activity.text:
import json
await ctx.send(f"Submit received: {json.dumps(ctx.activity.value)}")
return
# ... handle normal messagesAction.Submit vs Action.Execute
- Action.Execute → invoke activity → requires
InvokeResponse(use for card updates) - Action.Submit → message activity with
value→ fire-and-forget (use for simple data collection)
Suggested Actions
Offer quick-reply buttons to the user with withSuggestedActions(). Each button is a CardAction with a type (typically "imBack"), a display title, and a value sent back when clicked.
var reply = new TeamsActivityBuilder()
.WithConversationReference(ctx.Activity)
.WithText("Pick an option:")
.WithSuggestedActions(new SuggestedActions
{
Actions =
[
new CardAction { Type = "imBack", Title = "Option A", Value = "a" },
new CardAction { Type = "imBack", Title = "Option B", Value = "b" },
]
})
.Build();
await ctx.SendAsync(reply, ct);const reply = new TeamsActivityBuilder()
.withConversationReference(ctx.activity)
.withText('Pick an option:')
.withSuggestedActions({
actions: [
{ type: 'imBack', title: 'Option A', value: 'a' },
{ type: 'imBack', title: 'Option B', value: 'b' },
]
})
.build()
await ctx.send(reply)from botas import TeamsActivityBuilder
from botas.suggested_actions import SuggestedActions, CardAction
reply = (
TeamsActivityBuilder()
.with_conversation_reference(ctx.activity)
.with_text("Pick an option:")
.with_suggested_actions(SuggestedActions(
actions=[
CardAction(type="imBack", title="Option A", value="a"),
CardAction(type="imBack", title="Option B", value="b"),
]
))
.build()
)
await ctx.send(reply)TeamsActivity — reading Teams data
Use TeamsActivity.fromActivity() to access Teams-specific metadata from an incoming activity:
var teamsActivity = TeamsActivity.FromActivity(ctx.Activity);
Console.WriteLine($"Tenant: {teamsActivity.ChannelData?.Tenant?.Id}");
Console.WriteLine($"Locale: {teamsActivity.Locale}");import { TeamsActivity } from 'botas-core'
const teamsActivity = TeamsActivity.fromActivity(ctx.activity)
console.log(`Tenant: ${teamsActivity.channelData?.tenant?.id}`)
console.log(`Locale: ${teamsActivity.locale}`)from botas import TeamsActivity
teams_activity = TeamsActivity.from_activity(ctx.activity)
print(f"Tenant: {teams_activity.channel_data.tenant.id}")
print(f"Locale: {teams_activity.locale}")TeamsChannelData
TeamsActivity.channelData (or channel_data in Python) provides typed access to Teams metadata:
| Property | Type | Description |
|---|---|---|
tenant | TenantInfo | Azure AD tenant (id) |
channel | ChannelInfo | Teams channel (id, name) |
team | TeamInfo | Teams team (id, name, aadGroupId) |
meeting | MeetingInfo | Meeting context (id) |
notification | NotificationInfo | Alert settings (alert) |
Unknown fields in channelData are preserved as extension data, so new Teams features won't break existing code.
Running the TeamsSample
Each language has a complete sample in the repository using FluentCards for Adaptive Card construction. The sample responds to three commands:
| Command | Response |
|---|---|
cards | Sends an Adaptive Card with an Action.Execute button |
submit | Sends an Adaptive Card with an Action.Submit button |
actions | Sends Suggested Actions (quick-reply buttons) |
| (anything else) | Echoes back with an @mention of the sender |
When you click the Submit button on the Adaptive Card, Teams sends an invoke activity. The bot processes the verb and data, then responds with a confirmation card — demonstrating the full card → invoke → response round-trip.
cd dotnet && dotnet run --project samples/03-teams-featurescd node && npx tsx samples/03-teams-features/index.tscd python/samples/03-teams-features && python main.pyTyping Indicators
Show the user that your bot is working on a reply. Typing activities are part of the core Bot Service protocol (not Teams-specific), but they are especially useful in Teams where users expect real-time feedback.
Sending a typing indicator
Use sendTyping() / SendTypingAsync() / send_typing() on TurnContext when processing takes a few seconds:
app.On("message", async (ctx, ct) =>
{
await ctx.SendTypingAsync(ct);
await Task.Delay(3000, ct); // simulate long-running work
await ctx.SendAsync("Processing complete!", ct);
});app.on('message', async (ctx) => {
await ctx.sendTyping()
await new Promise(resolve => setTimeout(resolve, 3000))
await ctx.send('Processing complete!')
})@app.on("message")
async def on_message(ctx):
await ctx.send_typing()
await asyncio.sleep(3) # simulate long-running work
await ctx.send("Processing complete!")TIP
- Reserve typing indicators for operations that genuinely take 1–3+ seconds.
- You can send typing multiple times in a single turn if the operation has distinct phases.
Next steps
- Full TeamsActivity spec — complete type definitions and design decisions
- Middleware — add RemoveMentionMiddleware to strip bot @mentions from incoming text
- Language guides — deeper coverage of each implementation