Generated banner for Teams App Developer
Generated logo for Teams App Developer

Teams App Developer

MicrosoftOfficialPublished by Microsoft

  • Skill
  • Tool integration
  • Software development
  • MIT
  • Medium risk

From the author

Builds, tests, and deploys Microsoft 365 apps and agents for Teams and Copilot. Includes sub-skills for project creation, local testing, cloud deployment, troubleshooting, and Slack-to-Teams migration. USE FOR: Teams agent, bot, tab, message extension, Declarative Agents, Custom Engine Agents, local testing, Agents Playground, Azure resource provision, remote deployment, Slack to Teams migration, cross-platform bot development, Block Kit to Adaptive Cards conversion. DO NOT USE FOR: general web development, non-bot/non-Teams projects.
— the description by Microsoft

AutoGineer summary

Written by AutoGineer from the skill’s own files, not by the author

The teams-app-developer skill provides a structured framework for managing the lifecycle of Microsoft 365 agents and Teams applications. It leverages the ATK CLI to handle project scaffolding, local testing via the Agents Playground, cloud provisioning on Azure, and troubleshooting. The skill includes specialized guidance for migrating Slack bots to Teams, including converting Block Kit UI to Adaptive Cards, and provides access to an extensive library of micro-expert documentation for cross-platform development.

What it does

  • Scaffolds new Microsoft 365 agents and Teams applications from templates.
  • Facilitates local testing using the Agents Playground or Teams-specific environments.
  • Automates Azure resource provisioning and cloud deployment workflows.
  • Provides diagnostic procedures for common authentication and configuration errors.
  • Supports migration of Slack bots to the Teams platform.
  • Manages project configuration and environment variables for ATK-based projects.

When to use it

  • When starting a new Teams app, bot, or Declarative Agent project.
  • When you need to test an agent locally before deploying to the cloud.
  • When migrating existing Slack bots to the Microsoft Teams ecosystem.
  • When troubleshooting deployment or authentication issues in a Teams project.
  • When you need to provision Azure infrastructure for a Teams application.

Advantages

  • Streamlines the development lifecycle with standardized CLI commands.
  • Reduces development time by offering a local testing playground.
  • Provides expert-level guidance for complex cross-platform migrations.
  • Ensures consistent configuration management across development environments.
  • Includes robust error-handling protocols for common platform-specific pitfalls.

Usage examples

“Create a new Teams bot project using the default template.”
The agent will initialize a new project folder, configure the necessary YAML files, and guide you through the initial setup using the ATK CLI.
“How do I migrate my Slack bot to Microsoft Teams?”
The agent will initiate the slack-to-teams workflow, providing access to cross-platform bridging experts and guidance on converting UI components.
“My Teams bot is returning a 401 error, how do I fix it?”
The agent will trigger the troubleshooting workflow, checking your environment variables, tunnel status, and authentication configuration.

Install

  1. Download the skill as a ZIP:

    https://mp-staging.autogineer.com/agent-skills/microsoft/teams-app-developer/download
  2. Unzip it into the cross-client skills folder of your project:

    mkdir -p .agents/skills && unzip teams-app-developer.zip -d .agents/skills

Documentation by Microsoft

Microsoft 365 Agents Toolkit Skill

Build Microsoft 365 agents and Teams apps using the ATK CLI.

AI Behavior Guidelines

  1. Testing Strategy: Recommend Agents Playground first (faster, no M365 needed). Use Teams workflow only if user explicitly requests it.

  2. Environment Variables: NEVER hardcode secrets or make up placeholder values. Always ask users for real values.

  3. Error Handling: Read error messages carefully. Check env/.env.local, .localConfigs, and atk auth list. Common pitfalls:

    • AADSTS7000229 → aadApp/create missing generateServicePrincipal: true in YAML — add it and re-provision

    • Missing TENANT_ID in .localConfigs → SDK uses wrong token authority → 401 from Bot Connector

    • 401 persists after auth fix → devtunnel URL may be blacklisted — create a fresh tunnel

    • See troubleshoot/troubleshoot.md for full diagnostic steps

  4. Long-Running Commands — WAIT for completion:

    • atk new, atk provision, atk deploy can take several minutes

    • Always wait for completion before running the next step (timeout 120000ms+)

  5. Local Service Startup — Hangs terminal (expected):

    • npm run dev, npm start, python app.py, devtunnel host, etc. will hang — the process keeps running indefinitely

    • ALWAYS run as a background process (isBackground=true) — NEVER use isBackground=false for these commands

    • Do NOT wait for it to "finish" — verify startup by checking output for "listening on port" or tunnel URL

    • If errors appear, read logs, diagnose, fix, restart

    • Use a NEW terminal to launch Agents Playground or open Teams sideloading URL

  6. Monitor App Logs: Periodically check background terminal output for runtime errors. If the app crashes, read the error, fix the root cause, and restart.

  7. Telemetry Tagging: Before running any atk CLI commands, set the session environment variable so all CLI invocations are tagged as skill-initiated:

    export ATK_CLI_SKILL=true

    Run this once at the start of the session. All subsequent atk commands in the same terminal will inherit it.

ATK CLI Setup

atk --version  # Must be > 1.1.5-beta

If ATK is not found or version is too old:

npm i -g @microsoft/m365agentstoolkit-cli@beta

CLI Global Options

OptionMeaningRecommendation
-iInteractive modeAlways use -i false in automation to avoid hanging
-fProject folderDefault to be current directory, used when specifying a custom folder. When scaffolding a new project, this is the parent folder where the project folder will be created under.
-hCommand helpUse atk <command> -h for quick syntax checks

Sub-Skills

Sub-SkillWhen to UseReference
create-projectScaffold new project from template, choose template, atk newcreate-project/create-project.md
test-playgroundTest locally with Agents Playground, agentsplayground, quick testingtest-playground/test-playground.md
test-teamsRun on Teams, devtunnel, sideload, Teams testing, test in Copilottest-teams/test-teams.md
provision-deployProvision Azure resources, deploy to cloud, atk provision, atk deployprovision-deploy/provision-deploy.md
troubleshootFix errors, 401, port conflicts, YAML errors, stale botstroubleshoot/troubleshoot.md
slack-to-teamsMigrate Slack bot to Teams, cross-platform bridging, Block Kit to Adaptive Cardsslack-to-teams/SKILL.md

