DEV Community

Cover image for Building and Deploying a Microsoft Teams Bot on Azure — End to End
Vignesh Athiappan
Vignesh Athiappan

Posted on

Building and Deploying a Microsoft Teams Bot on Azure — End to End

A practical, no-gaps walkthrough: from an empty folder to a bot living inside Microsoft Teams, wired to a backend service and fully instrumented with Application Insights.


Who this is for

You want to put a chatbot inside Microsoft Teams. Not a toy — a real one that calls your own backend (a Logic App, an API, an orchestration layer, whatever you have) and answers questions for your users. You have an Azure subscription and you can write C#. You do not need to be a Teams expert or a Bot Framework expert going in.

By the end you'll have:

  1. A .NET 8 bot backend that echoes, then calls your backend and renders rich cards.
  2. Application Insights telemetry so you can see who's using it and what they ask.
  3. An Azure Bot resource with the Teams channel enabled.
  4. The bot deployed to App Service and sideloaded into Teams as a proper app.

I'm writing this the way I wish someone had written it for me — with the traps called out, because I hit most of them.


The mental model (read this before touching anything)

A Teams bot is not one thing. It's a few separate pieces that only work once they all point at each other correctly. This is the single biggest source of confusion, so get it straight up front:

  Teams client  ──►  Azure Bot resource  ──►  App Service (/api/messages)  ──►  your backend
   (the app the        (the "registration",       (your C# code that            (Logic App / API /
    user installs)      knows the endpoint)         actually runs)               orchestrator)
Enter fullscreen mode Exit fullscreen mode
  • App Registration (Entra ID) — an identity. Gives you an App ID and a client secret. The bot authenticates as this identity.
  • Azure Bot resource — the registration. It knows your messaging endpoint URL and which channels (Teams, Web Chat, etc.) are switched on. It does not run your code.
  • App Service — hosts your actual bot code and exposes POST /api/messages.
  • Teams app package — a zip (manifest + icons) that you sideload so users can find and install the bot.
  • Your backend — where the real work happens. The bot is a thin messaging layer in front of it.

Every failure I ever had came down to one of these pieces pointing at the wrong place. Keep the diagram in your head.


Prerequisites

  • .NET 8 SDK — check with dotnet --list-sdks (you want an 8.0.xxx line).
  • An Azure subscription where you can create resources.
  • Permission to create an App Registration in your tenant (or someone who can do it for you).
  • Bot Framework Emulator for local testing.
  • A code editor. VS Code is fine. A tip that saved me a lot of pain: treat dotnet build from the terminal as your authoritative build signal. IDE error panes can lie or cache stale state; the terminal doesn't.

Part 1 — Scaffold the bot backend

Install the template (one time)

dotnet new install Microsoft.Bot.Framework.CSharp.EchoBot
Enter fullscreen mode Exit fullscreen mode

Create the project

dotnet new echobot -n TeamsBot
cd TeamsBot
Enter fullscreen mode Exit fullscreen mode

You get a project shaped like this (the files that matter):

