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?
- First-class SDK support — Microsoft Bot Framework SDK v4 is authored in C# and maintained by the same team that builds the Azure Bot Service
- Native Azure integration — Azure AI Language, Azure OpenAI, Azure Cosmos DB, and Azure Storage all have idiomatic .NET SDKs
- ASP.NET Core hosting — bots are just HTTP endpoints; everything you know about middleware, dependency injection, and configuration applies
- Strong typing — dialog models, intent entities, and bot state are all first-class C# classes, making refactoring and testing straightforward
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.
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
# 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
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
[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.
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
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.
Implementing a WaterfallDialog
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.
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
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.
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.
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.
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.
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.
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:
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
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
TelemetryInitializerMiddleware to capture turns, intents, and latency automatically
AdapterWithErrorHandler should catch exceptions, log them, and send a user-friendly apology
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:
- Understand the Activity model — everything flows through
ITurnContext; master it and the rest follows naturally - Use WaterfallDialogs for structured flows — they handle prompt retry, validation, and cancellation out of the box
- Separate NLP from dialog logic — design the bot so the intent recogniser is swappable without rewriting dialogs
- LLM as a fallback, not a crutch — structure dialogs for known intents; use GPT for the long tail and open-ended conversation
- Test with
DialogTestClient— every dialog step should have unit-test coverage before the bot goes to staging - Production state needs persistent storage — swap
MemoryStoragefor Cosmos DB or Azure Blob on day one
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.