MANDATORY: Before executing any workflow, read the corresponding sub-skill document.

Shared References

  • manifest-and-yaml.md — Project files, YAML config, env vars, .localConfigs flow

  • commands.md — ATK CLI commands: package, validate, share, collaborate

  • templates.md — Complete template catalog with language support

  • experts/ — 100+ micro-expert files: Teams SDK, Slack SDK, cross-platform bridging, deploy, AI models, security, language conversion

  • docs/ — Platform comparison guides: UI, messaging, identity, infrastructure, feature gaps

Workflow Chains

Match user intent to the smallest valid workflow.

User IntentWorkflow (read in order)
Build new app from scratchcreate-project → test-playground
Test existing project locallytest-playground (recommended) or test-teams
Deploy to Azureprovision-deploy
Fix broken bottroubleshoot → re-test
Migrate Slack bot to Teamsslack-to-teams

MANDATORY: Before executing any slack-to-teams workflow, read slack-to-teams/SKILL.md first. The sub-skill contains a routed expert system with 100+ micro-expert files for cross-platform bot development.

ATK Project Context Resolution

Resolve config values only when missing. If a value is already known in the session, reuse it.

Step 1: Detect ATK Project

If m365agentstoolkit*.yml exists in the current folder, treat it as an ATK project and parse configuration.

Step 2: Resolve Common Configuration

Resolve variables referenced in m365agentstoolkit*.yml. Common variables: AZURE_OPENAI_API_KEY AZURE_OPENAI_ENDPOINT AZURE_OPENAI_DEPLOYMENT_NAME

Step 3: Collect Missing Values

If required values are missing, ask the user for only the missing ones.

Refer to manifest-and-yaml.md for full config-file details.

Files

docs/feature-gaps.md

Feature Gap Analysis: Slack ↔ Teams

A complete inventory of every feature that does not have a direct equivalent on the other platform, organized by severity. Each gap includes mitigations in both directions.

How to Read This Document

  • Slack → Teams = you have a Slack bot and are adding Teams support

  • Teams → Slack = you have a Teams bot and are adding Slack support

  • Effort estimates are per-feature implementation hours

  • Features with direct 1:1 mappings (GREEN) are not listed — see messaging-and-commands.md and ui-components.md for those


RED Gaps — No Platform Equivalent

These features exist on one platform with no counterpart on the other. They require redesign, custom infrastructure, or acceptance of reduced functionality.


R1. Ephemeral Messages

Slack has it. Teams does not.

Slack's chat.postEphemeral() sends a message visible only to one user in a channel. Teams has no visibility flag — all bot messages are visible to everyone.

StrategyDirectionHowEffort
refresh.userIds on Action.ExecuteSlack → TeamsCard shows different content per user. Covers ~80% of cases. Max 60 user IDs per card.4–8 hrs
Route to 1:1 chatSlack → TeamsSend private content to user's personal bot chat via proactive messaging. Different UX but reliable.2–4 hrs
Build sendEphemeral() helperSlack → TeamsWrapper that auto-detects context and picks the best strategy. Worth it if many handlers use ephemeral.8–12 hrs
Drop ephemeral behaviorSlack → TeamsShow messages to everyone. Simplest but may expose private data.0 hrs
Native chat.postEphemeral()Teams → SlackDirect API call. No gap in this direction.0 hrs

R2. Custom Emoji Reactions

Slack has it. Teams does not.

Slack supports unlimited custom emoji as reactions. Teams supports exactly 6 fixed reactions: like, heart, laugh, surprised, sad, angry. Bots that use reactions as workflow signals (:white_check_mark: = approved) cannot map to Teams.

StrategyDirectionHowEffort
Adaptive Card buttonsSlack → TeamsReplace reaction workflows with Action.Submit buttons (e.g., "Approve" / "Reject"). Better audit trail.4–8 hrs
Map to 6 fixed reactionsSlack → TeamsMap most important reactions to like/heart/laugh/surprised/sad/angry. Lossy — only works with ≤6 reactions.2–4 hrs
Native emoji reactionsTeams → SlackDirect mapping. Slack supports unlimited custom emoji.0 hrs

R3. Modal Cancel Notification (viewClosed)

Slack has it. Teams does not.

Slack fires view_closed when a user dismisses a modal (with notify_on_close: true). Teams sends no notification when a dialog is dismissed — the bot never knows the user cancelled.

StrategyDirectionHowEffort
Timeout + explicit Cancel buttonSlack → TeamsAdd a "Cancel" button inside the dialog. Implement 5-min TTL for cleanup of stale locks/state.4–8 hrs
Accept stale stateSlack → TeamsDrop cancel cleanup. Accept that some locks may persist until TTL.0 hrs
Native notify_on_close: trueTeams → SlackSet notify_on_close: true in views.open(). Native support.0 hrs

R4. Mid-Form Dynamic Updates

Slack has it. Teams does not.

Slack modals support dispatch_action: true on inputs, which fires block_actions events while the modal is open. The bot can then call views.update() to change the modal dynamically (e.g., show/hide fields based on a dropdown selection). Teams dialogs have no equivalent — Adaptive Card inputs don't fire events until the form is submitted.

StrategyDirectionHowEffort
Multi-step dialogsSlack → TeamsSplit dependent fields across dialog steps. Step 1 collects the trigger value; step 2 shows dependent fields.8–16 hrs
Action.ToggleVisibilitySlack → TeamsShow/hide elements client-side. Works for simple show/hide but cannot fetch server data.2–4 hrs
Web-based task moduleSlack → TeamsEmbed a full web form in an iframe with real-time interactivity. Full control but much more effort.16–24 hrs
Native block_actions + views.update()Teams → SlackSet dispatch_action: true on input elements. Handle block_actions and call views.update().2–4 hrs

R5. Server-Side Field Validation with Inline Errors

Slack has it. Teams does not.

Slack's view_submission handler can return response_action: "errors" with a map of { block_id: "error message" } to show inline validation errors without closing the modal. Teams dialogs close on submit — there is no way to keep the dialog open with error messages.

StrategyDirectionHowEffort
Re-open dialog with errorsSlack → TeamsOn validation failure, return a new dialog card pre-populated with the user's data and error messages in field labels.4–8 hrs
Client-side validation onlySlack → TeamsUse Adaptive Card isRequired, regex, maxLength, min/max. Covers simple cases but not async checks (e.g., "username taken").1–2 hrs
Native response_action: "errors"Teams → SlackReturn { response_action: "errors", errors: { block_id: "msg" } } from view_submission handler.0 hrs

