Conversational AI has moved from research labs to production pipelines. Today, with Microsoft Bot Framework, Azure AI Services, and .NET 8, any developer can build a bot that understands natural language, maintains context across turns, calls external APIs, and deploys to Teams, Slack, or a web page — all within a single, testable C# codebase.

This guide takes you from first principles to a production-ready, AI-powered bot. We cover the Bot Framework SDK architecture, how to wire in Azure AI Language for intent recognition, dialog orchestration with WaterfallDialog, state management with ConversationState and UserState, integration with Azure OpenAI, multi-channel deployment, and the testing strategies that keep your bot reliable.

Every section is illustrated with working C# code, annotated diagrams, and production guidance drawn from real-world deployments.

Why Bots, Why .NET?

A “bot” is simply an application that communicates through a conversational interface. The conversation channel might be Microsoft Teams, Slack, WhatsApp, SMS, or a custom web chat widget. The user sends a message (called an Activity in Bot Framework terminology); the bot processes it, runs its logic, and sends one or more replies.

Why choose .NET for bot development?

🗡️

Scope of This Article

We target Bot Framework SDK v4 with .NET 8. The patterns shown apply equally to .NET 6 and .NET 7. The AI integrations use Azure AI Language (CLU) and Azure OpenAI (GPT-4o) via their official .NET client libraries.

Bot Framework Architecture

Understanding the request flow is essential before writing a single line of bot code. The diagram below shows the full pipeline from a user message to a bot response.

Bot Framework — End-to-End Request Flow Channel Teams / Slack Web / SMS Activity Azure Bot Service Authentication Channel routing Activity normalisation HTTPS POST ASP.NET Core BotController IBotFrameworkHttpAdapter Middleware pipeline ITurnContext Bot Logic ActivityHandler DialogManager AI Services Reply Channel (user sees reply) State Management ConversationState  •  UserState  •  Azure Blob / Cosmos storage Azure AI Layer (called by Bot Logic) Azure AI Language CLU Intent & entities Azure OpenAI GPT-4o generation QnA Maker / CLU KB FAQ answering Adaptive Dialogs LG + Recognizers
Figure 1 — Full Bot Framework request pipeline: channel → Azure Bot Service → ASP.NET Core adapter → bot logic → AI services → reply.

Project Setup

A Bot Framework bot is an ASP.NET Core web application with a single /api/messages endpoint. The Bot Framework HTTP adapter handles the low-level protocol; you only write the bot logic.

Creating the Project

Shell — scaffold and add packages
# Create a minimal ASP.NET Core project
dotnet new web -n SmartBot -f net8.0
cd SmartBot

# Bot Framework SDK
dotnet add package Microsoft.Bot.Builder
dotnet add package Microsoft.Bot.Builder.Integration.AspNet.Core

# Azure AI Language (CLU / NLP)
dotnet add package Azure.AI.Language.Conversations

# Azure OpenAI
dotnet add package Azure.AI.OpenAI

# State storage (Azure Blob)
dotnet add package Microsoft.Bot.Builder.Azure.Blobs

Program.cs — Wiring Everything Together

C# — Program.cs
using Microsoft.Bot.Builder;
using Microsoft.Bot.Builder.Integration.AspNet.Core;
using Microsoft.Bot.Connector.Authentication;
using SmartBot;

var builder = WebApplication.CreateBuilder(args);

// ── Bot Framework authentication ──────────────────────────
builder.Services.AddSingleton<BotFrameworkAuthentication,
    ConfigurationBotFrameworkAuthentication>();

// ── HTTP adapter (handles protocol, auth, error recovery) ─
builder.Services.AddSingleton<IBotFrameworkHttpAdapter,
    AdapterWithErrorHandler>();

// ── State storage (in-memory for dev; swap for Azure Blob) ─
builder.Services.AddSingleton<IStorage, MemoryStorage>();
builder.Services.AddSingleton<ConversationState>();
builder.Services.AddSingleton<UserState>();

// ── AI services ───────────────────────────────────────────
builder.Services.AddSingleton<LanguageService>();
builder.Services.AddSingleton<OpenAiService>();

