DEV Community

Cover image for Building Enterprise-Grade Teams Bots with .NET 10
Avinash Hedaoo
Avinash Hedaoo

Posted on

Building Enterprise-Grade Teams Bots with .NET 10

Where This Fits

Part 1 drew the line between a Teams app, a bot, and an agent. This part is about shipping the middle one: a structured, turn-based bot built on ASP.NET Core and the Bot Framework SDK, using .NET 9 and the Teams AI Library where appropriate. If your workflow is deterministic enough to draw as a decision tree, a scripted bot is still the most auditable and cost-effective option in 2026. And because it remains the conceptual foundation for the Microsoft 365 Agents SDK, understanding this lifecycle is still essential.

Project Scaffolding and the 2026 Tech Stack

The Microsoft Teams Toolkit remains the fastest path to a working scaffold. It wires up the app manifest, the Azure Bot registration, and the ASP.NET Core project structure in one step.

teamsapp new
# Select: Bot -> AI Bot (or Basic Bot) -> .NET (C#) -> .NET 10
Enter fullscreen mode Exit fullscreen mode

Your scaffold typically includes a controller, an adapter with error handling, and a class inheriting from TeamsActivityHandler. The important part is not the boilerplate — it is the execution contract you define inside the handler.

The Ingress Pipeline

An incoming Teams activity arrives through the Bot Framework adapter and is dispatched to your handler. In production, that request should be treated as an ingress boundary: validate it, normalize it, and hand off the actual work to application services. Keep the handler thin and deterministic.

The ActivityHandler Lifecycle

Every bot in this stack inherits from TeamsActivityHandler. The framework abstracts the raw JSON payload and routes it to the appropriate virtual methods.

public class EnterpriseTeamsBot : TeamsActivityHandler
{
    private readonly ITicketService _ticketService;

    public EnterpriseTeamsBot(ITicketService ticketService)
    {
        _ticketService = ticketService;
    }

    protected override async Task OnMessageActivityAsync(
        ITurnContext<IMessageActivity> turnContext,
        CancellationToken cancellationToken)
    {
        if (turnContext.Activity.Value != null)
        {
            await HandleCardSubmitAsync(turnContext, turnContext.Activity.Value, cancellationToken);
            return;
        }

        var text = turnContext.Activity.Text?.Trim();

        if (string.Equals(text, "status", StringComparison.OrdinalIgnoreCase))
        {
            await turnContext.SendActivityAsync(
                MessageFactory.Text("All downstream systems are operational."),
                cancellationToken);
            return;
        }

        await turnContext.SendActivityAsync(
            MessageFactory.Text($"Echo: {text}. Type 'status' for system diagnostics."),
            cancellationToken);
    }