R6. Dialog / Modal Stacking

Slack has it. Teams does not.

Slack supports views.push() to stack up to 3 modals. The user can navigate back by dismissing the top modal. Teams dialogs do not stack — opening a new dialog replaces the current one.

StrategyDirectionHowEffort
Single dialog with step routingSlack → TeamsOne dialog with internal step state. Submit handler checks step number and returns the next step's card. Add a "Back" button that decrements the step.8–16 hrs
Build StepDialog helperSlack → TeamsReusable class managing step state, forward/back navigation. Worth it if 3+ wizard flows exist.16–24 hrs
Sequential separate dialogsSlack → TeamsClose current dialog, open next. No back navigation. Degraded UX.4–8 hrs
Native views.push()Teams → SlackCall views.push() from within a view_submission or block_actions handler. Up to 3 levels.0 hrs

R7. Scheduled Message API

Slack has it. Teams does not.

Slack provides chat.scheduleMessage() and chat.deleteScheduledMessage() as first-class APIs. Teams has no server-side scheduling — the bot must build its own.

StrategyDirectionHowEffort
Azure Functions timer + Cosmos DBSlack → TeamsStore message + target time in DB. Timer function polls every minute and sends via proactive messaging.16–24 hrs
Azure Queue visibility timeoutSlack → TeamsEnqueue with visibilityTimeout set to the delay. Queue trigger fires at the right time. 7-day max.8–12 hrs
Azure Service Bus scheduled messagesSlack → TeamsscheduleMessages(msg, scheduledTime). Exact-time delivery, native cancellation. Best for high volume.12–16 hrs
Power AutomateSlack → Teams"Delay until" action in a flow. No code but requires license for custom connectors.8–12 hrs
In-process timer (dev only)Slack → TeamssetTimeout / node-cron. Not durable — lost on restart.2–4 hrs
Native chat.scheduleMessage()Teams → SlackDirect API call with post_at Unix timestamp. Native cancellation via deleteScheduledMessage().0 hrs

R8. Channel Archive

Slack has it. Teams does not.

Slack's conversations.archive() archives a channel — it becomes read-only and hidden from the channel list. Teams can only archive an entire Team, not individual channels.

StrategyDirectionHowEffort
Rename with [ARCHIVED] prefixSlack → TeamsRename channel, update description to "Archived on {date}". Non-destructive. Cosmetic only.4–8 hrs
Rename + remove all membersSlack → TeamsRename + kick everyone. Stronger enforcement but destructive and hard to undo.8–12 hrs
Team-level archiveSlack → TeamsArchive the entire Team via Graph. Only works if the channel has a dedicated Team.2–4 hrs
Native conversations.archive()Teams → SlackDirect API call. Reversible via conversations.unarchive().0 hrs

Slack has it. Teams does not.

Slack unfurls links in existing messages (edited to add a link, or links posted before the bot was installed). Teams only unfurls links in new messages — editing a message to add a link does not trigger unfurling.

StrategyDirectionHowEffort
Accept the limitation (Recommended)Slack → TeamsNo workaround exists. New message unfurling works fine.0 hrs
Manual preview commandSlack → TeamsBot command where users paste a URL to get a preview card. Niche use case.4–8 hrs
Native retroactive unfurlingTeams → SlackSlack unfurls retroactively by default. No issue.0 hrs

R10. Firewall-Friendly Transport (Socket Mode)

Slack has it. Teams does not.

Slack's Socket Mode uses an outbound WebSocket — no inbound ports needed. The bot can run behind any firewall. Teams requires a public HTTPS endpoint for inbound webhooks.

StrategyDirectionHowEffort
Deploy to AzureSlack → TeamsHost in App Service / Functions / Container Apps. Use Dev Tunnels for local dev. Standard cloud deployment.4–8 hrs
Azure RelaySlack → TeamsHybrid connection for strict on-premises firewalls that cannot expose any public endpoint. Adds latency.8–16 hrs
Native Socket ModeTeams → SlackSet socketMode: true with appToken. Outbound WebSocket, zero inbound ports.1–2 hrs

RED Gap Workarounds

Detailed implementation patterns for every RED gap. These are the recommended approaches — pick the one that fits your bot's needs.


R1 Workaround: Ephemeral via refresh.userIds

The best general-purpose workaround. An Action.Execute card with refresh.userIds shows personalized content to specific users while showing a default card to everyone else.

// Teams: per-user card content (replaces chat.postEphemeral)
const card = {
  type: "AdaptiveCard",
  version: "1.4",
  refresh: {
    action: {
      type: "Action.Execute",
      verb: "personalView",
      data: { requestId: "123" },
    },
    userIds: [actingUserId], // max 60 IDs
  },
  body: [
    { type: "TextBlock", text: "A request was submitted." }, // everyone sees this
  ],
};

// When the specified user views the card, Teams invokes the bot:
app.on("card.action", async (ctx) => {
  if (ctx.activity.value?.action?.verb === "personalView") {
    // Return a personalized card only this user sees
    return {
      status: 200,
      body: {
        type: "AdaptiveCard",
        version: "1.4",
        body: [
          { type: "TextBlock", text: "Your request #123 was approved.", weight: "Bolder" },
          { type: "TextBlock", text: "Only you can see these details." },
        ],
      },
    };
  }
});

When this doesn't work: More than 60 users need per-user views, or the content is plain text (not a card). Fall back to sending a proactive message in the user's 1:1 bot chat.

Reverse (Teams → Slack): Use chat.postEphemeral({ channel, user, text }) directly. Native support.


R2 Workaround: Reactions → Adaptive Card Buttons

Replace emoji-reaction workflows with explicit card buttons. This actually improves auditability — button clicks are tracked, emoji reactions are not.

// Before (Slack): reaction-based approval
app.event("reaction_added", async ({ event, client }) => {
  if (event.reaction === "white_check_mark") {
    await client.chat.postMessage({
      channel: event.item.channel,
      text: `Approved by <@${event.user}>`,
      thread_ts: event.item.ts,
    });
  }
});