// ── The bot itself ────────────────────────────────────────
builder.Services.AddTransient<IBot, SmartAssistantBot>();

var app = builder.Build();

app.UseDefaultFiles();
app.UseStaticFiles();
app.MapControllers();

app.Run();

The Messages Controller

C# — BotController.cs
[Route("api/messages")]
[ApiController]
public class BotController : ControllerBase
{
    private readonly IBotFrameworkHttpAdapter _adapter;
    private readonly IBot _bot;

    public BotController(IBotFrameworkHttpAdapter adapter, IBot bot)
    {
        _adapter = adapter;
        _bot     = bot;
    }

    [HttpPost]
    public async Task PostAsync()
        => await _adapter.ProcessAsync(Request, Response, _bot);
}

The Activity Handler

ActivityHandler is the base class for simple bots. It dispatches incoming activities — messages, conversation updates, reactions — to virtual methods you override. For more complex scenarios you’ll use DialogBot<T> or the Adaptive Dialog runtime.

C# — SmartAssistantBot.cs (simplified)
public class SmartAssistantBot : ActivityHandler
{
    private readonly ConversationState _conversationState;
    private readonly UserState         _userState;
    private readonly LanguageService    _language;
    private readonly OpenAiService      _openAi;
    private readonly DialogSet          _dialogs;

    public SmartAssistantBot(
        ConversationState conversationState,
        UserState userState,
        LanguageService language,
        OpenAiService openAi)
    {
        _conversationState = conversationState;
        _userState         = userState;
        _language          = language;
        _openAi            = openAi;

        // Register dialogs
        var dialogState = conversationState.CreateProperty<DialogState>("DialogState");
        _dialogs = new DialogSet(dialogState);
        _dialogs.Add(new BookingDialog());
        _dialogs.Add(new WeatherDialog());
        _dialogs.Add(new FallbackDialog(openAi));
    }

    protected override async Task OnMessageActivityAsync(
        ITurnContext<IMessageActivity> turnContext,
        CancellationToken cancellationToken)
    {
        // 1. Run NLP to determine intent
        var nlp = await _language.AnalyzeAsync(
            turnContext.Activity.Text, cancellationToken);

        // 2. Route to the appropriate dialog
        var dc = await _dialogs.CreateContextAsync(turnContext, cancellationToken);

        var dialogResult = await dc.ContinueDialogAsync(cancellationToken);

        if (dialogResult.Status == DialogTurnStatus.Empty)
        {
            string dialogId = nlp.TopIntent switch
            {
                "BookFlight"  => BookingDialog.DialogId,
                "GetWeather"  => WeatherDialog.DialogId,
                _              => FallbackDialog.DialogId
            };
            await dc.BeginDialogAsync(dialogId, nlp, cancellationToken);
        }

        // 3. Persist state changes
        await _conversationState.SaveChangesAsync(turnContext, false, cancellationToken);
        await _userState.SaveChangesAsync(turnContext, false, 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("Hello! I'm SmartAssistant. How can I help you today?"),
                    cancellationToken);
            }
        }
    }
}

Integrating Azure AI Language (CLU)

Azure AI Language’s Conversational Language Understanding (CLU) service is the successor to LUIS. You train a project with labeled utterances, and the service returns the top intent and extracted entities for any input string. The .NET SDK makes this a single async call.

Training Your CLU Project

In the Azure Language Studio, create a Conversational Language Understanding project and define intents such as BookFlight, GetWeather, CancelReservation with example utterances. Tag entities like destination, date, and origin. Train and deploy to a named endpoint.

The LanguageService Wrapper

C# — LanguageService.cs
public class NlpResult
{
    public string TopIntent  { get; init; } = string.Empty;
    public double Confidence { get; init; }
    public IDictionary<string, string> Entities { get; init; }
        = new Dictionary<string, string>();
}

public class LanguageService
{
    private readonly ConversationAnalysisClient _client;
    private readonly string _projectName;
    private readonly string _deploymentName;

    public LanguageService(IConfiguration config)
    {
        var endpoint = new Uri(config["AzureAI:Endpoint"]!);
        var credential = new AzureKeyCredential(config["AzureAI:ApiKey"]!);
        _client         = new ConversationAnalysisClient(endpoint, credential);
        _projectName    = config["AzureAI:ProjectName"]!;
        _deploymentName = config["AzureAI:DeploymentName"]!;
    }

