Teams App Dev
1. Teams App Architecture Overview
Microsoft Teams apps extend Teams with custom functionality through several surface areas:
App types:
| Type | Surface | Technology |
|---|---|---|
| Bot | Chat, channels, group chats | Bot Framework SDK + TeamsActivityHandler |
| Message extension | Compose box, message actions | Bot Framework search/action handlers |
| Tab (static) | Personal app, channel tab | React/HTML + Teams JS SDK v2 |
| Tab (configurable) | Channel/group chat tab | React/HTML + configuration page |
| Meeting extension | Pre/in/post meeting, side panel, stage | Tabs + bots + content bubble |
| Custom Engine Agent | Conversational AI agent | M365 Agents Toolkit + AI SDK |
| Agent 365 | Declarative agent | Agent 365 manifest + MCP tools |
| Connector | Channel notifications | Incoming/outgoing webhooks or O365 connectors |
Development stack:
- Language: TypeScript (recommended) or C#/.NET
- Bot runtime:
botbuilder+botbuilder-teamsnpm packages (Bot Framework SDK v4) - Frontend: React with
@microsoft/teams-jsSDK v2 - Auth: MSAL.js + Entra ID app registration (SSO via
getAuthToken) — single-tenant enforced - Tooling: M365 Agents Toolkit CLI (
m365agents) — replaces Teams Toolkit CLI - Hosting: Azure App Service, Azure Functions, or any HTTPS endpoint
- Local testing: Agents Playground (no bot registration required)
M365 Agents Toolkit vs legacy Teams Toolkit:
| Aspect | M365 Agents Toolkit (current) | Teams Toolkit (legacy) |
|---|---|---|
| CLI package | @microsoft/m365agentstoolkit-cli |
@microsoft/teamsapp-cli |
| CLI command | m365agents |
teamsapp |
| Config file | m365agents.yml |
teamsapp.yml |
| Manifest schema | v1.25 | v1.17 |
| Bot auth | Single-tenant with APP_TENANTID |
Multi-tenant |
| Local testing | Agents Playground (no registration) | Dev tunnel + sideload |
| Agent support | Custom Engine Agents, Agent 365 | N/A |
2. M365 Agents Toolkit CLI
M365 Agents Toolkit CLI (m365agents) manages the full app lifecycle. It replaces the legacy teamsapp CLI.
Install:
npm install -g @microsoft/m365agentstoolkit-cli
Core commands:
| Command | Description |
|---|---|
m365agents new |
Scaffold a new Teams app or agent project |
m365agents provision |
Create Azure resources defined in m365agents.yml |
m365agents deploy |
Deploy code to provisioned Azure resources |
m365agents preview --local |
Start local debug with Agents Playground |
m365agents preview --remote |
Preview deployed app in Teams |
m365agents validate |
Validate manifest against v1.25 schema |
m365agents package |
Build the app package (ZIP with manifest + icons) |
m365agents publish |
Publish to org app catalog |
m365agents env list |
List configured environments |
m365agents env add <name> |
Add a new environment |
Project structure (generated by m365agents new):
my-teams-app/
├── m365agents.yml # Lifecycle configuration (replaces teamsapp.yml)
├── m365agents.local.yml # Local debug overrides
├── env/
│ ├── .env.dev # Dev environment variables
│ └── .env.local # Local debug variables
├── appPackage/
│ ├── manifest.json # App manifest v1.25 (with {{VAR}} placeholders)
│ ├── color.png # 192x192 color icon
│ └── outline.png # 32x32 outline icon
├── src/
│ ├── index.ts # Entry point (bot adapter / Express server)
│ └── teamsBot.ts # TeamsActivityHandler implementation
├── infra/
│ └── azure.bicep # Azure resource definitions
└── package.json
m365agents.yml lifecycle hooks:
version: v1.6
provision:
- uses: teamsApp/create
with:
name: my-teams-app-${{TEAMSFX_ENV}}
writeToEnvironmentFile:
teamsAppId: TEAMS_APP_ID
- uses: botAadApp/create
with:
name: my-teams-app-bot-${{TEAMSFX_ENV}}
appTenantId: ${{APP_TENANTID}}
writeToEnvironmentFile:
botId: BOT_ID
botPassword: SECRET_BOT_PASSWORD
- uses: botFramework/create
with:
botId: ${{BOT_ID}}
name: my-teams-app-bot
messagingEndpoint: ${{BOT_ENDPOINT}}/api/messages
appTenantId: ${{APP_TENANTID}}
- uses: teamsApp/validateManifest
with:
manifestPath: ./appPackage/manifest.json
- uses: teamsApp/zipAppPackage
with:
manifestPath: ./appPackage/manifest.json
outputZipPath: ./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip
- uses: teamsApp/update
with:
appPackagePath: ./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip
deploy:
- uses: cli/runNpmCommand
with:
args: install
- uses: cli/runNpmCommand
with:
args: run build
Agents Playground
Agents Playground allows local testing of bots and agents without Azure Bot registration or Dev Tunnels.
# Start with Agents Playground (no registration needed)
m365agents preview --local
# The playground opens a browser-based chat interface at http://localhost:56150
# It simulates the Teams client environment locally
Key benefits:
- No Azure Bot Service registration required for local dev
- No Dev Tunnel or ngrok setup needed
- Simulates Teams channel data, conversation references, and invoke activities
- Supports Adaptive Card rendering and action testing
3. Adaptive Card Schema
Adaptive Cards are platform-agnostic UI snippets rendered natively in Teams. Teams supports schema version 1.6 on desktop/web and 1.5 on mobile.
Card structure:
{
"type": "AdaptiveCard",
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"version": "1.5",
"body": [],
"actions": []
}
Element Types
Containers:
| Type | Purpose | Key Properties |
|---|---|---|
Container |
Group elements vertically | items[], style, bleed, minHeight |
ColumnSet |
Side-by-side columns | columns[] (each has width, items[]) |
Column |
Single column in a ColumnSet | width ("auto"/"stretch"/weight), items[] |
FactSet |
Key-value pairs | facts[] (title, value) |
ImageSet |
Gallery of images | images[], imageSize |
Table |
Tabular data (v1.5+) | columns[], rows[], gridStyle |
ActionSet |
Inline action buttons | actions[] |
Elements:
| Type | Purpose | Key Properties |
|---|---|---|
TextBlock |
Display text | text, size, weight, color, wrap, style |
Image |
Display an image | url, size, style ("default"/"person"), altText |
RichTextBlock |
Formatted inline text | inlines[] (TextRun objects) |
Media |
Embedded media (v1.1+) | sources[], poster |
Icon |
Fluent icon (v1.6+) | name, size, color |
Input elements:
| Type | Purpose | Key Properties |
|---|---|---|
Input.Text |
Single/multi-line text | id, placeholder, isMultiline, maxLength, regex |
Input.Number |
Numeric input | id, min, max, placeholder |
Input.Date |
Date picker | id, min, max, placeholder |
Input.Time |
Time picker | id, min, max |
Input.Toggle |
On/off switch | id, title, valueOn, valueOff |
Input.ChoiceSet |
Dropdown or radio/checkbox | id, choices[], isMultiSelect, style |
Action types:
| Type | Purpose | Key Properties |
|---|---|---|
Action.OpenUrl |
Open a URL | url |
Action.Submit |
Submit input values | data (merged with input values) |
Action.Execute |
Universal action (Teams) | verb, data — Teams-preferred over Action.Submit |
Action.ShowCard |
Toggle an inline card | card |
Action.ToggleVisibility |
Show/hide elements | targetElements[] |
Teams-Specific: Action.Execute
Teams uses Action.Execute (Universal Actions) instead of Action.Submit for bot-powered cards. The bot receives an adaptiveCard/action invoke with the verb and data.
{
"type": "Action.Execute",
"title": "Approve",
"verb": "approveRequest",
"data": {
"requestId": "REQ-001"
}
}
Bot handler for Action.Execute:
async onAdaptiveCardInvoke(
context: TurnContext,
invokeValue: AdaptiveCardInvokeValue
): Promise<AdaptiveCardInvokeResponse> {
const verb = invokeValue.action.verb;
const data = invokeValue.action.data;
switch (verb) {
case "approveRequest":
await this.processApproval(data.requestId);
return {
statusCode: 200,
type: "application/vnd.microsoft.card.adaptive",
value: this.buildApprovedCard(data.requestId),
};
default:
return { statusCode: 200, type: "application/vnd.microsoft.activity.message", value: "Unknown action" };
}
}
Templating
Adaptive Card Templating separates data from layout using $data (data binding) and $when (conditional rendering).
Install:
npm install adaptivecards-templating
Template:
{
"type": "AdaptiveCard",
"version": "1.5",
"body": [
{
"type": "TextBlock",
"text": "Hello, ${name}!",
"size": "Large",
"weight": "Bolder"
},
{
"type": "Container",
"$data": "${items}",
"items": [
{
"type": "TextBlock",
"text": "${title} — ${status}",
"$when": "${status != 'hidden'}"
}
]
}
]
}
Render with data:
import { Template } from "adaptivecards-templating";
const template = new Template(cardPayload);
const card = template.expand({
$root: {
name: "Contoso",
items: [
{ title: "Task A", status: "active" },
{ title: "Task B", status: "hidden" },
{ title: "Task C", status: "done" },
],
},
});
Validation Rules
versionmust be"1.4"or"1.5"for broad Teams client support.- Every
Input.*element must have a uniqueidproperty. Action.Executemust include averbstring.- Card payload size limit: 28 KB (compressed).
- Image URLs must be HTTPS.
fallbackTextshould be set for accessibility when elements use newer schema features.
4. Message Extensions
Message extensions let users search external services, take actions on messages, and unfurl links — all from the Teams compose box or message context menu.
Search-Based Message Extension
Users type a query in the compose box and receive results from an external service.
Manifest fragment (composeExtensions):
{
"composeExtensions": [
{
"botId": "{{BOT_ID}}",
"commands": [
{
"id": "searchProducts",
"type": "query",
"title": "Search Products",
"description": "Find products by name or SKU",
"initialRun": true,
"parameters": [
{
"name": "query",
"title": "Search",
"description": "Product name or SKU",
"inputType": "text"
}
]
}
]
}
]
}
Handler (TeamsActivityHandler):
async handleTeamsMessagingExtensionQuery(
context: TurnContext,
query: MessagingExtensionQuery
): Promise<MessagingExtensionResponse> {
const searchText = query.parameters?.[0]?.value || "";
const products = await this.productService.search(searchText);
const attachments = products.map((product) => ({
contentType: "application/vnd.microsoft.card.adaptive",
content: {
type: "AdaptiveCard",
version: "1.5",
body: [
{ type: "TextBlock", text: product.name, weight: "Bolder", size: "Medium" },
{ type: "TextBlock", text: `SKU: ${product.sku}`, isSubtle: true },
{ type: "TextBlock", text: product.description, wrap: true },
],
},
preview: CardFactory.heroCard(product.name, product.description, [product.imageUrl]),
}));
return {
composeExtension: {
type: "result",
attachmentLayout: "list",
attachments,
},
};
}
Action-Based Message Extension
Users fill out a form (dialog) triggered from the compose box or a message context menu.
Manifest fragment:
{
"composeExtensions": [
{
"botId": "{{BOT_ID}}",
"commands": [
{
"id": "createTicket",
"type": "action",
"title": "Create Ticket",
"description": "Create a support ticket from this message",
"context": ["message", "compose"],
"fetchTask": true
}
]
}
]
}
Fetch task handler (returns the form via dialog.url.open() pattern):
async handleTeamsMessagingExtensionFetchTask(
context: TurnContext,
action: MessagingExtensionAction
): Promise<MessagingExtensionActionResponse> {
const messageText = action.messagePayload?.body?.content || "";
return {
task: {
type: "continue",
value: {
title: "Create Support Ticket",
width: "medium",
height: "medium",
card: CardFactory.adaptiveCard({
type: "AdaptiveCard",
version: "1.5",
body: [
{ type: "Input.Text", id: "title", label: "Title", placeholder: "Ticket title" },
{
type: "Input.Text",
id: "description",
label: "Description",
isMultiline: true,
value: messageText,
},
{
type: "Input.ChoiceSet",
id: "priority",
label: "Priority",
choices: [
{ title: "Low", value: "low" },
{ title: "Medium", value: "medium" },
{ title: "High", value: "high" },
],
value: "medium",
},
],
actions: [{ type: "Action.Submit", title: "Create" }],
}),
},
},
};
}
Submit handler:
async handleTeamsMessagingExtensionSubmitAction(
context: TurnContext,
action: MessagingExtensionAction
): Promise<MessagingExtensionActionResponse> {
const { title, description, priority } = action.data;
const ticket = await this.ticketService.create({ title, description, priority });
return {
composeExtension: {
type: "result",
attachmentLayout: "list",
attachments: [
CardFactory.adaptiveCard({
type: "AdaptiveCard",
version: "1.5",
body: [
{ type: "TextBlock", text: `Ticket Created: ${ticket.id}`, weight: "Bolder" },
{ type: "FactSet", facts: [
{ title: "Title", value: title },
{ title: "Priority", value: priority },
{ title: "Status", value: "Open" },
]},
],
}),
],
},
};
}
Link Unfurling
When a user pastes a URL matching a registered domain, Teams invokes the bot to provide a preview card.
Manifest fragment:
{
"composeExtensions": [
{
"botId": "{{BOT_ID}}",
"messageHandlers": [
{
"type": "link",
"value": {
"domains": ["contoso.com", "*.contoso.com"]
}
}
]
}
]
}
Handler:
async handleTeamsAppBasedLinkQuery(
context: TurnContext,
query: AppBasedLinkQuery
): Promise<MessagingExtensionResponse> {
const url = query.url;
const metadata = await this.fetchPageMetadata(url);
return {
composeExtension: {
type: "result",
attachmentLayout: "list",
attachments: [
{
contentType: "application/vnd.microsoft.card.thumbnail",
content: {
title: metadata.title,
text: metadata.description,
images: [{ url: metadata.imageUrl }],
buttons: [{ type: "openUrl", title: "View", value: url }],
},
preview: CardFactory.thumbnailCard(metadata.title, metadata.description, [metadata.imageUrl]),
},
],
},
};
}
5. Bot Framework for Teams
Teams bots extend the Bot Framework TeamsActivityHandler, which provides Teams-specific method overrides on top of the standard ActivityHandler.
TeamsActivityHandler Methods
| Method | Trigger | Use Case |
|---|---|---|
onMessage |
User sends a text message | General conversation, command routing |
onMembersAdded |
User/bot joins conversation | Welcome message |
onTeamsMembersAdded |
Member added to a team | Team-specific welcome |
onTeamsMembersRemoved |
Member removed from team | Cleanup, farewell |
onTeamsChannelCreated |
Channel created | Notify team about new channel |
onTeamsChannelDeleted |
Channel deleted | Audit logging |
onTeamsChannelRenamed |
Channel renamed | Update references |
onTeamsTeamRenamed |
Team renamed | Update references |
onTeamsTeamArchived |
Team archived | Status update |
onTeamsTeamUnarchived |
Team unarchived | Re-enable features |
handleTeamsMessagingExtensionQuery |
Search message extension | Return search results |
handleTeamsMessagingExtensionFetchTask |
Action extension form | Return dialog card |
handleTeamsMessagingExtensionSubmitAction |
Action extension submit | Process form data |
handleTeamsAppBasedLinkQuery |
Link unfurling | Return preview card |
onAdaptiveCardInvoke |
Action.Execute on card |
Process universal action |
handleTeamsTaskModuleFetch |
Dialog requested (legacy: task/fetch) | Return dialog content |
handleTeamsTaskModuleSubmit |
Dialog submitted (legacy: task/submit) | Process dialog data |
onInstallationUpdate |
App installed/uninstalled | Setup or teardown per-user state |
Bot Scaffold
import { TeamsActivityHandler, TurnContext, MessageFactory, CardFactory } from "botbuilder";
export class TeamsBot extends TeamsActivityHandler {
constructor() {
super();
this.onMessage(async (context: TurnContext, next) => {
const text = context.activity.text?.trim().toLowerCase() || "";
if (text === "help") {
await context.sendActivity(
MessageFactory.text("I can help with:\n- **search** — Find items\n- **status** — Check status")
);
} else {
await context.sendActivity(
MessageFactory.text(`You said: "${context.activity.text}"`)
);
}
await next();
});
this.onMembersAdded(async (context, next) => {
for (const member of context.activity.membersAdded || []) {
if (member.id !== context.activity.recipient.id) {
await context.sendActivity(
MessageFactory.text("Welcome! Type **help** to see what I can do.")
);
}
}
await next();
});
}
}
TurnContext Key Methods
| Method | Description |
|---|---|
context.sendActivity(activity) |
Send a reply to the user |
context.activity.text |
The user's message text |
context.activity.from |
Sender info (id, name, aadObjectId) |
context.activity.conversation |
Conversation info (id, tenantId, conversationType) |
context.activity.channelData |
Teams-specific data (team, channel, tenant) |
context.activity.value |
Data from card actions or dialogs |
Proactive Messaging
Send messages outside the normal request-response flow (e.g., notifications, scheduled updates).
import { ConversationReference, TurnContext } from "botbuilder";
// Store the conversation reference when user first interacts
const conversationReferences: Record<string, Partial<ConversationReference>> = {};
// In your message handler:
const ref = TurnContext.getConversationReference(context.activity);
conversationReferences[ref.conversation!.id] = ref;
// Later, send a proactive message:
async function sendProactiveMessage(adapter: BotFrameworkAdapter, ref: Partial<ConversationReference>, message: string) {
await adapter.continueConversation(ref, async (turnContext) => {
await turnContext.sendActivity(MessageFactory.text(message));
});
}
Conversation State
Bot Framework provides state management via storage providers.
import {
MemoryStorage,
ConversationState,
UserState,
StatePropertyAccessor,
} from "botbuilder";
const memoryStorage = new MemoryStorage();
const conversationState = new ConversationState(memoryStorage);
const userState = new UserState(memoryStorage);
// Create accessors
const dialogStateAccessor: StatePropertyAccessor = conversationState.createProperty("DialogState");
const userProfileAccessor: StatePropertyAccessor = userState.createProperty("UserProfile");
// In your bot, save state at end of turn:
this.onTurn(async (context, next) => {
await next();
await conversationState.saveChanges(context, false);
await userState.saveChanges(context, false);
});
Production storage: Replace MemoryStorage with BlobStorage (botbuilder-azure-blobs) or CosmosDbPartitionedStorage (botbuilder-azure) for persistence.
Dialogs (WaterfallDialog)
Multi-step conversation flows using the Dialogs library.
import { WaterfallDialog, WaterfallStepContext, TextPrompt, ChoicePrompt } from "botbuilder-dialogs";
const ORDER_DIALOG = "orderDialog";
const orderDialog = new WaterfallDialog(ORDER_DIALOG, [
async (step: WaterfallStepContext) => {
return step.prompt("textPrompt", "What product are you looking for?");
},
async (step: WaterfallStepContext) => {
step.values["product"] = step.result;
return step.prompt("choicePrompt", "Select quantity:", ["1", "5", "10"]);
},
async (step: WaterfallStepContext) => {
step.values["quantity"] = step.result.value;
await step.context.sendActivity(
`Order placed: ${step.values["quantity"]}x ${step.values["product"]}`
);
return step.endDialog();
},
]);
6. Meeting Apps
Meeting apps extend the Teams meeting experience with pre-meeting, in-meeting, and post-meeting surfaces.
Meeting App Surfaces
| Surface | When Available | Manifest Context | Technology |
|---|---|---|---|
| Pre-meeting tab | Before meeting starts | meetingDetailsTab |
Configurable tab |
| Side panel | During meeting | meetingSidePanel |
Configurable tab |
| Meeting stage | During meeting (shared) | meetingStage |
Configurable tab + shareAppContentToStage |
| Content bubble | During meeting | N/A | Bot notification with targetedMeetingNotification |
| Post-meeting tab | After meeting ends | meetingDetailsTab |
Configurable tab |
Meeting App Manifest (v1.25)
{
"$schema": "https://developer.microsoft.com/json-schemas/teams/v1.25/MicrosoftTeams.schema.json",
"manifestVersion": "1.25",
"configurableTabs": [
{
"configurationUrl": "https://{{TAB_DOMAIN}}/config",
"canUpdateConfiguration": true,
"scopes": ["groupChat"],
"context": [
"meetingChatTab",
"meetingDetailsTab",
"meetingSidePanel",
"meetingStage"
],
"supportsChannelFeatures": true
}
],
"authorization": {
"permissions": {
"resourceSpecific": [
{ "name": "OnlineMeeting.ReadBasic.Chat", "type": "Delegated" },
{ "name": "OnlineMeetingParticipant.Read.Chat", "type": "Delegated" },
{ "name": "MeetingStage.Write.Chat", "type": "Delegated" }
]
}
}
}
Share to Meeting Stage
import { meeting, app } from "@microsoft/teams-js";
// From side panel, share content to the meeting stage
async function shareToStage() {
await app.initialize();
meeting.shareAppContentToStage(
(err, result) => {
if (err) {
console.error("Failed to share to stage:", err);
return;
}
console.log("Shared to stage successfully");
},
`${window.location.origin}/stage?meetingId=${meetingId}`
);
}
Meeting Side Panel
import { app, meeting } from "@microsoft/teams-js";
async function initSidePanel() {
await app.initialize();
const context = await app.getContext();
if (context.page.frameContext === "sidePanel") {
// Side panel specific logic
const meetingId = context.meeting?.id;
// Get meeting details
meeting.getMeetingDetails((err, details) => {
if (details) {
console.log("Organizer:", details.organizer?.id);
console.log("Scheduled start:", details.details?.scheduledStartTime);
}
});
}
}
Content Bubble (In-Meeting Notification)
Bots can send targeted meeting notifications that appear as content bubbles during a meeting.
// Bot sends a targeted meeting notification
async function sendContentBubble(context: TurnContext, meetingId: string) {
const card = CardFactory.adaptiveCard({
type: "AdaptiveCard",
version: "1.5",
body: [
{ type: "TextBlock", text: "Action Required", weight: "Bolder" },
{ type: "TextBlock", text: "Please vote on the current agenda item", wrap: true },
],
actions: [
{ type: "Action.Execute", title: "Vote Yes", verb: "vote", data: { vote: "yes" } },
{ type: "Action.Execute", title: "Vote No", verb: "vote", data: { vote: "no" } },
],
});
// Targeted notification surfaces as a content bubble during the meeting
await context.sendActivity({
type: "message",
attachments: [card],
channelData: {
notification: {
alertInMeeting: true,
externalResourceUrl: `https://${process.env.TAB_DOMAIN}/stage?action=vote`,
},
},
});
}
Meeting Message Extensions
Message extensions work within meeting chats. When a meeting is active, extensions can:
- Search and share content relevant to the meeting agenda
- Create action items from the meeting chat context
- Unfurl links shared during the meeting with rich previews
Meeting-aware search extension:
async handleTeamsMessagingExtensionQuery(
context: TurnContext,
query: MessagingExtensionQuery
): Promise<MessagingExtensionResponse> {
const searchText = query.parameters?.[0]?.value || "";
// Detect meeting context from channel data
const meetingId = context.activity.channelData?.meeting?.id;
const isMeetingChat = !!meetingId;
let results;
if (isMeetingChat) {
// Fetch meeting-specific agenda items or related content
results = await this.searchMeetingAgendaItems(meetingId, searchText);
} else {
results = await this.searchAllItems(searchText);
}
const attachments = results.map((item) => ({
contentType: "application/vnd.microsoft.card.adaptive",
content: {
type: "AdaptiveCard",
version: "1.5",
body: [
{ type: "TextBlock", text: item.title, weight: "Bolder", size: "Medium" },
{ type: "TextBlock", text: item.description, wrap: true },
...(isMeetingChat ? [
{ type: "TextBlock", text: `📋 Agenda: ${item.agendaItem}`, isSubtle: true }
] : []),
],
},
preview: CardFactory.heroCard(item.title, item.description),
}));
return {
composeExtension: {
type: "result",
attachmentLayout: "list",
attachments,
},
};
}
Meeting action extension — create action item from meeting chat:
async handleTeamsMessagingExtensionFetchTask(
context: TurnContext,
action: MessagingExtensionAction
): Promise<MessagingExtensionActionResponse> {
const meetingId = context.activity.channelData?.meeting?.id;
const messageText = action.messagePayload?.body?.content || "";
return {
task: {
type: "continue",
value: {
title: "Create Meeting Action Item",
width: "medium",
height: "medium",
card: CardFactory.adaptiveCard({
type: "AdaptiveCard",
version: "1.5",
body: [
{ type: "TextBlock", text: "Create Action Item", size: "Large", weight: "Bolder" },
{ type: "Input.Text", id: "title", label: "Action Item", value: messageText, isRequired: true },
{ type: "Input.Text", id: "assignee", label: "Assign To", placeholder: "user@contoso.com" },
{ type: "Input.Date", id: "dueDate", label: "Due Date" },
{
type: "Input.ChoiceSet",
id: "priority",
label: "Priority",
choices: [
{ title: "High", value: "high" },
{ title: "Medium", value: "medium" },
{ title: "Low", value: "low" },
],
value: "medium",
},
],
actions: [{ type: "Action.Submit", title: "Create", data: { meetingId } }],
}),
},
},
};
}
async handleTeamsMessagingExtensionSubmitAction(
context: TurnContext,
action: MessagingExtensionAction
): Promise<MessagingExtensionActionResponse> {
const { title, assignee, dueDate, priority, meetingId } = action.data;
const actionItem = await this.createActionItem({ title, assignee, dueDate, priority, meetingId });
return {
composeExtension: {
type: "result",
attachmentLayout: "list",
attachments: [
CardFactory.adaptiveCard({
type: "AdaptiveCard",
version: "1.5",
body: [
{ type: "TextBlock", text: `Action Item Created`, weight: "Bolder" },
{ type: "FactSet", facts: [
{ title: "Title", value: title },
{ title: "Assigned To", value: assignee || "Unassigned" },
{ title: "Due", value: dueDate || "No date" },
{ title: "Priority", value: priority },
{ title: "Meeting", value: meetingId ? "Linked" : "None" },
]},
],
}),
],
},
};
}
7. Dialog Namespace (Replaces Task Modules)
The dialog namespace replaces the deprecated tasks namespace. Use dialog.url.open() and dialog.adaptiveCard.open() instead of tasks.startTask().
Opening a Dialog from a Tab
import { dialog, app } from "@microsoft/teams-js";
// Open a dialog with an Adaptive Card
async function openAdaptiveCardDialog() {
await app.initialize();
dialog.adaptiveCard.open({
card: JSON.stringify({
type: "AdaptiveCard",
version: "1.5",
body: [
{ type: "TextBlock", text: "Submit Feedback", size: "Large", weight: "Bolder" },
{ type: "Input.Text", id: "feedback", label: "Your feedback", isMultiline: true, isRequired: true },
{ type: "Input.ChoiceSet", id: "rating", label: "Rating", choices: [
{ title: "Excellent", value: "5" },
{ title: "Good", value: "4" },
{ title: "Average", value: "3" },
{ title: "Poor", value: "2" },
]},
],
actions: [{ type: "Action.Submit", title: "Submit" }],
}),
title: "Feedback",
size: { width: "medium", height: "medium" },
}, (result) => {
if (result.err) {
console.error("Dialog error:", result.err);
return;
}
const data = JSON.parse(result.result as string);
console.log("Feedback:", data.feedback, "Rating:", data.rating);
});
}
// Open a dialog with a URL (iframe)
async function openUrlDialog() {
await app.initialize();
dialog.url.open({
url: `${window.location.origin}/dialog/form`,
title: "Create Item",
size: { width: "large", height: "large" },
fallbackUrl: `${window.location.origin}/dialog/form`,
}, (result) => {
if (result.err) {
console.error("Dialog error:", result.err);
return;
}
console.log("Dialog result:", result.result);
});
}
Submitting from Inside a Dialog
import { dialog, app } from "@microsoft/teams-js";
// Call from within the dialog iframe to close and return data
async function submitDialog() {
await app.initialize();
const formData = {
title: document.getElementById("title")?.value,
description: document.getElementById("description")?.value,
};
dialog.url.submit(JSON.stringify(formData));
}
Bot-Side Dialog Handlers
// These handlers respond to task/fetch and task/submit invokes
// The method names are legacy but the client uses the dialog namespace
protected async handleTeamsTaskModuleFetch(
context: TurnContext,
taskModuleRequest: { data: Record<string, string> }
) {
return {
task: {
type: "continue",
value: {
title: "Create Item",
height: 450,
width: 500,
card: CardFactory.adaptiveCard({
type: "AdaptiveCard",
version: "1.5",
body: [
{ type: "Input.Text", id: "title", label: "Title", isRequired: true },
{ type: "Input.Text", id: "notes", label: "Notes", isMultiline: true },
],
actions: [{ type: "Action.Submit", title: "Create" }],
}),
},
},
};
}
protected async handleTeamsTaskModuleSubmit(
context: TurnContext,
taskModuleRequest: { data: Record<string, string> }
) {
const { title, notes } = taskModuleRequest.data;
await this.createItem(title, notes);
return { task: { type: "message", value: `Created: ${title}` } };
}
8. Tab Apps
Tabs embed web content as personal apps or channel tabs inside Teams.
Tab Types
| Type | Scope | Configuration |
|---|---|---|
| Static (personal) tab | Personal app bar | No config page needed; define contentUrl in manifest |
| Configurable tab | Channel or group chat | Requires a configuration page (configurationUrl) |
Teams JavaScript SDK v2
npm install @microsoft/teams-js
Initialize:
import { app, authentication } from "@microsoft/teams-js";
async function initializeApp() {
await app.initialize();
const context = await app.getContext();
console.log("Theme:", context.app.theme);
console.log("Locale:", context.app.locale);
console.log("User ID:", context.user?.id);
console.log("Team ID:", context.team?.internalId);
console.log("Channel ID:", context.channel?.id);
console.log("Host:", context.app.host.name); // "Teams" | "Outlook" | "Office"
}
SSO Authentication Pattern — Nested App Authentication (NAA)
Manifest v1.25 introduces Nested App Authentication (NAA) for pop-up-free iframe authentication.
Manifest v1.25 nestedAppAuthInfo:
{
"nestedAppAuthInfo": {
"oidcScopes": ["openid", "profile", "email", "offline_access"],
"accessTokenAcceptedVersion": 2
},
"webApplicationInfo": {
"id": "{{AZURE_CLIENT_ID}}",
"resource": "api://{{TAB_DOMAIN}}/{{AZURE_CLIENT_ID}}"
}
}
Client-side with NAA (no popup required):
import { authentication, app } from "@microsoft/teams-js";
async function getTokenWithNAA(): Promise<string> {
await app.initialize();
// NAA enables silent token acquisition without pop-up
// The token is acquired through the parent app (Teams) acting as a broker
const token = await authentication.getAuthToken({
silent: true,
});
return token;
}
Traditional SSO (fallback):
import { authentication } from "@microsoft/teams-js";
async function getToken(): Promise<string> {
const token = await authentication.getAuthToken();
// This is an ID token scoped to your app's client ID.
// Exchange it server-side for a Graph token using OBO flow.
return token;
}
Server-side (On-Behalf-Of flow):
import { ConfidentialClientApplication } from "@azure/msal-node";
const msalClient = new ConfidentialClientApplication({
auth: {
clientId: process.env.AZURE_CLIENT_ID!,
clientSecret: process.env.AZURE_CLIENT_SECRET!,
// Single-tenant authority — APP_TENANTID required in v1.25
authority: `https://login.microsoftonline.com/${process.env.APP_TENANTID}`,
},
});
async function exchangeToken(ssoToken: string): Promise<string> {
const result = await msalClient.acquireTokenOnBehalfOf({
oboAssertion: ssoToken,
scopes: ["https://graph.microsoft.com/.default"],
});
return result!.accessToken;
}
React Tab Starter
import React, { useEffect, useState } from "react";
import { app } from "@microsoft/teams-js";
import { FluentProvider, teamsLightTheme, teamsDarkTheme, teamsHighContrastTheme, Text } from "@fluentui/react-components";
const themeMap = {
default: teamsLightTheme,
dark: teamsDarkTheme,
contrast: teamsHighContrastTheme,
};
export function Tab() {
const [theme, setTheme] = useState(teamsLightTheme);
const [userName, setUserName] = useState("");
useEffect(() => {
(async () => {
await app.initialize();
const ctx = await app.getContext();
setTheme(themeMap[ctx.app.theme as keyof typeof themeMap] || teamsLightTheme);
setUserName(ctx.user?.userPrincipalName || "Unknown");
app.registerOnThemeChangeHandler((newTheme) => {
setTheme(themeMap[newTheme as keyof typeof themeMap] || teamsLightTheme);
});
})();
}, []);
return (
<FluentProvider theme={theme}>
<Text size={500} weight="bold">Hello, {userName}!</Text>
</FluentProvider>
);
}
9. Teams App Manifest v1.25
The manifest (manifest.json) is the app's contract with Teams. Manifest v1.25 is the current schema version.
What Changed from v1.17 to v1.25
| Feature | v1.17 | v1.25 |
|---|---|---|
| Schema URL | v1.17/MicrosoftTeams.schema.json |
v1.25/MicrosoftTeams.schema.json |
manifestVersion |
"1.17" |
"1.25" |
| Bot tenancy | Multi-tenant | Single-tenant with APP_TENANTID |
supportsChannelFeatures |
N/A | New boolean on configurable tabs |
nestedAppAuthInfo |
N/A | NAA for pop-up-free SSO |
backgroundLoadConfiguration |
N/A | Background tab preloading |
agenticUserTemplates |
N/A | Custom Engine Agent prompt templates |
| Tooling | teamsapp CLI |
m365agents CLI |
| Config file | teamsapp.yml |
m365agents.yml |
Known v1.25 Bugs
- Regex validation: The Dev Portal schema validator may reject valid regex patterns in
Input.Text.regex. Workaround: validate locally withm365agents validate. - Dev Portal save issue: Editing manifests directly in the Teams Developer Portal may silently drop
nestedAppAuthInfoandagenticUserTemplatesfields. Workaround: always author manifests in your codebase and usem365agents package.
Required Fields
{
"$schema": "https://developer.microsoft.com/json-schemas/teams/v1.25/MicrosoftTeams.schema.json",
"manifestVersion": "1.25",
"version": "1.0.0",
"id": "{{APP_ID}}",
"developer": {
"name": "Contoso",
"websiteUrl": "https://contoso.com",
"privacyUrl": "https://contoso.com/privacy",
"termsOfUseUrl": "https://contoso.com/terms"
},
"name": {
"short": "My Teams App",
"full": "My Teams App — Full Description"
},
"description": {
"short": "Brief app description (max 80 chars)",
"full": "Detailed description of app capabilities (max 4000 chars)"
},
"icons": {
"color": "color.png",
"outline": "outline.png"
},
"accentColor": "#4F6BED",
"validDomains": ["contoso.com", "*.contoso.com"]
}
Bot Registration (Single-Tenant)
All bot registrations in v1.25 enforce single-tenant with APP_TENANTID.
{
"bots": [
{
"botId": "{{BOT_ID}}",
"scopes": ["personal", "team", "groupChat"],
"supportsFiles": false,
"isNotificationOnly": false,
"commandLists": [
{
"scopes": ["personal"],
"commands": [
{ "title": "help", "description": "Show available commands" },
{ "title": "status", "description": "Check current status" }
]
}
]
}
]
}
Bicep for single-tenant bot:
resource botService 'Microsoft.BotService/botServices@2022-09-15' = {
name: botServiceName
location:
…(truncated)