// After (Teams): button-based approval
const approvalCard = {
  type: "AdaptiveCard", version: "1.5",
  body: [{ type: "TextBlock", text: "Request #42 needs approval" }],
  actions: [
    { type: "Action.Submit", title: "Approve", style: "positive",
      data: { action: "approve", requestId: "42" } },
    { type: "Action.Submit", title: "Reject", style: "destructive",
      data: { action: "reject", requestId: "42" } },
  ],
};

When reactions are decorative (not workflow signals): map to the 6 fixed Teams reactions. Only viable if you use ≤6 distinct reactions.

Reverse (Teams → Slack): Map Action.Submit buttons to emoji reactions via reactions.add, or keep as Slack buttons (usually better UX anyway).


R3 Workaround: Cancel Detection via TTL + Explicit Button

Since Teams sends no notification when a dialog is dismissed, combine two strategies:

// 1. Add an explicit Cancel button inside the dialog card
const dialogCard = {
  type: "AdaptiveCard", version: "1.5",
  body: [/* form fields */],
  actions: [
    { type: "Action.Submit", title: "Submit", data: { action: "submit_form" } },
    { type: "Action.Submit", title: "Cancel", data: { action: "cancel_form", lockId: "abc" } },
  ],
};

// 2. Handle explicit cancel
app.on("dialog.submit", async ({ activity, send }) => {
  const data = activity.value?.data;
  if (data?.action === "cancel_form") {
    await releaseLock(data.lockId);
    return { status: 200, body: { task: { type: "message", value: "Cancelled." } } };
  }
  // ... handle submit ...
});

// 3. TTL-based cleanup for users who close via the X button
setInterval(async () => {
  const staleLocks = await getLocksOlderThan(5 * 60_000); // 5 min
  for (const lock of staleLocks) await releaseLock(lock.id);
}, 60_000);

Reverse (Teams → Slack): Use notify_on_close: true in views.open() and handle view_closed callback.


R4 Workaround: Mid-Form Updates via Multi-Step Dialogs

Split dependent fields across dialog steps. Step 1 collects the value that drives the dynamic behavior; step 2 renders the dependent fields.

app.on("dialog.submit", async ({ activity }) => {
  const data = activity.value?.data;

  if (data?.step === 1) {
    // User selected a category — return step 2 with dependent fields
    const subcategories = await getSubcategories(data.category);
    return {
      status: 200,
      body: {
        task: {
          type: "continue",
          value: {
            title: "Step 2 of 2",
            card: buildStep2Card(data.category, subcategories),
          },
        },
      },
    };
  }

  if (data?.step === 2) {
    // Final submission
    await processForm(data);
    return { status: 200, body: { task: { type: "message", value: "Done!" } } };
  }
});

For simple show/hide (no server data needed): use Action.ToggleVisibility to show/hide card elements client-side. This works for "show advanced options" toggles but cannot populate options from an API.

Reverse (Teams → Slack): Use dispatch_action: true on inputs + views.update() in the block_actions handler. Native support for real-time form updates.


R5 Workaround: Server Validation via Dialog Re-render

On validation failure, return a continue response with the same form, pre-populated with the user's values, plus error messages as colored TextBlock elements.

app.on("dialog.submit", async ({ activity }) => {
  const data = activity.value?.data;
  const errors: string[] = [];

  if (!data?.email?.includes("@")) errors.push("Invalid email address");
  if ((data?.name?.length ?? 0) < 2) errors.push("Name must be at least 2 characters");

  if (errors.length > 0) {
    return {
      status: 200,
      body: {
        task: {
          type: "continue",
          value: {
            title: "Fix Errors",
            card: {
              contentType: "application/vnd.microsoft.card.adaptive",
              content: {
                type: "AdaptiveCard", version: "1.5",
                body: [
                  // Error banner
                  ...errors.map(e => ({
                    type: "TextBlock", text: e, color: "Attention", weight: "Bolder",
                  })),
                  // Re-populate form with user's previous values
                  { type: "Input.Text", id: "name", label: "Name", value: data.name ?? "" },
                  { type: "Input.Text", id: "email", label: "Email", value: data.email ?? "" },
                ],
                actions: [{ type: "Action.Submit", title: "Submit", data: { action: "register" } }],
              },
            },
          },
        },
      },
    };
  }

  // Validation passed
  await processRegistration(data);
  return { status: 200, body: { task: { type: "message", value: "Registered!" } } };
});

Combine with client-side validation for the best UX: add isRequired, regex, and errorMessage to catch obvious errors before the server round-trip.

Reverse (Teams → Slack): Use response_action: "errors" with { block_id: "error message" } natively.


R6 Workaround: Modal Stacking via Step Routing

Simulate views.push with a single dialog that routes by step number. Include a "Back" button that decrements the step.

app.on("dialog.submit", async ({ activity }) => {
  const data = activity.value?.data;
  const step = data?.step ?? 1;

  if (data?.action === "back") {
    return continueDialog(buildStepCard(step - 1, data));
  }

  if (step < 3) {
    return continueDialog(buildStepCard(step + 1, data));
  }

  // Final step — process all collected data
  await processWizard(data);
  return { status: 200, body: { task: { type: "message", value: "Complete!" } } };
});

function continueDialog(card: object) {
  return {
    status: 200,
    body: { task: { type: "continue", value: { title: `Step ${(card as any).step}`, card } } },
  };
}

function buildStepCard(step: number, previousData: Record<string, unknown>): object {
  // Each step card embeds ALL previous data in Action.Submit.data
  // so nothing is lost between steps
  return {
    contentType: "application/vnd.microsoft.card.adaptive",
    content: {
      type: "AdaptiveCard", version: "1.5",
      body: [/* step-specific fields */],
      actions: [
        ...(step > 1 ? [{ type: "Action.Submit", title: "Back",
          data: { ...previousData, step, action: "back" } }] : []),
        { type: "Action.Submit", title: step === 3 ? "Finish" : "Next",
          data: { ...previousData, step, action: "next" } },
      ],
    },
    step,
  };
}

Key principle: Every step's Action.Submit.data must carry forward ALL data from previous steps, since there's no persistent modal state like Slack's private_metadata.

Reverse (Teams → Slack): Use views.push() natively — up to 3 levels of stacking with built-in "X to go back" behavior.


R7 Workaround: Scheduling via Azure Service Bus

The most production-ready approach. Azure Service Bus supports exact-time delivery and native cancellation.

import { ServiceBusClient } from "@azure/service-bus";