    public async Task<NlpResult> AnalyzeAsync(
        string utterance, CancellationToken ct = default)
    {
        var data = new
        {
            analysisInput = new { conversationItem = new
            {
                text = utterance, id = "1", participantId = "user"
            }},
            parameters = new
            {
                projectName    = _projectName,
                deploymentName = _deploymentName,
                stringIndexType = "Utf16CodeUnit"
            },
            kind = "Conversation"
        };

        Response response = await _client.AnalyzeConversationAsync(
            RequestContent.Create(data), ct);

        using var doc = JsonDocument.Parse(response.Content.ToString());
        var prediction = doc.RootElement
            .GetProperty("result")
            .GetProperty("prediction");

        var topIntent  = prediction.GetProperty("topIntent").GetString() ?? "None";
        var confidence = prediction
            .GetProperty("intents")[0]
            .GetProperty("confidenceScore").GetDouble();

        // Extract entities into a flat dictionary
        var entities = new Dictionary<string, string>();
        if (prediction.TryGetProperty("entities", out var entArr))
        {
            foreach (var ent in entArr.EnumerateArray())
            {
                var cat = ent.GetProperty("category").GetString() ?? "";
                var txt = ent.GetProperty("text").GetString()     ?? "";
                entities[cat] = txt;
            }
        }

        return new NlpResult
        {
            TopIntent  = topIntent,
            Confidence = confidence,
            Entities   = entities
        };
    }
}
⚠️

API Key Security

Never hard-code Azure API keys in source code. Store them in Azure Key Vault (production) or dotnet user-secrets (development). Rotate keys periodically and use Managed Identity where possible to eliminate keys entirely.

Dialog Orchestration

Dialogs are the core abstraction for managing multi-turn conversations. A WaterfallDialog chains a sequence of steps; each step can prompt the user, branch on input, call child dialogs, or call external services.

WaterfallDialog — Flight Booking Flow Step 1 Ask destination TextPrompt Step 2 Ask travel date DateTimePrompt Step 3 Confirm booking ConfirmPrompt Step 4 Call booking API End / return result Done DialogTurnResult Status.Complete Validation fails → re-prompt same step Cancel Flow User says “cancel” at any step → CancelAndHelpDialog intercepts → CancelAllDialogsAsync → clean teardown
Figure 2 — WaterfallDialog step sequence with validation retry loop and cancel intercept.

Implementing a WaterfallDialog

C# — BookingDialog.cs
public class BookingDialog : ComponentDialog
{
    public const string DialogId = "BookingDialog";

    private const string DestinationKey = "destination";
    private const string DateKey        = "travelDate";

    public BookingDialog() : base(DialogId)
    {
        AddDialog(new TextPrompt("destinationPrompt"));
        AddDialog(new DateTimePrompt("datePrompt", ValidateDateAsync));
        AddDialog(new ConfirmPrompt("confirmPrompt"));

        AddDialog(new WaterfallDialog("waterfall", new WaterfallStep[]
        {
            AskDestinationAsync,
            AskDateAsync,
            ConfirmBookingAsync,
            FinalizeBookingAsync
        }));

        InitialDialogId = "waterfall";
    }

    private static async Task<DialogTurnResult> AskDestinationAsync(
        WaterfallStepContext sc, CancellationToken ct)
    {
        // If NLP pre-filled the destination entity, skip the prompt
        if (sc.Options is NlpResult nlp && nlp.Entities.TryGetValue("destination", out var dest))
        {
            sc.Values[DestinationKey] = dest;
            return await sc.NextAsync(dest, ct);
        }

        return await sc.PromptAsync("destinationPrompt",
            new PromptOptions { Prompt = MessageFactory.Text("Where would you like to fly?") }, ct);
    }