TeamsBot/
├── Bots/
│   └── EchoBot.cs          ← your bot logic lives here
├── Controllers/
│   └── BotController.cs    ← the /api/messages endpoint (don't touch)
├── Program.cs / Startup.cs ← DI wiring
├── appsettings.json        ← bot credentials + config
└── TeamsBot.csproj
Enter fullscreen mode Exit fullscreen mode

What each file does:

  • BotController.cs exposes POST /api/messages. Azure Bot Service hits this endpoint every time a user sends a message. You rarely edit it.
  • EchoBot.cs inherits from ActivityHandler. It has overridable methods like OnMessageActivityAsync (user sent a message) and OnMembersAddedAsync (bot was added to a chat). This is where your logic goes.
  • Program.cs / Startup.cs register the bot, the adapter, and your services in dependency injection.
  • appsettings.json holds MicrosoftAppId, MicrosoftAppPassword, MicrosoftAppTenantId, MicrosoftAppType.

The out-of-the-box message handler is dead simple:

protected override async Task OnMessageActivityAsync(
    ITurnContext<IMessageActivity> turnContext,
    CancellationToken cancellationToken)
{
    var replyText = $"Echo: {turnContext.Activity.Text}";
    await turnContext.SendActivityAsync(
        MessageFactory.Text(replyText), cancellationToken);
}
Enter fullscreen mode Exit fullscreen mode

That's the whole bot for now. It echoes whatever you type.

Test it locally BEFORE touching Azure

This is the step beginners skip, and it's why they get stuck. The Emulator lets you prove your code works with zero cloud involved.

dotnet run
Enter fullscreen mode Exit fullscreen mode

Your bot starts on http://localhost:3978. Open the Emulator → Open Bot → set:

  • Bot URL: http://localhost:3978/api/messages
  • Leave App ID and Password blank (local testing needs no auth).

Type hello → you get Echo: hello. 🎉

Do not move to Azure until this works. Getting the local loop green first means that when something breaks in the cloud later, you know it's an infra/config problem and not your code.


Part 2 — Connect the bot to your backend

The echo is just proof of life. Now the bot needs to (a) figure out who the user is, (b) send their question to your backend, and (c) render the answer nicely.

2.1 A service class for your backend call

Create a service (e.g. Services/BackendService.cs) whose job is to take the user's question plus their identity and call your backend, then hand the response to a card builder. The shape:

public class BackendService
{
    private readonly HttpClient _http;
    private readonly ILogger<BackendService> _log;

    public BackendService(HttpClient http, ILogger<BackendService> log)
    {
        _http = http;
        _log = log;
    }

    // Called by the bot for every user question
    public async Task<Attachment> AskAndRenderAsync(
        string question, string loggedInUser, CancellationToken ct)
    {
        var answer = await CallBackendAsync(question, loggedInUser, ct);
        return CardFactory.BuildAnswerCard(answer);   // see 2.3
    }

    private async Task<BackendResponse> CallBackendAsync(
        string question, string loggedInUser, CancellationToken ct)
    {
        var payload = new BackendRequest
        {
            Question = question,
            LoggedInUser = loggedInUser   // identity flows through here
        };
        var resp = await _http.PostAsJsonAsync("<your-backend-endpoint>", payload, ct);
        resp.EnsureSuccessStatusCode();
        return await resp.Content.ReadFromJsonAsync<BackendResponse>(cancellationToken: ct);
    }
}
Enter fullscreen mode Exit fullscreen mode

The key idea: the resolved user identity has to travel all the way through — from the Teams activity, into AskAndRenderAsync, into the request payload your backend receives. If your backend personalises answers (leave balances, "my" anything), this chain is the whole ballgame. Trace it end to end and confirm the field actually lands in the outbound request.

2.2 Resolving the real user (email) — and caching it

Teams doesn't hand you the user's email in the raw message activity by default. You fetch it with TeamsInfo.GetMemberAsync, which returns a fully populated member object (email, UPN, name).

Two gotchas I hit:

  1. Don't call it twice per turn. It's a roster API call. If you call it in a diagnostic block and in production code, you double your latency — which matters (see the timeout trap in 2.4).
  2. Cache it. The member doesn't change turn to turn, so cache it process-wide.
// Process-wide cache, 24-hour TTL, keyed on tenantId:teamsUserId
private static readonly ConcurrentDictionary<string, (string Email, DateTime CachedAt)>
    _memberCache = new();

private async Task<string> ResolveEmailAsync(
    ITurnContext turnContext, CancellationToken ct)
{
    var tenantId = turnContext.Activity.Conversation?.TenantId ?? "unknown";
    var userId   = turnContext.Activity.From?.Id ?? "unknown";
    var key      = $"{tenantId}:{userId}";

    if (_memberCache.TryGetValue(key, out var hit) &&
        (DateTime.UtcNow - hit.CachedAt) < TimeSpan.FromHours(24))
    {
        return hit.Email;
    }

    var member = await TeamsInfo.GetMemberAsync(turnContext, userId, ct);
    var email  = member?.Email ?? member?.UserPrincipalName ?? "unknown";
    _memberCache[key] = (email, DateTime.UtcNow);
    return email;
}
Enter fullscreen mode Exit fullscreen mode

Tip: while you're building, keep a small diagnostic block that dumps the raw activity and the GetMemberAsync result to your logs. It's how you prove the email is actually coming through instead of guessing. Just make sure it doesn't double the API call — read from the cache.

2.3 Render answers as Adaptive Cards

Plain text replies look bad in Teams. Use Adaptive Cards. The pattern that kept me sane: one card-building method is the single source of truth for rendering — don't scatter card logic across the codebase, and (a lesson learned the hard way) don't let an LLM generate your card JSON on the hot path unless you have a very good reason. A deterministic factory is faster, cheaper, and doesn't surprise you in production.

public static class CardFactory
{
    public static Attachment BuildAnswerCard(BackendResponse r)
    {
        var card = new
        {
            type = "AdaptiveCard",
            version = "1.4",
            body = new object[] {
                new { type = "TextBlock", text = r.Answer, wrap = true }
            },
            actions = r.Suggestions.Select(BuildMessageBackAction).ToArray()
        };
        return new Attachment {
            ContentType = "application/vnd.microsoft.card.adaptive",
            Content = card
        };
    }
    // BuildMessageBackAction — see next section
}
Enter fullscreen mode Exit fullscreen mode

2.4 The button timeout trap (important)

Here's one that cost me a day. When a user clicks a button on a card, Teams uses an Action.Submit contract that expects your bot to return HTTP 200 within roughly 15 seconds. If your backend + rendering takes longer, Teams shows a "Something went wrong. Please try again." banner — even though the correct answer arrives a moment later. It looks broken when it isn't.

The fix: make your card buttons use Teams' messageBack format instead of a plain Action.Submit. With messageBack, Teams treats a button click as if the user typed a normal message — which bypasses the card-action acknowledgment timeout entirely and renders the click as a proper user bubble.

private static object BuildMessageBackAction(string label) => new
{
    type = "Action.Submit",
    title = label,
    data = new
    {
        msteams = new
        {
            type = "messageBack",
            text = label,          // shows as the user's message
            displayText = label
        }
    }
};
Enter fullscreen mode Exit fullscreen mode

Because clicks now arrive as ordinary messages, your normal message handler already knows how to route them — no separate click-handling path required. (If you had an "echo the question back" line for clicks, delete it; Teams now renders the click natively and the echo becomes a duplicate.)

2.5 Wire it into the message handler

protected override async Task OnMessageActivityAsync(
    ITurnContext<IMessageActivity> turnContext, CancellationToken ct)
{
    var question = turnContext.Activity.Text?.Trim();
    var email    = await ResolveEmailAsync(turnContext, ct);

    var card = await _backend.AskAndRenderAsync(question, email, ct);
    await turnContext.SendActivityAsync(MessageFactory.Attachment(card), ct);
}
Enter fullscreen mode Exit fullscreen mode

Rebuild, run the Emulator again, confirm a real backend answer renders as a card. Green? Move on.


Part 3 — Application Insights (see who uses it)

You can't manage what you can't measure. Application Insights gives you inbound/outbound message counts, user counts, and — with a little work — the real identity behind each conversation. This matters especially if you don't have Teams Admin Center access to pull usage stats.

3.1 The NuGet trap (this is the one that wastes hours)

There are two families of App Insights packages, and Bot Framework's telemetry integration is built against the 2.x base package. If you pull in the 3.x App Insights packages, they clash with Bot Framework's 2.x expectations and you get maddening build/runtime errors.

Rules that finally worked:

  • Pin Microsoft.ApplicationInsights.AspNetCore to 2.22.0. This transitively pulls the correct 2.x base package — you don't reference Microsoft.ApplicationInsights directly.
  • Do not add the 3.x packages (Microsoft.ApplicationInsights 3.x, Microsoft.ApplicationInsights.AspNetCore 3.x).
  • Do not add Microsoft.Bot.Builder.Integration.ApplicationInsights.WebApi — it targets .NET Framework, not .NET Core, and it does not belong in a .NET 8 project.

The confirmed working package set:

<ItemGroup>
  <PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.11" />
  <PackageReference Include="Microsoft.ApplicationInsights.AspNetCore" Version="2.22.0" />
  <PackageReference Include="Microsoft.Bot.Builder.ApplicationInsights" Version="4.23.1" />
  <PackageReference Include="Microsoft.Bot.Builder.Integration.ApplicationInsights.Core" Version="4.23.1" />
  <PackageReference Include="Microsoft.Bot.Builder.Integration.AspNet.Core" Version="4.23.1" />
</ItemGroup>
Enter fullscreen mode Exit fullscreen mode

3.2 Register the telemetry middleware

Bot Framework ships two middleware pieces:

  • TelemetryLoggerMiddleware — logs Bot Framework events (BotMessageReceived, BotMessageSent, etc.).
  • TelemetryInitializerMiddleware — enriches those events with request context.

A trap I hit: the TelemetryInitializerMiddleware constructor is (IHttpContextAccessor, TelemetryLoggerMiddleware, bool) — three arguments. If you use a two-argument form you'll get a build error. Register roughly like this:

// Startup.cs — ConfigureServices
services.AddApplicationInsightsTelemetry();
services.AddHttpContextAccessor();

services.AddSingleton<IBotTelemetryClient, BotTelemetryClient>();
services.AddSingleton<TelemetryLoggerMiddleware>();
services.AddSingleton<TelemetryInitializerMiddleware>(sp =>
    new TelemetryInitializerMiddleware(
        sp.GetRequiredService<IHttpContextAccessor>(),
        sp.GetRequiredService<TelemetryLoggerMiddleware>(),
        logPersonalInformation: true));
Enter fullscreen mode Exit fullscreen mode

Then attach the initializer middleware to your adapter so every turn flows through it.

3.3 Config: the connection string

Put your App Insights connection string in config (App Service application settings in production). The connection string includes the ingestion endpoint for your region — don't assume the default region; copy the exact string from the App Insights resource's Overview blade.

{
  "ApplicationInsights": {
    "ConnectionString": "<your-app-insights-connection-string>"
  }
}
Enter fullscreen mode Exit fullscreen mode

3.4 Finding the real user in the data

The Users blade shows anonymised/hashed IDs, not Teams display names — so at first glance it looks useless for "who actually asked this." The real identity is in the custom dimensions on BotMessageReceived events. In App Insights → Logs (KQL):

customEvents
| where name == "BotMessageReceived"
| extend userName = tostring(customDimensions.fromName),
         userId   = tostring(customDimensions.fromId)
| summarize messages = count() by userName, userId
| order by messages desc
Enter fullscreen mode Exit fullscreen mode

That gives you a real leaderboard of who's using the bot and how much.


Part 4 — Create the Azure Bot resource (channel registration)

Now the cloud side. The minimum pieces:

  1. App Registration in Entra ID → gives you MicrosoftAppId + a client secret.
    • Put the secret in Key Vault from day one. Do not hardcode it, do not commit it. Future-you dealing with a leaked or expired secret in a dozen config files will thank present-you.
    • Note whether the registration is Single Tenant or Multi-tenant — this determines your MicrosoftAppType.
  2. Azure Bot resource → the registration. Create it, and set its messaging endpoint to:
   https://<your-app-service>.azurewebsites.net/api/messages
Enter fullscreen mode Exit fullscreen mode

(You'll create the App Service in Part 5; you can come back and set this after.)

Local dev config

For local development, appsettings.json needs the credentials — but never commit real values:

{
  "MicrosoftAppType": "SingleTenant",
  "MicrosoftAppId": "<your-app-id>",
  "MicrosoftAppPassword": "<your-secret>",
  "MicrosoftAppTenantId": "<your-tenant-id>"
}
Enter fullscreen mode Exit fullscreen mode

Better than editing the file: use User Secrets so credentials live outside the project folder entirely:

dotnet user-secrets init
dotnet user-secrets set "MicrosoftAppId"       "<your-app-id>"
dotnet user-secrets set "MicrosoftAppPassword" "<your-secret>"
dotnet user-secrets set "MicrosoftAppTenantId" "<your-tenant-id>"
dotnet user-secrets set "MicrosoftAppType"     "SingleTenant"
Enter fullscreen mode Exit fullscreen mode

And guard against accidents:

echo "appsettings.json"             >> .gitignore
echo "appsettings.Development.json" >> .gitignore
Enter fullscreen mode Exit fullscreen mode

In production, the App Service pulls these from its application settings / Key Vault, not from a committed file.

Sanity check with real credentials

Before deploying, re-run the Emulator but this time fill in the App ID and Password. If it still echoes, your App Registration credentials are valid and the bot can authenticate — a huge checkpoint that isolates auth problems from deployment problems. A 401 here means the secret is wrong or MicrosoftAppType/MicrosoftAppTenantId don't match the registration.


Part 5 — Deploy to App Service

Create an App Service (Linux, .NET 8) in your resource group. Then publish and deploy. The most reliable path from a Mac (no CLI or IDE flakiness) is a zip pushed through Kudu.

Build the deployable zip

dotnet publish -c Release -o ./publish
cd publish && zip -r ../deploy.zip . && cd ..
Enter fullscreen mode Exit fullscreen mode

The zip must have your bot's .dll at the root of the archive, not nested inside a publish/ folder.

Push via Kudu Zip Push Deploy

Kudu is App Service's admin console. Two ways in:

  • Portal: App Service → Development Tools → Advanced Tools → Go → in Kudu, Tools → Zip Push Deploy.
  • Direct URL: https://<your-app-service>.scm.azurewebsites.net/ZipDeployUI

Confirm the URL ends in /ZipDeployUI before you drop the file. There's a similar-looking File Manager / Debug Console page — if you drop a zip there, it just uploads the zip as a file without extracting or deploying it, and silently "succeeds." The /ZipDeployUI page says "Drag a zip here to deploy," extracts into /site/wwwroot, and restarts the app.

Drag deploy.zip on → watch the deployment log scroll → it ends with success.

Verify

Open https://<your-app-service>.azurewebsites.net. First load can take 30–60s (cold start). You should see "Your bot is ready!"

Now go back to the Azure Bot resource and confirm its messaging endpoint points at this App Service's /api/messages. Use the Bot resource's Test in Web Chat blade to send "hello" — if it echoes/answers, your endpoint wiring is correct and you're ready for Teams.


Part 6 — Enable the Teams channel and push to Teams

Enable the channel

On the Azure Bot resourceChannels → add Microsoft Teams. That's the switch that lets Teams route messages to your endpoint.

Build the Teams app package

A Teams app is a zip containing three files:

teams-app.zip
├── manifest.json
├── color.png     (192×192)
└── outline.png   (32×32)
Enter fullscreen mode Exit fullscreen mode

Icon requirements (get these wrong and upload fails):

  • color.png192×192 px. Keep the actual mark within a 96×96 safe area in the centre so it isn't clipped.
  • outline.png32×32 px, white-only on a transparent background. This is the monochrome icon Teams shows in the rail.

A minimal manifest.json:

{
  "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.16/MicrosoftTeams.schema.json",
  "manifestVersion": "1.16",
  "version": "1.0.0",
  "id": "<your-app-id>",
  "packageName": "com.example.teamsbot",
  "developer": {
    "name": "Your Team",
    "websiteUrl": "https://example.com",
    "privacyUrl": "https://example.com/privacy",
    "termsOfUseUrl": "https://example.com/terms"
  },
  "name": { "short": "TeamsBot", "full": "TeamsBot Assistant" },
  "description": {
    "short": "Ask questions, get answers.",
    "full": "A Teams assistant that answers questions from our backend knowledge base."
  },
  "icons": { "color": "color.png", "outline": "outline.png" },
  "accentColor": "#FFFFFF",
  "bots": [
    {
      "botId": "<your-app-id>",
      "scopes": ["personal", "team", "groupchat"],
      "supportsFiles": false,
      "isNotificationOnly": false
    }
  ],
  "permissions": ["identity", "messageTeamMembers"],
  "validDomains": []
}
Enter fullscreen mode Exit fullscreen mode

Notes:

  • id and bots[].botId are both your App Registration App ID.
  • Set accentColor to match your color icon's background (e.g. #FFFFFF for a white background) so the two look consistent.
  • Zip the three files flat (no enclosing folder), same as the deploy zip rule.

Sideload / publish

  • Sideloading (for testing): Teams → Apps → Manage your apps → Upload an app → Upload a custom app → pick your teams-app.zip.
  • Org-wide: submit through the Teams Admin Center. In many tenants sideloading is restricted, so even for testing you may need an admin to approve the app before it appears. Plan for that approval step — it's often the thing standing between "works for me" and "works for everyone."

Once approved and installed, message the bot in Teams. You should get your backend-powered card answer, and within a few minutes you'll see the traffic land in Application Insights.


Appendix — All the config files in one place

TeamsBot.csproj (relevant packages)

<ItemGroup>
  <PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.11" />
  <PackageReference Include="Microsoft.ApplicationInsights.AspNetCore" Version="2.22.0" />
  <PackageReference Include="Microsoft.Bot.Builder.ApplicationInsights" Version="4.23.1" />
  <PackageReference Include="Microsoft.Bot.Builder.Integration.ApplicationInsights.Core" Version="4.23.1" />
  <PackageReference Include="Microsoft.Bot.Builder.Integration.AspNet.Core" Version="4.23.1" />
</ItemGroup>
Enter fullscreen mode Exit fullscreen mode

appsettings.json (placeholders — real values via User Secrets / Key Vault)

{
  "MicrosoftAppType": "SingleTenant",
  "MicrosoftAppId": "<your-app-id>",
  "MicrosoftAppPassword": "<your-secret>",
  "MicrosoftAppTenantId": "<your-tenant-id>",
  "ApplicationInsights": {
    "ConnectionString": "<your-app-insights-connection-string>"
  },
  "Backend": {
    "Endpoint": "<your-backend-endpoint>"
  }
}
Enter fullscreen mode Exit fullscreen mode

manifest.json — see Part 6.


The gotcha checklist (bookmark this)

  • Get the local Emulator green before touching Azure. Isolate code bugs from infra bugs.
  • Pin Microsoft.ApplicationInsights.AspNetCore to 2.22.0. Don't mix in 3.x. Drop the WebApi integration package.
  • TelemetryInitializerMiddleware takes three args: (IHttpContextAccessor, TelemetryLoggerMiddleware, bool).
  • Cache GetMemberAsync (24h TTL, keyed tenantId:teamsUserId) and don't call it twice per turn.
  • Use messageBack buttons, not plain Action.Submit, to dodge the ~15s card-action timeout and its false "Something went wrong" banner.
  • One deterministic card factory as the sole rendering path. Avoid LLM-generated cards on the hot path.
  • Secrets go in Key Vault from day one. Never hardcode, never commit.
  • Deploy via /ZipDeployUI, not the File Manager page. Verify the URL.
  • Zip flat — bot .dll at the archive root; manifest + icons at the app-package root.
  • Icons: color 192×192 (mark in 96×96 safe area), outline 32×32 white-on-transparent.
  • Expect a Teams Admin approval step if sideloading is restricted in your tenant.
  • The Users blade shows hashed IDs; real identity is in customDimensions.fromName / fromId on BotMessageReceived.
  • Trust the terminal dotnet build over IDE error panes.

That's the whole path. Empty folder → live in Teams. If you follow it in order and keep each checkpoint green before moving on, the next person really can do this.

Top comments (0)