const sbClient = new ServiceBusClient(process.env.SERVICEBUS_CONNECTION!);
const sender = sbClient.createSender("scheduled-messages");

// Schedule a message
async function scheduleMessage(
  conversationId: string, text: string, sendAt: Date
): Promise<Long> {
  const [sequenceNumber] = await sender.scheduleMessages(
    { body: { conversationId, text } },
    sendAt
  );
  return sequenceNumber; // store this for cancellation
}

// Cancel a scheduled message
async function cancelScheduled(sequenceNumber: Long): Promise<void> {
  await sender.cancelScheduledMessages(sequenceNumber);
}

// Receiver (runs as a separate process or Azure Function)
const receiver = sbClient.createReceiver("scheduled-messages");
receiver.subscribe({
  processMessage: async (msg) => {
    const { conversationId, text } = msg.body;
    await teamsApp.send(conversationId, text);
  },
  processError: async (err) => console.error(err),
});

For simpler needs: Azure Queue with visibilityTimeout (max 7 days) or Azure Functions timer + Cosmos DB (poll every minute).

Reverse (Teams → Slack): Use chat.scheduleMessage({ channel, text, post_at }) natively.


R8 Workaround: Channel Archive via Rename + Description

The most widely used workaround. Cosmetic-only — doesn't actually prevent new messages.

async function archiveChannel(
  graph: Client, teamId: string, channelId: string
): Promise<void> {
  const channel = await graph.api(`/teams/${teamId}/channels/${channelId}`).get();

  await graph.api(`/teams/${teamId}/channels/${channelId}`).patch({
    displayName: `[ARCHIVED] ${channel.displayName}`.substring(0, 50),
    description: `Archived on ${new Date().toISOString()}. Original: ${channel.description ?? ""}`,
  });
}

For stronger enforcement: After renaming, remove all non-owner members. This is destructive (members must be re-invited to undo) but prevents new messages.

Reverse (Teams → Slack): Use conversations.archive() natively. Reversible via conversations.unarchive().


R9 Workaround: Retroactive Unfurling

No workaround exists. Teams only unfurls links in new messages. Accept this limitation — it affects a small percentage of use cases (links in edited messages or messages sent before the bot was installed).

If critical, build a /preview <url> bot command that returns a card preview on demand.


R10 Workaround: Firewall Transport

Deploy to a cloud provider. This is the standard path for any Teams bot. For local development, use Dev Tunnels (built into VS Code) or ngrok.

For strict on-premises environments that truly cannot expose any endpoint, Azure Relay provides a hybrid connection where the bot connects outbound to Azure, and Azure proxies inbound Teams traffic through that connection. This adds 10–50ms latency but requires zero inbound firewall rules.

Reverse (Teams → Slack): Enable Socket Mode with socketMode: true and appToken. Zero inbound ports, zero tunneling.


YELLOW Gaps — Equivalent Exists but Requires Design Decisions

These features have functional equivalents on the other platform, but the mapping is not 1:1 and requires choosing an approach.


Y1. Slash Commands

Slack has native /command. Teams does not.

StrategyDirectionHowEffort
Text pattern matchingSlack → TeamsDetect command-like text in app.on("message"). Accept weather and /weather.2–4 hrs
Manifest bot commandsSlack → TeamsAdd commands[] to manifest for Teams command menu. Not / prefix but discoverable.1–2 hrs
Message extensionSlack → TeamscomposeExtensions for richer command UX with search results or task modules.8–12 hrs
Native app.command()Teams → SlackRegister via app.command("/cmd", handler). Add ack() call. Configure in Slack app dashboard.2–4 hrs

Y2. Thread Broadcast (reply_broadcast)

Slack has it as a single call. Teams requires two.

StrategyDirectionHowEffort
Two API callsSlack → TeamsCall reply() (thread) + send() (channel) separately.1–2 hrs
replyWithBroadcast() wrapperSlack → TeamsConvenience method that calls both internally.2–4 hrs
Native reply_broadcast: trueTeams → SlackSingle say() call with reply_broadcast: true.0 hrs

Y3. Thread Discovery

Slack has conversations.replies(). Teams uses Graph API.

StrategyDirectionHowEffort
Graph API directSlack → TeamsGET /teams/{teamId}/channels/{channelId}/messages/{messageId}/replies. Requires ChannelMessage.Read.All.4–8 hrs
getThreadReplies() helperSlack → TeamsWrapper encapsulating Graph client setup, auth, and pagination.8–12 hrs
Native conversations.replies()Teams → SlackDirect API call with thread ts.0 hrs

Y4/5/6. File Upload

Slack: one call. Teams: 3-step consent flow.

StrategyDirectionHowEffort
sendFile() helperSlack → TeamsUnified wrapper: auto-detects personal/channel, routes to OneDrive/SharePoint, chunks >4 MB.24–40 hrs
Manual FileConsentCardSlack → TeamsImplement 3-step consent flow directly. Verbose and error-prone.16–24 hrs
Native files.uploadV2()Teams → SlackSingle API call. No consent step.1–2 hrs

Slack: 30-minute async. Teams: 5-second sync.

StrategyDirectionHowEffort
Cache-first with prefetchSlack → TeamsCache middleware wraps handler. Pre-populate for known URLs. Without this, slow unfurls silently fail.12–16 hrs
Synchronous handler onlySlack → TeamsDirect handler. Only viable for fast data sources (<5 seconds).4–8 hrs
Native async chat.unfurl()Teams → SlackHandle link_shared event. Respond within 30 minutes via chat.unfurl().2–4 hrs

Y8. Reminders

Slack has reminders.add(). Teams does not.

StrategyDirectionHowEffort
Piggyback on scheduler (R7)Slack → TeamsReuse scheduled message infrastructure. setReminder() stores + sends to 1:1 chat at target time.4–8 hrs (if scheduler exists)
Power Automate + PlannerSlack → TeamsCreate Planner tasks with due-date notifications.8–12 hrs
Native reminders.add()Teams → SlackDirect API call. Platform-managed delivery.0 hrs

Y9. Dynamic Select Menus (Server-Side Typeahead)

Slack has external_data_source + block_suggestion. Teams does not.

Slack's app.options() handler receives keystrokes and returns filtered results from the server. Teams' Input.ChoiceSet is client-side only.