    private static async Task<DialogTurnResult> AskDateAsync(
        WaterfallStepContext sc, CancellationToken ct)
    {
        sc.Values[DestinationKey] = (string)sc.Result;

        return await sc.PromptAsync("datePrompt",
            new PromptOptions
            {
                Prompt        = MessageFactory.Text($"When do you want to fly to {sc.Values[DestinationKey]}?"),
                RetryPrompt   = MessageFactory.Text("Please provide a valid future date.")
            }, ct);
    }

    private static async Task<DialogTurnResult> ConfirmBookingAsync(
        WaterfallStepContext sc, CancellationToken ct)
    {
        sc.Values[DateKey] = ((IList<DateTimeResolution>)sc.Result)[0].Value;

        var msg = $"Book a flight to {sc.Values[DestinationKey]} on {sc.Values[DateKey]}?";
        return await sc.PromptAsync("confirmPrompt",
            new PromptOptions { Prompt = MessageFactory.Text(msg) }, ct);
    }

    private static async Task<DialogTurnResult> FinalizeBookingAsync(
        WaterfallStepContext sc, CancellationToken ct)
    {
        if ((bool)sc.Result)
        {
            // TODO: call real booking API here
            await sc.Context.SendActivityAsync(
                MessageFactory.Text($"✓ Booking confirmed: {sc.Values[DestinationKey]} on {sc.Values[DateKey]}."), ct);
        }
        else
        {
            await sc.Context.SendActivityAsync(
                MessageFactory.Text("Booking cancelled. Let me know if I can help with anything else."), ct);
        }

        return await sc.EndDialogAsync(null, ct);
    }

    private static Task<bool> ValidateDateAsync(
        PromptValidatorContext<IList<DateTimeResolution>> ctx,
        CancellationToken ct)
    {
        if (!ctx.Recognized.Succeeded) return Task.FromResult(false);
        var value = ctx.Recognized.Value[0].Value;
        return Task.FromResult(DateTime.TryParse(value, out var dt) && dt > DateTime.UtcNow);
    }
}

Azure OpenAI Fallback

When the NLP model returns None intent or low confidence, falling back to a large language model (LLM) provides a graceful, human-like response rather than an unhelpful “I don’t understand.” The FallbackDialog streams a GPT-4o completion while maintaining a system persona.

C# — OpenAiService.cs
public class OpenAiService
{
    private readonly OpenAIClient _client;
    private readonly string _deployment;

    private const string SystemPrompt =
        """
        You are SmartAssistant, a helpful, concise, and friendly airline booking assistant.
        You specialise in flight bookings, travel queries, and weather information.
        Keep answers under 3 sentences unless more detail is explicitly requested.
        """;

    public OpenAiService(IConfiguration config)
    {
        _client     = new OpenAIClient(
            new Uri(config["AzureOpenAI:Endpoint"]!),
            new AzureKeyCredential(config["AzureOpenAI:ApiKey"]!));
        _deployment = config["AzureOpenAI:Deployment"]!;
    }

    public async Task<string> GetReplyAsync(
        string userMessage,
        IEnumerable<ChatMessage>? history = null,
        CancellationToken ct = default)
    {
        var messages = new List<ChatMessage>
        {
            new SystemChatMessage(SystemPrompt)
        };

        if (history is not null)
            messages.AddRange(history);

        messages.Add(new UserChatMessage(userMessage));

        var options = new ChatCompletionsOptions(_deployment, messages)
        {
            MaxTokens   = 512,
            Temperature = 0.7f
        };

        Response<ChatCompletions> response =
            await _client.GetChatCompletionsAsync(options, ct);

        return response.Value.Choices[0].Message.Content;
    }
}
💡

Conversation History for Context

Pass the last N turns from ConversationState as the history parameter to maintain coherent multi-turn LLM conversations. Keep the window small (6–10 turns) to control token usage and latency.

State Management

Bots are stateless HTTP endpoints by nature — each request is independent. Bot Framework provides a structured state layer that abstracts the storage backend completely. You choose the granularity: ConversationState (shared across all users in one conversation), UserState (per user, across conversations), or a custom scoped state.

State type Scope Typical use Storage key
ConversationState Channel + conversation ID DialogState, current flow context channelId/conversationId
UserState Channel + user ID User profile, preferences, history channelId/userId
Custom BotState Defined by you Cross-conversation analytics, A/B flags Custom key function