    protected override async Task OnMembersAddedAsync(
        IList<ChannelAccount> membersAdded,
        ITurnContext<IConversationUpdateActivity> turnContext,
        CancellationToken cancellationToken)
    {
        foreach (var member in membersAdded)
        {
            if (member.Id != turnContext.Activity.Recipient.Id)
            {
                await turnContext.SendActivityAsync(
                    MessageFactory.Text("Welcome to the operations bot. Send 'status' to get started."),
                    cancellationToken);
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Production rule: always check turnContext.Activity.Value before falling back to text parsing. When users click a card button, Teams sends the interaction payload in Value and leaves Text empty.

Message Extensions: Search vs. Action Commands

A message extension is not a chat conversation — it is a UI surfaced from the compose box or the ... command menu, and it has its own contract. There are two shapes:

  • Search commands return results as the user types. They need to be fast, low-latency, and optimized for quick feedback.
  • Action commands open a task module or a modal experience and can afford a slower round trip because the user has explicitly initiated the flow.
protected override async Task<MessagingExtensionResponse> OnTeamsMessagingExtensionQueryAsync(
    ITurnContext<IInvokeActivity> turnContext,
    MessagingExtensionQuery query,
    CancellationToken cancellationToken)
{
    var searchTerm = query.Parameters?.FirstOrDefault(p => p.Name == "query")?.Value?.ToString() ?? string.Empty;
    var results = await _ticketService.SearchTicketsAsync(searchTerm);

    var attachments = results.Select(ticket => new MessagingExtensionAttachment
    {
        ContentType = HeroCard.ContentType,
        Content = new HeroCard { Title = ticket.Title, Subtitle = $"Status: {ticket.Status}" }.ToAttachment().Content,
        Preview = new HeroCard { Title = ticket.Title }.ToAttachment()
    }).ToList();

    return new MessagingExtensionResponse
    {
        ComposeExtension = new MessagingExtensionResult
        {
            Type = "result",
            AttachmentLayout = "list",
            Attachments = attachments
        }
    };
}
Enter fullscreen mode Exit fullscreen mode

The distinction matters because it changes your API design. Search commands must stay responsive enough to support live typing; action commands can be heavier because they are gated behind explicit user submission.

Production-Grade Adaptive Cards

Every non-trivial bot response should be an Adaptive Card, not raw text. It gives you structured layout, interactive buttons, and a consistent rendering surface across Teams desktop, web, and mobile. In production, however, you should not build those cards inline as large C# object graphs. Separate visual layout from execution logic with strongly typed templates.

var templateJson = await File.ReadAllTextAsync("Cards/TicketCard.json", cancellationToken);
var template = new AdaptiveCardTemplate(templateJson);

var cardData = new
{
    Title = ticket.Title,
    Status = ticket.Status,
    Id = ticket.Id
};

var boundCardJson = template.Expand(cardData);

var attachment = new Attachment
{
    ContentType = "application/vnd.microsoft.card.adaptive",
    Content = JsonSerializer.Deserialize<JsonElement>(boundCardJson)
};

await turnContext.SendActivityAsync(MessageFactory.Attachment(attachment), cancellationToken);
Enter fullscreen mode Exit fullscreen mode

This is the production-friendly pattern: keep your card schema in a template file or embedded resource, bind it to a strongly typed data object, and let the rendering layer stay clean and reusable.

State and Memory Efficiency in .NET 9

A bot should not keep unnecessary state in memory just because it can. Conversation state should stay minimal, explicit, and serializable. Avoid storing large business objects, full API payloads, or long-lived in-memory caches inside your handler unless you truly need them. For lightweight state, use scoped state accessors; for enterprise workloads, move to distributed storage such as Redis or Cosmos DB when the session footprint grows.

The guiding principle is simple: keep the bot handler stateless, and let your services own the durable business context.

File Upload Consent Flow

File handling in Teams bots is not a direct binary upload. It is a secure two-step consent flow. The bot asks the user for permission, Teams allocates a temporary storage location, and only after the user accepts does the bot push the file to the returned upload URL.

protected override async Task OnTeamsFileConsentAcceptAsync(
    ITurnContext<IInvokeActivity> turnContext,
    FileConsentCardResponse fileConsentCardResponse,
    CancellationToken cancellationToken)
{
    using var httpClient = new HttpClient();
    var fileBytes = await File.ReadAllBytesAsync(_localFilePath, cancellationToken);

    using var content = new ByteArrayContent(fileBytes);
    content.Headers.ContentRange = new ContentRangeHeaderValue(0, fileBytes.Length - 1, fileBytes.Length);

    var response = await httpClient.PutAsync(fileConsentCardResponse.UploadInfo.UploadUrl, content, cancellationToken);
    response.EnsureSuccessStatusCode();

    await turnContext.SendActivityAsync(
        MessageFactory.Text($"Successfully uploaded: {fileConsentCardResponse.UploadInfo.Name}."),
        cancellationToken);
}
Enter fullscreen mode Exit fullscreen mode

The upload URL is short-lived and single-use. If a timeout or network failure occurs, do not retry against the same endpoint — restart the consent flow instead.

Authentication: Identity Over Secrets

Every enterprise bot needs to authenticate to the Azure Bot Service gateway, but the modern approach is not to rely on a long-lived client secret. In 2026, production-grade deployments should prefer User-Assigned Managed Identity where possible.

{
  "MicrosoftAppType": "UserAssignedMSI",
  "MicrosoftAppId": "your-managed-identity-client-id",
  "MicrosoftAppPassword": "",
  "MicrosoftAppTenantId": "your-tenant-id"
}
Enter fullscreen mode Exit fullscreen mode

This removes secret rotation overhead, reduces credential leak risk, and allows the runtime to acquire the required tokens through the hosting environment itself.

What's Next

Part 3 compares this exact structural foundation against the Microsoft 365 Agents SDK at the code level. We will rebuild the same status and ticketing workflows as an autonomous agent and show how the orchestration loop, tool registry, and state management change when the system is allowed to plan instead of merely respond.


This is Part 2 of the "Building Intelligent Microsoft Teams Applications with .NET & Azure" series, part of the Emerging Tech & Architecture Series.

Top comments (0)