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:
- A .NET 8 bot backend that echoes, then calls your backend and renders rich cards.
- Application Insights telemetry so you can see who's using it and what they ask.
- An Azure Bot resource with the Teams channel enabled.
- 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)
- 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 an8.0.xxxline). - 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 buildfrom 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
Create the project
dotnet new echobot -n TeamsBot
cd TeamsBot
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
What each file does:
-
BotController.csexposesPOST /api/messages. Azure Bot Service hits this endpoint every time a user sends a message. You rarely edit it. -
EchoBot.csinherits fromActivityHandler. It has overridable methods likeOnMessageActivityAsync(user sent a message) andOnMembersAddedAsync(bot was added to a chat). This is where your logic goes. -
Program.cs/Startup.csregister the bot, the adapter, and your services in dependency injection. -
appsettings.jsonholdsMicrosoftAppId,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);
}
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
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);
}
}
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:
- 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).
- 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;
}
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
}
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
}
}
};
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);
}
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.AspNetCoreto2.22.0. This transitively pulls the correct 2.x base package — you don't referenceMicrosoft.ApplicationInsightsdirectly. - Do not add the 3.x packages (
Microsoft.ApplicationInsights3.x,Microsoft.ApplicationInsights.AspNetCore3.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>
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));
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>"
}
}
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
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:
-
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.
- Azure Bot resource → the registration. Create it, and set its messaging endpoint to:
https://<your-app-service>.azurewebsites.net/api/messages
(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>"
}
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"
And guard against accidents:
echo "appsettings.json" >> .gitignore
echo "appsettings.Development.json" >> .gitignore
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 ..
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 resource → Channels → 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)
Icon requirements (get these wrong and upload fails):
-
color.png— 192×192 px. Keep the actual mark within a 96×96 safe area in the centre so it isn't clipped. -
outline.png— 32×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": []
}
Notes:
-
idandbots[].botIdare both your App Registration App ID. - Set
accentColorto match your color icon's background (e.g.#FFFFFFfor 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>
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>"
}
}
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.AspNetCoreto2.22.0. Don't mix in 3.x. Drop the WebApi integration package. -
TelemetryInitializerMiddlewaretakes three args:(IHttpContextAccessor, TelemetryLoggerMiddleware, bool). -
Cache
GetMemberAsync(24h TTL, keyedtenantId:teamsUserId) and don't call it twice per turn. -
Use
messageBackbuttons, not plainAction.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
.dllat 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/fromIdonBotMessageReceived. -
Trust the terminal
dotnet buildover 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)