Building Your First AI Chatbot in C# with Semantic Kernel
Semantic Kernel is Microsoft's open-source SDK for building AI applications in .NET, and "chatbot with skills" is its home turf: conversation state, plugin functions the model can call, prompt management, and provider independence, all in idiomatic C#. In this tutorial we'll build a working support chatbot for a fictional store — one that can actually look things up rather than hallucinate answers.
By the end you'll have: a chat loop with memory, a plugin the model calls on its own, and streaming responses — the skeleton of every real chatbot I've shipped.
Setup
dotnet new console -n StoreBot
cd StoreBot
dotnet add package Microsoft.SemanticKernel
The kernel is the DI container of the AI world — you register capabilities on it, then ask it to run things:
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.ChatCompletion;
var builder = Kernel.CreateBuilder();
builder.AddOpenAIChatCompletion(
modelId: "gpt-4o-mini",
apiKey: Environment.GetEnvironmentVariable("OPENAI_API_KEY")!);
Kernel kernel = builder.Build();
(Swap AddOpenAIChatCompletion for AddAzureOpenAIChatCompletion or a local Ollama endpoint without touching anything below — that's the provider independence working.)
The chat loop with memory
var chat = kernel.GetRequiredService<IChatCompletionService>();
var history = new ChatHistory(
"""
You are Thabo, the support assistant for TechTrove, an online electronics
store in South Africa. Be friendly and concise. Use the available tools
to answer questions about orders and stock — never guess.
If you can't help, offer to escalate to a human at support@techtrove.example.
""");
while (true)
{
Console.Write("You: ");
var input = Console.ReadLine();
if (string.IsNullOrWhiteSpace(input)) break;
history.AddUserMessage(input);
var reply = await chat.GetChatMessageContentAsync(history, kernel: kernel);
history.AddAssistantMessage(reply.Content!);
Console.WriteLine($"Thabo: {reply.Content}");
}
ChatHistory is the memory — the model itself remembers nothing between calls. Two production notes on that: the history grows (and costs tokens) every turn, so long conversations need trimming or summarizing; and in a web app you'd persist history per session/user rather than in a local variable.
Plugins: where chatbots become useful
A chatbot that only chats is a toy. Plugins give the model functions it can decide to call:
using System.ComponentModel;
public class OrderPlugin
{
[KernelFunction]
[Description("Gets the status and expected delivery date of an order.")]
public OrderStatusResult GetOrderStatus(
[Description("The order number, e.g. TT-10293")] string orderNumber)
{
// Real implementation: query your database / order service
return Orders.Lookup(orderNumber)
?? new OrderStatusResult("Unknown", "Order not found");
}
[KernelFunction]
[Description("Checks how many units of a product are in stock.")]
public int GetStockLevel([Description("Product SKU")] string sku)
=> Inventory.Count(sku);
}
public record OrderStatusResult(string Status, string Detail);
Register it and enable automatic invocation:
builder.Plugins.AddFromType<OrderPlugin>();
var settings = new OpenAIPromptExecutionSettings
{
FunctionChoiceBehavior = FunctionChoiceBehavior.Auto()
};
var reply = await chat.GetChatMessageContentAsync(
history, settings, kernel);
Now the conversation does this, with no routing code from you:
You: Where's my order TT-10293?
Thabo: Your order TT-10293 has shipped and should arrive by Thursday, 6 August.
Under the hood, the model saw the question, decided to call GetOrderStatus("TT-10293"), Semantic Kernel invoked your C# method, fed the result back, and the model wrote the human answer. The [Description] attributes are load-bearing — they're the only documentation the model gets, so write them like you're explaining the function to a new teammate.
The safety rules for plugins
The moment the model can call your code, you're on the hook for what that code can do:
- Expose read operations freely; guard writes. Cancelling an order? Require an explicit confirmation turn ("Should I go ahead and cancel TT-10293?") and check the user's actual permissions in the plugin — never trust the model to enforce authorization.
- Validate inputs like any API endpoint. The arguments come from a model interpreting user text; treat them as untrusted user input, because that's what they are.
- Return structured results (records, not prose strings) so the model gets clean facts to phrase, and log every invocation — "why did the bot say that?" is a question you will be asked.
Streaming for a real UI
For a console demo, the wait is fine. For users, stream:
await foreach (var chunk in chat.GetStreamingChatMessageContentsAsync(
history, settings, kernel))
{
Console.Write(chunk.Content);
}
In ASP.NET Core, forward those chunks over SSE or SignalR. The pattern is identical; only the transport changes.
Where this skeleton goes next
You've now got the three pillars — memory, tools, streaming. Real deployments add, roughly in order:
- Grounding in your documents so the bot answers from your knowledge base instead of its training data — that's Retrieval-Augmented Generation.
- Persistence of chat history (database keyed by session) and a token-budget trimming strategy.
- Evaluation — a test set of expected question/answer behaviors you run when you change prompts or models, because prompt changes are code changes.
Semantic Kernel scales up with you: the same kernel handles multi-step agent orchestration and planners when you need them. But don't start there — start with this skeleton, ship the boring version, and let real user questions tell you which fancy features you actually need.
Where to go next
- RAG Explained for .NET Developers — grounding your bot in your own documents.
- LLM Function Calling in C# — the mechanics and security of tool calling, in depth.
- Getting Started with Microsoft.Extensions.AI — the lighter-weight alternative when you don't need full SK orchestration.