StrategyDirectionHowEffort
Pre-populated Input.ChoiceSetSlack → TeamsLoad all options at dialog open. Client-side filtering via style: "filtered". Works up to ~500 items.2–4 hrs
Two-step dialogSlack → TeamsStep 1: text input for search. Step 2: filtered results as ChoiceSet. Works for any dataset size.8–12 hrs
Web-based task moduleSlack → TeamsEmbed a web view with search-as-you-type. Full control. High effort.16–24 hrs
Native block_suggestionTeams → SlackSet external_data_source: true on select. Handle app.options() for server-side filtering.2–4 hrs

Y10. App Home

Slack has app_home_opened + views.publish(). Teams uses tabs.

StrategyDirectionHowEffort
tab.fetch handlerSlack → TeamsPersonal tab returns Adaptive Card on every open. Closest to app_home_opened.4–8 hrs
Welcome card on installSlack → TeamsSend card to 1:1 chat on install.add. Simple but fires once.1–2 hrs
Static web tabSlack → TeamsFull web page in iframe. Richer but needs hosting + Teams JS SDK.8–16 hrs
Native views.publish()Teams → SlackListen for app_home_opened event. Call views.publish() with Block Kit.2–4 hrs

Y11. View Hash (Race Condition Protection)

Slack has view_hash. Teams does not.

Slack's views.update() accepts a view_hash parameter. If the view has changed since the hash was captured, the update is rejected. This prevents race conditions. Teams has no equivalent.

StrategyDirectionHowEffort
Manual _version fieldSlack → TeamsInject version counter into Action.Submit.data. Reject updates where the submitted version doesn't match the stored version.2–4 hrs
Card versioning middlewareSlack → TeamsSDK plugin auto-injecting and checking version counters on every card send/receive.4–8 hrs
Native view_hashTeams → SlackPass view_hash from the previous views.open() / views.update() response.0 hrs

Y12. Global Shortcuts

Slack has app.shortcut() (global). Teams uses compose extensions.

StrategyDirectionHowEffort
Compose extensionSlack → TeamscomposeExtensions with context: ["compose", "commandBox"]. Always opens task module.8–12 hrs
Minimal-dismiss patternSlack → TeamsTask module returns tiny "Done" card for fire-and-forget actions.4–8 hrs
Bot commandSlack → TeamsReplace shortcut with typed command. Simpler but less discoverable.2–4 hrs
Native app.shortcut()Teams → SlackRegister global shortcut callback. Can fire-and-forget (ack + background work).2–4 hrs

Y13. Message Shortcuts

Slack has message_shortcut. Teams uses action-based message extensions.

StrategyDirectionHowEffort
Action message extensionSlack → TeamscomposeExtensions command with context: ["message"]. Message payload in activity.value.messagePayload.4–8 hrs
Native message_shortcutTeams → SlackRegister app.shortcut() with type message_shortcut. Message in shortcut.message.2–4 hrs

Y14. Confirmation Dialogs on Buttons

Slack has native confirm object. Teams does not.

StrategyDirectionHowEffort
Action.ShowCard inlineSlack → TeamsInline expand with "Are you sure?" + Yes/No buttons. Native Adaptive Card.2–4 hrs
Task module confirmSlack → TeamsSmall dialog popup. More prominent, closer to Slack UX.4–6 hrs
confirmAction() helperSlack → TeamsTemplate function generating confirm cards. Reusable.4–8 hrs
Native confirm objectTeams → SlackAdd confirm object to button element. Platform-rendered popup.0 hrs

Y15. Unfurl Domain Wildcards

Slack supports *.example.com. Teams requires exact domain listing.

StrategyDirectionHowEffort
Manual enumerationSlack → TeamsList every subdomain in manifest domains[]. Fine for <10.1–2 hrs
Manifest generator scriptSlack → TeamsScript reads subdomain list from config and generates manifest array.4–8 hrs
Native wildcard supportTeams → SlackWildcards work out of the box.0 hrs

Y16. All Channel Messages Without @Mention

Slack gets them by default. Teams requires RSC permission.

StrategyDirectionHowEffort
RSC permissionSlack → TeamsAdd ChannelMessage.Read.Group to manifest webApplicationInfo.applicationPermissions. Config only.1–2 hrs
Require @mentionSlack → TeamsChange UX to require @mention. Simplifies permissions but changes behavior.0 hrs
Default behaviorTeams → SlackSlack bots receive all messages in channels they're added to. No config needed.0 hrs

Y17. Built-in Retry / Resilience

Slack Bolt has retryConfig. Teams SDK has no built-in retry.

StrategyDirectionHowEffort
Build RetryPluginSlack → TeamsPlugin with exponential backoff, jitter, circuit breaker.12–16 hrs
Manual retry wrapperSlack → TeamsHand-roll backoff around outbound calls. Simpler but easy to get wrong.4–8 hrs
Native Bolt retryConfigTeams → SlackConfigure in App constructor. Built-in exponential backoff.0 hrs

Y18. Workflow Builder

Slack has it (free). Teams uses Power Automate (licensed).

StrategyDirectionHowEffort
Bot-driven orchestrationSlack → TeamsState machine + Adaptive Card buttons + persistent storage. No license dependency.16–40 hrs
Power Automate rebuildSlack → TeamsRebuild in Power Automate. Custom steps need Premium license.24–80 hrs
Teams Workflows appSlack → TeamsSimplified UI for basic automations (free). Limited scenarios.4–8 hrs
HybridSlack → TeamsSimple flows → Power Automate, complex → bot-driven.Varies
Native Workflow BuilderTeams → SlackRebuild in Slack Workflow Builder. Free, no license.8–16 hrs

Y19. App Distribution

Both platforms have app stores, but packaging and review differ.

StrategyDirectionHowEffort
Org app catalogSlack → TeamsPublish to organization catalog via Teams Admin Center. Requires admin approval.2–4 hrs
SideloadingSlack → TeamsZIP manifest + icons. Upload via Teams client. May be disabled by admin.1–2 hrs
Partner Center (public)Slack → TeamsSubmit to Teams App Store. 1–2 week review. Requires Partner Network account.8–16 hrs
Slack App DirectoryTeams → SlackSubmit via api.slack.com. Hours-to-days review. Implement InstallProvider for OAuth install flow.4–8 hrs


YELLOW Gap Best Practices

Recommended approaches for every YELLOW gap. These are the patterns that produce the best cross-platform UX with the least effort.


Y1. Slash Commands — Best Practice