Accessing State Properties

C# — Reading and writing UserState
public class UserProfile
{
    public string? Name          { get; set; }
    public string? PreferredCity { get; set; }
    public int      TurnCount     { get; set; }
}

// In the bot constructor — create a typed property accessor
private readonly IStatePropertyAccessor<UserProfile> _profileAccessor;

SmartAssistantBot(UserState userState, /* ... */)
{
    _profileAccessor = userState.CreateProperty<UserProfile>("UserProfile");
}

// In OnMessageActivityAsync — read, mutate, save
var profile = await _profileAccessor.GetAsync(
    turnContext, () => new UserProfile(), cancellationToken);

profile.TurnCount++;

if (string.IsNullOrEmpty(profile.Name))
    profile.Name = turnContext.Activity.From.Name;

// SaveChangesAsync (called at end of turn) persists to storage
await _userState.SaveChangesAsync(turnContext, false, cancellationToken);
📅

Production Storage

Replace MemoryStorage with CosmosDbPartitionedStorage or BlobsStorage for production. Both are drop-in replacements — only the DI registration changes. MemoryStorage loses all state on app restart and does not scale across multiple instances.

Rich Replies with Adaptive Cards

Plain text replies are fine for simple responses, but bots can send rich UI components known as Adaptive Cards — JSON-described layouts with text, images, buttons, and input fields that render natively in each supported channel.

C# — Sending an Adaptive Card
using AdaptiveCards;
using Microsoft.Bot.Schema;

public static IActivity BuildWeatherCard(string city, string temp, string condition)
{
    var card = new AdaptiveCard(new AdaptiveSchemaVersion(1, 5))
    {
        Body =
        [
            new AdaptiveColumnSet
            {
                Columns =
                [
                    new AdaptiveColumn
                    {
                        Items = [new AdaptiveTextBlock
                        {
                            Text   = city,
                            Size   = AdaptiveTextSize.ExtraLarge,
                            Weight = AdaptiveTextWeight.Bolder
                        }]
                    },
                    new AdaptiveColumn
                    {
                        Items = [new AdaptiveTextBlock
                        {
                            Text = $"{temp} — {condition}",
                            HorizontalAlignment = AdaptiveHorizontalAlignment.Right
                        }]
                    }
                ]
            }
        ],
        Actions =
        [
            new AdaptiveSubmitAction { Title = "5-day forecast", Data = new { action = "forecast", city } },
            new AdaptiveSubmitAction { Title = "Dismiss",       Data = new { action = "dismiss"       } }
        ]
    };

    var attachment = new Attachment
    {
        ContentType = AdaptiveCard.ContentType,
        Content     = JsonSerializer.Deserialize<object>(card.ToJson())
    };

    return MessageFactory.Attachment(attachment);
}

Multi-Channel Deployment

One of Bot Framework’s strongest benefits is the write once, deploy everywhere model. You register your bot endpoint in Azure Bot Service and enable channels from the portal. The adapter normalises channel-specific Activity formats so your bot code remains channel-agnostic.

1

Deploy to Azure App Service

Publish your ASP.NET Core bot to Azure App Service or Azure Container Apps. Ensure HTTPS is enabled — the Bot Service connector requires it.

2

Register in Azure Bot Service

Create an Azure Bot resource. Set the Messaging Endpoint to https://<your-app>.azurewebsites.net/api/messages. Copy the App ID and secret to app settings.

3

Enable Channels

Open the Channels blade. Connect Microsoft Teams, Web Chat, Slack, Twilio SMS, or any supported channel with a few clicks. Each generates a channel-specific token.

4

Test in Web Chat

Use the built-in Test in Web Chat pane in the Azure portal to validate your bot before distributing links or publishing to a Teams app store.

Channel-Specific Handling

Some channels support features others don’t. Use turnContext.Activity.ChannelId to conditionally send richer responses:

C# — Channel-aware reply
var reply = turnContext.Activity.ChannelId switch
{
    Channels.Msteams  => BuildWeatherCard(city, temp, condition),  // Adaptive Card
    Channels.Webchat  => BuildWeatherCard(city, temp, condition),  // Adaptive Card
    _                  => MessageFactory.Text($"{city}: {temp}, {condition}") // plain text
};

