
Learn how to build AI agents in C# with Semantic Kernel. Step-by-step .NET tutorial with runnable code, plugins, tool calling and best practices. Start building today.
Building AI agents in C# used to mean gluing together HTTP calls, JSON schemas and a lot of hopeful string parsing. That era is over. With Microsoft Semantic Kernel, .NET developers now have a first-class agent framework that handles tool calling, memory, planning and multi-agent collaboration — all with strongly typed C# code, dependency injection and the async patterns you already know. In this tutorial you'll learn how to build autonomous, task-solving AI agents in C# from scratch, understand why each piece exists, and avoid the pitfalls that burn most teams on their first production deployment.
What Is an AI Agent (and How Is It Different From a Chatbot)?
A chatbot answers. An agent acts. The practical difference comes down to one loop:
- Goal — the agent receives a task, not just a message ("reconcile yesterday's failed payments").
- Reason — the LLM decides what information or action it needs next.
- Act — it calls a tool: your C# method, a REST API, a database query.
- Observe — the tool result is fed back into the conversation.
- Repeat — until the goal is met or a stop condition fires.
That loop is what makes an agent autonomous. Semantic Kernel implements it for you through automatic function calling, so you write the tools and the framework runs the cycle.
Why Semantic Kernel for .NET Developers?
You could call the OpenAI or Anthropic REST API directly. Most teams start there and regret it around week three. Semantic Kernel gives you:
- Model portability — swap Azure OpenAI, OpenAI, Ollama or ONNX behind one
Kernelabstraction. - Automatic tool calling — decorate a C# method, and the SDK generates the JSON schema, handles the model's call request, invokes your method, and returns the result.
- DI and telemetry — it's built on
Microsoft.Extensions.*, so logging,IHttpClientFactory, OpenTelemetry and configuration all work normally. - Agent + orchestration primitives — agents, threads, and multi-agent patterns without writing your own state machine.
Installing the Packages
// .NET 8 or later
dotnet new console -n AgentDemo
cd AgentDemo
dotnet add package Microsoft.SemanticKernel
dotnet add package Microsoft.SemanticKernel.Agents.Core
dotnet add package Microsoft.Extensions.Configuration.UserSecrets
Never hardcode API keys. Use user secrets locally and Key Vault or managed identity in production:
dotnet user-secrets init
dotnet user-secrets set "OpenAI:ApiKey" "sk-..."
Your First AI Agent in C#
Start with the smallest thing that works — a ChatCompletionAgent with a name, instructions and a kernel.
using Microsoft.Extensions.Configuration;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Agents;
using Microsoft.SemanticKernel.ChatCompletion;
var config = new ConfigurationBuilder()
.AddUserSecrets<Program>()
.AddEnvironmentVariables()
.Build();
var builder = Kernel.CreateBuilder();
builder.AddOpenAIChatCompletion(
modelId: "gpt-4o-mini",
apiKey: config["OpenAI:ApiKey"]!);
Kernel kernel = builder.Build();
ChatCompletionAgent agent = new()
{
Name = "DevOpsAssistant",
Instructions =
"""
You are a senior .NET DevOps engineer.
Diagnose build and deployment issues precisely.
If you lack data, call a tool rather than guessing.
Answer in under 200 words.
""",
Kernel = kernel
};
AgentThread thread = new ChatHistoryAgentThread();
await foreach (var response in agent.InvokeAsync(
"Our nightly build started failing. Where do I begin?", thread))
{
Console.WriteLine(response.Message.Content);
}
Two concepts matter here. Instructions are the agent's persona and policy — they are sent as the system message on every turn, so keep them short and imperative. AgentThread is the conversation state. Create one per user session; do not share a thread across users, or you will leak one customer's context into another's answer.
Making the Agent Autonomous: Plugins and Tool Calling
An agent without tools is just an expensive text generator. In Semantic Kernel, a plugin is a plain C# class whose methods are marked with [KernelFunction]. The [Description] attributes are not documentation — they are the prompt the model reads to decide when to call your code. Write them as if explaining to a new hire.
using System.ComponentModel;
using Microsoft.SemanticKernel;
public sealed class BuildPlugin(IBuildService builds)
{
[KernelFunction("get_recent_builds")]
[Description("Gets the most recent CI builds for a repository, newest first.")]
public async Task<IReadOnlyList<BuildSummary>> GetRecentBuildsAsync(
[Description("Repository name, e.g. 'payments-api'")] string repository,
[Description("How many builds to return, 1-20")] int count = 5,
CancellationToken ct = default)
{
if (count is < 1 or > 20)
throw new ArgumentOutOfRangeException(nameof(count));
return await builds.GetRecentAsync(repository, count, ct);
}
[KernelFunction("get_build_log")]
[Description("Returns the last N lines of the log for a specific build id.")]
public async Task<string> GetBuildLogAsync(
[Description("The build identifier")] string buildId,
[Description("Number of trailing log lines, max 300")] int tailLines = 100,
CancellationToken ct = default)
{
var log = await builds.GetLogAsync(buildId, ct);
return string.Join('\n', log.TakeLast(Math.Min(tailLines, 300)));
}
}
public record BuildSummary(string Id, string Branch, string Status, DateTimeOffset FinishedAt);
Register the plugin and switch on automatic function calling:
kernel.Plugins.AddFromObject(new BuildPlugin(buildService), "Builds");
ChatCompletionAgent agent = new()
{
Name = "BuildDoctor",
Instructions = "Diagnose CI failures. Always inspect real build data before concluding.",
Kernel = kernel,
Arguments = new KernelArguments(
new OpenAIPromptExecutionSettings
{
FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(),
Temperature = 0.1,
MaxTokens = 800
})
};
AgentThread thread = new ChatHistoryAgentThread();
await foreach (var item in agent.InvokeAsync(
"Why is payments-api failing on main?", thread))
{
Console.WriteLine(item.Message.Content);
}
That single line — FunctionChoiceBehavior.Auto() — is what turns a chat model into an autonomous AI agent in C#. Semantic Kernel now runs the reason–act–observe loop: the model requests get_recent_builds, SK invokes your method, appends the JSON result to the thread, and calls the model again. It repeats until the model produces a final answer.
Controlling the Loop
Autonomy without limits is a billing incident. Constrain it explicitly:
var settings = new OpenAIPromptExecutionSettings
{
// Cap the automatic invocation rounds
FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(
options: new FunctionChoiceBehaviorOptions { AllowParallelCalls = true }),
Temperature = 0.1
};
// Bound the whole operation
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60));
await foreach (var item in agent.InvokeAsync(task, thread, cancellationToken: cts.Token))
{
Console.WriteLine(item.Message.Content);
}
You can also restrict which functions the agent may use on a given turn by passing an explicit function list to FunctionChoiceBehavior.Auto(functions) — useful when a read-only agent must never touch a write tool.
Streaming Responses in ASP.NET Core
Users will not wait 20 seconds staring at a spinner. Stream the output:
app.MapPost("/agent/ask", async (
AskRequest request,
ChatCompletionAgent agent,
IThreadStore threads,
HttpContext http,
CancellationToken ct) =>
{
http.Response.ContentType = "text/event-stream";
AgentThread thread = await threads.GetOrCreateAsync(request.SessionId, ct);
await foreach (var chunk in agent.InvokeStreamingAsync(request.Message, thread, cancellationToken: ct))
{
if (string.IsNullOrEmpty(chunk.Message.Content)) continue;
await http.Response.WriteAsync($"data: {chunk.Message.Content}\n\n", ct);
await http.Response.Body.FlushAsync(ct);
}
});
Note that ct flows all the way into your plugin methods. If the browser disconnects, the SQL query your tool started gets cancelled too. This is the kind of plumbing you get for free in .NET and would have to build by hand elsewhere.
Multi-Agent Orchestration: Specialists Beat Generalists
One agent with thirty tools performs worse than three agents with ten each. The model's tool-selection accuracy degrades as the schema list grows. Split by responsibility:
ChatCompletionAgent researcher = new()
{
Name = "Researcher",
Instructions = "Gather facts using tools. Never propose fixes. Output bullet points only.",
Kernel = researchKernel
};
ChatCompletionAgent reviewer = new()
{
Name = "Reviewer",
Instructions =
"""
Review the researcher's findings and produce a remediation plan.
If evidence is missing, reply exactly: NEEDS_MORE_DATA.
Otherwise end your message with: APPROVED.
""",
Kernel = reviewKernel
};
// Simple, explicit orchestration — easy to test and debug
AgentThread shared = new ChatHistoryAgentThread();
string task = "Diagnose the payments-api build failure on main.";
for (int round = 0; round < 3; round++)
{
await foreach (var r in researcher.InvokeAsync(task, shared)) { }
string verdict = "";
await foreach (var r in reviewer.InvokeAsync("Review the findings.", shared))
verdict = r.Message.Content ?? "";
if (verdict.Contains("APPROVED", StringComparison.Ordinal))
{
Console.WriteLine(verdict);
break;
}
task = "Collect the additional evidence the reviewer requested.";
}
Semantic Kernel also ships higher-level orchestration patterns (sequential, concurrent, handoff and group chat) in Microsoft.SemanticKernel.Agents.Orchestration. Start with the explicit loop above — it is trivially unit-testable — and graduate to the built-in orchestrators when your coordination logic genuinely gets complex.
Best Practices for Production AI Agents in C#
1. Treat tool descriptions as production code
90% of "the agent picked the wrong tool" bugs are description bugs. Be specific about units, formats and boundaries: "Returns balance in cents, not dollars" beats "gets balance".
2. Make every tool idempotent or explicitly guarded
The model can and will retry. A send_invoice tool must accept an idempotency key. For genuinely irreversible actions, require human approval rather than trusting the loop:
kernel.FunctionInvocationFilters.Add(new ApprovalFilter());
public sealed class ApprovalFilter : IFunctionInvocationFilter
{
private static readonly HashSet<string> Sensitive = ["refund_payment", "delete_resource"];
public async Task OnFunctionInvocationAsync(
FunctionInvocationContext context, Func<FunctionInvocationContext, Task> next)
{
if (Sensitive.Contains(context.Function.Name) && !await Approvals.ConfirmAsync(context))
{
context.Result = new FunctionResult(context.Function, "Denied by operator.");
return; // short-circuit: never call next()
}
await next(context);
}
}
3. Set Temperature low for tool-using agents
Creativity helps with prose and hurts with argument selection. Use 0.0–0.2 for agents that call functions.
4. Manage context window growth
Every tool result is appended to the thread. A log-reading agent will blow past the context window in a dozen turns. Truncate tool output at the source (note the tailLines cap above), and use ChatHistorySummarizationReducer to compress older turns.
5. Instrument everything
Enable OpenTelemetry and log every function invocation with arguments, duration and token cost. When an agent misbehaves in production, the trace is the only way to find out why.
6. Test agents like distributed systems
Unit-test plugins as ordinary C# classes — no LLM involved. Then write a small suite of evaluation cases that run against a real model and assert on which tools were called, not on exact wording. LLM output is non-deterministic; tool traces are far more stable.
Common Pitfalls to Avoid
- Unbounded loops. Always set a cancellation token and a max-iteration guard. A misconfigured agent can burn thousands of tokens in seconds.
- Leaking secrets into prompts. Tool results become conversation history that goes to the model provider. Redact PII and credentials before returning them.
- Prompt injection via tool output. If a tool fetches a web page, its content is untrusted input. Never let fetched text be interpreted as instructions — wrap it in clear delimiters and instruct the agent to treat it as data.
- Sharing one
AgentThreadglobally. Threads are per-conversation state. Register agents as singletons; store threads per session. - Blocking on async in tools.
.Resultinside a[KernelFunction]will deadlock under load, exactly as it does anywhere else in ASP.NET Core. - Over-trusting the first model you pick. Benchmark a small model first. Many agent workloads run fine on cheaper models when the tools are well designed.
Conclusion: Key Takeaways
Building AI agents in C# with Semantic Kernel is no longer experimental — it is ordinary .NET engineering with a probabilistic component in the middle. The framework gives you the loop; your job is to give it good tools and firm boundaries.
- An agent is a goal → reason → act → observe loop;
FunctionChoiceBehavior.Auto()is what enables it in Semantic Kernel. - Plugins are plain C# classes —
[KernelFunction]and precise[Description]attributes do the heavy lifting. AgentThreadholds conversation state; scope it per user, never globally.- Constrain autonomy with cancellation tokens, low temperature, truncated tool output and invocation filters for sensitive actions.
- Prefer several focused agents over one agent with a huge tool list — tool-selection accuracy drops as the schema list grows.
- Test plugins as normal C# code; evaluate agents on tool-call traces rather than exact text.
Start with a single agent and two tools. Get the descriptions right, watch the traces, and add capability only when the current agent is reliably correct. Done that way, AI agents in C# become just another well-behaved service in your .NET solution — testable, observable and safe to ship.
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.
Comments
Post a Comment