Use text pattern matching + manifest bot commands together.

Register commands in the Teams manifest for discoverability (users see them in the command menu), AND detect them via text pattern matching as a fallback. Accept both /weather and weather so users migrating from Slack don't have to retrain muscle memory.

// Teams: detect both patterns
app.message(/^\/?weather$/i, async (ctx) => {
  const response = await handleWeather();
  await ctx.send(response);
});

In the Teams manifest:

{ "commands": [{ "title": "weather", "description": "Check the weather" }] }

Don't: Create a message extension for every slash command. Reserve extensions for commands that benefit from rich search results or task module UI.


Y2. Thread Broadcast — Best Practice

Write a one-line helper that makes both calls. Don't over-engineer this.

async function replyWithBroadcast(ctx: any, text: string): Promise<void> {
  await ctx.reply(text);
  await ctx.send(text);
}

Don't: Try to batch these into a single API call — Teams doesn't support it. Two calls is the correct pattern.


Y3. Thread Discovery — Best Practice

Use Graph API directly with the ctx.appGraph client. Don't build a wrapper unless you need pagination across multiple threads.

const replies = await ctx.appGraph
  .api(`/teams/${teamId}/channels/${channelId}/messages/${messageId}/replies`)
  .top(50)
  .get();

Watch out for: ChannelMessage.Read.All is an application permission requiring admin consent. If you only need thread replies in the bot's own conversations, you may be able to use delegated permissions instead.


Y4/5/6. File Upload — Best Practice

Build the sendFile() helper. The manual FileConsentCard flow is a 30-line footgun that's easy to get wrong. A helper that auto-detects personal vs. channel context and handles chunking for large files pays for itself after the second use.

Key decisions:

  • Personal chat → FileConsentCard flow (requires supportsFiles: true in manifest)

  • Channel → Direct Graph API upload to SharePoint (no consent card)

  • Files >4 MB → Graph resumable upload session with 320 KB–60 MB chunks

Don't: Store pending file buffers in memory for long periods. Upload promptly or stream to a temporary blob.


Always use a cache layer. The 5-second Teams deadline makes this non-optional. Cache aggressively:

  1. On first unfurl, fetch and cache the preview data

  2. Set a reasonable TTL (5–60 minutes depending on data freshness needs)

  3. For known high-traffic URLs, pre-populate the cache on startup

const cache = new Map<string, { data: any; expires: number }>();

app.on("message.ext.query-link", async ({ activity }) => {
  const url = activity.value?.url;
  const cached = cache.get(url);
  if (cached && cached.expires > Date.now()) {
    return buildUnfurlResponse(cached.data);
  }
  const data = await fetchPreviewData(url); // must complete in <4 seconds
  cache.set(url, { data, expires: Date.now() + 300_000 }); // 5 min TTL
  return buildUnfurlResponse(data);
});

Don't: Make multiple API calls in the unfurl handler. Pre-fetch or batch data sources.


Y8. Reminders — Best Practice

Piggyback on whatever scheduling infrastructure you built for R7. Don't create a separate system. A reminder is just a scheduled message sent to a 1:1 conversation.

async function setReminder(userId: string, text: string, when: Date): Promise<void> {
  const conversationId = await get1to1ConversationId(userId);
  await scheduleMessage(conversationId, `Reminder: ${text}`, when);
}

Don't: Use Power Automate + Planner for bot reminders — it adds an external dependency and licensing complexity. Keep it in the bot.


Y9. Dynamic Select Menus — Best Practice

Pre-populate with Input.ChoiceSet style: "filtered" for datasets under 500 items. This covers the vast majority of cases (user lists, category selects, project dropdowns).

{
  "type": "Input.ChoiceSet",
  "id": "user_select",
  "label": "Assign to",
  "style": "filtered",
  "choices": [
    { "title": "Alice Smith", "value": "[email protected]" },
    { "title": "Bob Jones", "value": "[email protected]" }
  ]
}

For datasets over 500 items: Use a two-step dialog. Step 1 is a text input for search. The submit handler queries the server and returns step 2 with filtered results as a ChoiceSet.

Don't: Build a web-based task module just for a searchable dropdown. The effort (16–24 hrs) rarely justifies the marginal UX improvement over two-step.


Y10. App Home — Best Practice

Use tab.fetch to return an Adaptive Card. It fires on every tab open (like app_home_opened) and supports tab.submit for interactions within the tab.

app.on("tab.fetch", async (ctx) => {
  const userData = await getUserDashboard(ctx.activity.from?.aadObjectId ?? "");
  return {
    status: 200,
    body: {
      tab: {
        type: "continue",
        value: { cards: [{ card: buildDashboardCard(userData) }] },
      },
    },
  };
});

Don't: Use a static web tab unless you need rich interactivity beyond what Adaptive Cards can provide (charts, real-time updates, complex navigation). Web tabs require hosting, CORS configuration, and the Teams JS SDK.


Y11. View Hash — Best Practice

Inject a _version counter into every card's Action.Submit.data. Increment on every update. Reject submissions where the version doesn't match.

let cardVersion = 0;

function buildCard(data: any): object {
  cardVersion++;
  return {
    type: "AdaptiveCard", version: "1.5",
    body: [/* ... */],
    actions: [{
      type: "Action.Submit", title: "Update",
      data: { ...data, _version: cardVersion },
    }],
  };
}

app.on("card.action", async (ctx) => {
  const submitted = ctx.activity.value?.action?.data;
  if (submitted?._version !== cardVersion) {
    await ctx.send("This card is outdated. Please use the latest version.");
    return;
  }
  // Process the update...
});

Don't: Skip version checking for low-traffic bots — race conditions happen even with single users (fast double-clicks, multiple tabs).


Y12. Global Shortcuts — Best Practice

Use compose extensions for actions that open a form. For fire-and-forget actions (no UI), use the minimal-dismiss pattern: return a tiny "Done" card that auto-closes.

{
  "composeExtensions": [{
    "commands": [{
      "id": "quickAction",
      "type": "action",
      "title": "Quick Action",
      "context": ["compose", "commandBox"],
      "fetchTask": true
    }]
  }]
}

Don't: Replace every shortcut with a bot command. Commands are less discoverable than compose extensions, which appear in the Teams UI with icons and descriptions.


Y13. Message Shortcuts — Best Practice

Use action-based message extensions with context: ["message"]. This is the closest 1:1 mapping to Slack's message shortcuts.