await turnContext.SendActivityAsync(reply, cancellationToken);

Testing Your Bot

Bot Framework provides Microsoft.Bot.Builder.Testing which gives you a DialogTestClient that drives dialogs in-process without a real HTTP endpoint. Combined with xUnit and Moq, you can achieve high test coverage on dialog logic and state transitions.

🟢 Unit test with DialogTestClient

  • Fast, in-process, no network
  • Full dialog state inspection
  • Deterministic — no LLM variability
  • CI-friendly, no Azure credentials needed

🔴 Manual testing only in portal

  • Slow feedback loop
  • Cannot test edge cases reliably
  • No regression detection
  • Requires live Azure resources
C# — BookingDialogTests.cs (xUnit)
public class BookingDialogTests
{
    [Fact]
    public async Task BookingDialog_HappyPath_ConfirmsBooking()
    {
        var testClient = new DialogTestClient(
            Channels.Test,
            new BookingDialog(),
            middlewares: [ new TestLocaleMiddleware("en-US") ]);

        // Step 1: bot asks for destination
        var reply = await testClient.SendActivityAsync<IMessageActivity>("book a flight");
        Assert.Contains("Where would you like to fly", reply.Text);

        // Step 2: user provides destination
        reply = await testClient.SendActivityAsync<IMessageActivity>("Paris");
        Assert.Contains("Paris", reply.Text);

        // Step 3: user provides date
        reply = await testClient.SendActivityAsync<IMessageActivity>("next Friday");
        Assert.Contains("confirm", reply.Text.ToLower());

        // Step 4: user confirms
        reply = await testClient.SendActivityAsync<IMessageActivity>("yes");
        Assert.Contains("confirmed", reply.Text.ToLower());
        Assert.Equal(DialogTurnStatus.Complete, testClient.DialogTurnResult.Status);
    }

    [Fact]
    public async Task BookingDialog_InvalidDate_RePromptsUser()
    {
        var testClient = new DialogTestClient(Channels.Test, new BookingDialog());

        await testClient.SendActivityAsync<IMessageActivity>("book a flight");
        await testClient.SendActivityAsync<IMessageActivity>("Tokyo");

        // Invalid date — bot should re-prompt
        var retry = await testClient.SendActivityAsync<IMessageActivity>("yesterday");
        Assert.Contains("valid future date", retry.Text);
        Assert.NotEqual(DialogTurnStatus.Complete, testClient.DialogTurnResult.Status);
    }
}

Production Best Practices

🔒
Use Managed Identity for Azure services Eliminate API keys by assigning the bot’s App Service a managed identity with Cognitive Services / OpenAI roles
📦
Persist state to Azure Cosmos DB Replace MemoryStorage before any multi-instance deployment or restart
📄
Log all activities to Application Insights Use TelemetryInitializerMiddleware to capture turns, intents, and latency automatically
Implement graceful error handling in the adapter AdapterWithErrorHandler should catch exceptions, log them, and send a user-friendly apology
🧪
Rate-limit LLM calls Use Polly retry with exponential backoff for Azure OpenAI — avoid cascading failures under load
💡
Validate all user input before NLP Sanitise input length and encoding at the controller level; never pass raw user text directly to database queries
🛠
Version your dialogs Store a schema version in ConversationState; migrate or reset stale state when upgrading dialog structure
🚀
Enable proactive messaging carefully Store conversation references to initiate bot messages; respect channel throttle limits and user opt-out

Conclusion

Bot development with AI and .NET in 2026 is a mature, production-ready stack. Microsoft Bot Framework v4 gives you a structured, testable foundation; Azure AI Language takes care of intent recognition; Azure OpenAI provides a powerful fallback for open-ended queries; and ASP.NET Core ties it all together with the DI, configuration, and middleware patterns you already know.

The key points to carry forward:

🧠

About MindWeave Engineering

MindWeave Engineering publishes deep-dive articles on .NET, AI, and systems programming. Our goal is production-quality guidance backed by real-world experience — not just documentation restatements. Explore the rest of our series for more articles on C#, Azure, and modern application architecture.