# Teams App Dev

> Deep expertise in custom Microsoft Teams app development — manifest v1.25 schema, M365 Agents Toolkit CLI, Adaptive Cards 1.6, search/action/link-unfurling message extensions, meeting apps (side panel, stage, content bubble), Custom Engine Agents, Agent 365 blueprints, Nested App Authentication (NAA), dialog namespace (replacing task modules), single-tenant bot registration, and Agents Playground for local testing. Targets professional TypeScript developers building production Teams apps on the Microsoft 365 platform.

- Skill: `thelobbi/teams-app-dev` (Agent Skill, multi-file: 9 files)
- Install (CLI): `npx skillmds@latest add thelobbi/teams-app-dev`
- Raw SKILL.md: https://api.skillmd.com/api/skills/thelobbi/teams-app-dev/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- Author: thelobbi (https://skillmd.com/u/thelobbi)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/thelobbi/teams-app-dev

---


# 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-teams` npm packages (Bot Framework SDK v4)
- **Frontend**: React with `@microsoft/teams-js` SDK 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**:
```bash
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**:
```yaml
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.

```bash
# 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**:
```json
{
  "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`.

```json
{
  "type": "Action.Execute",
  "title": "Approve",
  "verb": "approveRequest",
  "data": {
    "requestId": "REQ-001"
  }
}
```

**Bot handler for Action.Execute**:
```typescript
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**:
```bash
npm install adaptivecards-templating
```

**Template**:
```json
{
  "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**:
```typescript
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

- `version` must be `"1.4"` or `"1.5"` for broad Teams client support.
- Every `Input.*` element **must** have a unique `id` property.
- `Action.Execute` **must** include a `verb` string.
- Card payload size limit: **28 KB** (compressed).
- Image URLs must be HTTPS.
- `fallbackText` should 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`):
```json
{
  "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`):
```typescript
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**:
```json
{
  "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):
```typescript
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**:
```typescript
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**:
```json
{
  "composeExtensions": [
    {
      "botId": "{{BOT_ID}}",
      "messageHandlers": [
        {
          "type": "link",
          "value": {
            "domains": ["contoso.com", "*.contoso.com"]
          }
        }
      ]
    }
  ]
}
```

**Handler**:
```typescript
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

```typescript
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).

```typescript
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.

```typescript
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.

```typescript
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)

```json
{
  "$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

```typescript
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

```typescript
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.

```typescript
// 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**:
```typescript
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**:
```typescript
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

```typescript
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

```typescript
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

```typescript
// 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

```bash
npm install @microsoft/teams-js
```

**Initialize**:
```typescript
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`**:
```json
{
  "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):
```typescript
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)**:
```typescript
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)**:
```typescript
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

```typescript
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

1. **Regex validation**: The Dev Portal schema validator may reject valid regex patterns in `Input.Text.regex`. Workaround: validate locally with `m365agents validate`.
2. **Dev Portal save issue**: Editing manifests directly in the Teams Developer Portal may silently drop `nestedAppAuthInfo` and `agenticUserTemplates` fields. Workaround: always author manifests in your codebase and use `m365agents package`.

### Required Fields

```json
{
  "$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`.

```json
{
  "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**:
```bicep
resource botService 'Microsoft.BotService/botServices@2022-09-15' = {
  name: botServiceName
  location: 

…(truncated)