Access the original message via activity.value.messagePayload — it contains the message text, sender, and timestamp.

Don't: Forget to add fetchTask: true in the manifest command. Without it, the extension silently does nothing when clicked.


Y14. Confirmation Dialogs — Best Practice

Use Action.ShowCard for inline confirmation. It expands inline without leaving the current context — closest to Slack's native confirm popup.

{
  "type": "Action.ShowCard",
  "title": "Delete",
  "card": {
    "type": "AdaptiveCard",
    "body": [{ "type": "TextBlock", "text": "Are you sure you want to delete this?", "weight": "Bolder" }],
    "actions": [
      { "type": "Action.Submit", "title": "Yes, delete", "style": "destructive",
        "data": { "action": "confirm_delete", "itemId": "42" } },
      { "type": "Action.Submit", "title": "Cancel",
        "data": { "action": "cancel_delete" } }
    ]
  }
}

Don't: Open a full task module dialog for a simple yes/no confirmation. It's too heavy for the interaction.


Y15. Unfurl Domain Wildcards — Best Practice

Enumerate domains in the manifest. For fewer than 10 subdomains, list them manually. For more, write a build-time script that reads your subdomain list and generates the manifest array.

{
  "composeExtensions": [{
    "messageHandlers": [{
      "type": "link",
      "value": {
        "domains": [
          "app.example.com",
          "docs.example.com",
          "api.example.com"
        ]
      }
    }]
  }]
}

Don't: Try to register a single wildcard domain — Teams will reject it silently.


Y16. All Channel Messages — Best Practice

Add the RSC permission to the manifest. It's config-only, no code change, and matches Slack's default behavior.

{
  "webApplicationInfo": {
    "id": "{{CLIENT_ID}}",
    "resource": "api://{{CLIENT_ID}}"
  },
  "authorization": {
    "permissions": {
      "resourceSpecific": [
        { "name": "ChannelMessage.Read.Group", "type": "Application" }
      ]
    }
  }
}

Also set activity.mentions.stripText: true in the App constructor to remove <at>bot</at> text from messages that do include an @mention.

Don't: Change your UX to require @mention unless your bot genuinely shouldn't listen to all messages.


Y17. Built-in Retry — Best Practice

Build a retry utility with exponential backoff and jitter. Apply it to all outbound API calls (Graph, proactive messaging, external services).

async function withRetry<T>(fn: () => Promise<T>, maxRetries = 3): Promise<T> {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await fn();
    } catch (err: any) {
      if (attempt === maxRetries) throw err;
      const retryAfter = err?.response?.headers?.["retry-after"];
      const baseDelay = retryAfter ? parseInt(retryAfter) * 1000 : 1000 * 2 ** attempt;
      const jitter = Math.random() * 1000;
      await new Promise(r => setTimeout(r, baseDelay + jitter));
    }
  }
  throw new Error("Unreachable");
}

For proactive broadcasts (sending to hundreds of users): use p-queue with concurrency control (e.g., 5 concurrent sends) to stay within Teams' rate limits (~1 msg/sec/conversation).

Don't: Retry without jitter. Without random delay, multiple bot instances retry at the same time and cause a thundering herd.


Y18. Workflow Builder — Best Practice

Keep workflow logic in the bot (bot-driven orchestration). This avoids Power Automate licensing dependencies and keeps everything in one codebase.

Pattern: state machine with Adaptive Card buttons for user decisions, persistent storage for workflow state, and proactive messaging for notifications.

When to use Power Automate instead: Approval workflows that benefit from the built-in Approvals connector, and simple recurring tasks that business users should manage themselves.

Don't: Build a hybrid system (some flows in Power Automate, some in the bot) unless you have a clear organizational reason. Two systems means two places to debug.


Y19. App Distribution — Best Practice

Start with sideloading for dev/test, use org catalog for internal deployment, and Partner Center only for public distribution.

Sideloading checklist:

  1. manifest.json — schema v1.19+, valid id, correct botId

  2. color.png — 192x192 full-color icon

  3. outline.png — 32x32 transparent monochrome outline

  4. ZIP all three (no nested folders)

  5. Upload via Teams client → Apps → Manage your apps

Don't: Submit to Partner Center (public store) until the bot is fully stable. The 1–2 week review cycle makes iteration slow. Use org catalog for internal users.


Summary: Gap Asymmetry

Most RED gaps are asymmetric — they only apply in one direction. The pattern is clear:

DirectionRED gaps to handleWhy
Slack → Teams10 RED gapsTeams lacks ephemeral, custom reactions, modal stacking, cancel notifications, mid-form updates, field validation, scheduling, channel archive, retroactive unfurl, Socket Mode
Teams → Slack0 RED gapsSlack has native support for everything Teams offers, plus more

This means adding Slack to a Teams bot is significantly easier than adding Teams to a Slack bot. A Teams → Slack migration mostly involves mapping concepts 1:1 (Adaptive Cards → Block Kit, app.on("message") → app.message(), etc.) with few design decisions. A Slack → Teams migration requires redesigning multiple interaction patterns.

Effort Estimates by Bot Complexity
ProfileSlack Features UsedSlack → Teams EffortTeams → Slack Effort
A — SimpleMessages, basic commands, simple cards8–16 hrs4–8 hrs
B — ModerateA + ephemeral, threads, files, basic interactivity40–80 hrs8–16 hrs
C — ComplexB + shortcuts, App Home, unfurling, dynamic selects, modals80–160 hrs16–32 hrs
D — FullC + workflows, scheduling, Socket Mode, stacked modals160–300 hrs32–48 hrs

Requirements and compatibility

Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta

“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.

Needs: Needs an API key, Needs Python, Needs Node.js

Security

What our scanner noticed· Medium risk

  • Possible data-exfiltration endpoints
  • Instructions that use the network
  • Downloads piped into a shell

A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.

Attribution and licence

Published by
Microsoft
Publisher
Microsoft (Official)
Source
microsoft/skills · .github/plugins/microsoft-365-agents-toolkit/skills/teams-app-developer · commit 0696600
Licence
MIT License (redistribution allowed)
  • LICENSE (added by AutoGineer from LICENSE)
  • ATTRIBUTION.md (written by AutoGineer)
Synced

AutoGineer is not affiliated with or endorsed by Microsoft.

Download ZIP

The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download teams-app-developer.zip