Semantic Kernel’s .NET SDK treats AI capabilities as first-class services you compose through dependency injection. This tutorial walks you through wiring it into an ASP.NET Core application — registering the kernel, adding plugins, handling streaming, and surfacing usage metadata — so you can ship LLM features without fighting the framework.
Prerequisites
- .NET 8 SDK or later
- An OpenAI-compatible API key (OpenAI, Azure OpenAI, or a gateway like n4n.ai)
- Basic familiarity with ASP.NET Core minimal APIs or controllers
Create a new project and add the packages:
dotnet new web -n SkAspNetDemo
cd SkAspNetDemo
dotnet add package Microsoft.SemanticKernel
dotnet add package Microsoft.SemanticKernel.Connectors.OpenAI
dotnet add package Microsoft.Extensions.Http.Resilience
Register the kernel in DI
Semantic Kernel provides AddKernel() and builder extensions that plug into IServiceCollection. Open Program.cs and replace the boilerplate:
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Connectors.OpenAI;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddKernel()
.AddOpenAIChatCompletion(
modelId: builder.Configuration["OpenAI:Model"] ?? "gpt-4o-mini",
apiKey: builder.Configuration["OpenAI:ApiKey"] ?? throw new InvalidOperationException("Missing OpenAI:ApiKey"),
serviceId: "default");
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.MapGet("/health", () => Results.Ok(new { status = "healthy" }));
app.Run();
Add your credentials to appsettings.Development.json:
{
"OpenAI": {
"ApiKey": "sk-...",
"Model": "gpt-4o-mini"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.SemanticKernel": "Debug"
}
}
}
Run the app and hit /health — you should see {"status":"healthy"}. The kernel is now registered and ready for injection.
Inject and invoke from a minimal API endpoint
Create a ChatRequest record and a /chat endpoint that streams tokens back to the client:
// Add near the top of Program.cs
app.MapPost("/chat", async (ChatRequest request, Kernel kernel, HttpContext http, CancellationToken ct) =>
{
var chatService = kernel.GetRequiredService<IChatCompletionService>();
var history = new ChatHistory(request.SystemPrompt ?? "You are a helpful assistant.");
foreach (var msg in request.Messages)
{
history.AddMessage(msg.Role switch
{
"user" => AuthorRole.User,
"assistant" => AuthorRole.Assistant,
"system" => AuthorRole.System,
_ => AuthorRole.User
}, msg.Content);
}
http.Response.ContentType = "text/event-stream";
http.Response.Headers.CacheControl = "no-cache";
http.Response.Headers.Connection = "keep-alive";
await foreach (var chunk in chatService.GetStreamingChatMessageContentsAsync(history, cancellationToken: ct))
{
if (!string.IsNullOrEmpty(chunk.Content))
{
await http.Response.WriteAsync($"data: {chunk.Content}\n\n", ct);
await http.Response.Body.FlushAsync(ct);
}
}
await http.Response.WriteAsync("data: [DONE]\n\n", ct);
})
.WithName("Chat")
.WithOpenApi();
record ChatRequest(
string? SystemPrompt,
List<ChatMessage> Messages
);
record ChatMessage(string Role, string Content);
Test it with curl:
curl -N -X POST http://localhost:5xxx/chat \
-H "Content-Type: application/json" \
-d '{
"systemPrompt": "You are a terse engineer.",
"messages": [{"role": "user", "content": "Explain dependency injection in one sentence."}]
}'
Expected output (streamed):
data: Dependency injection
data: is a pattern where
data: dependencies are provided
data: to a class rather than
data: created internally.
data: [DONE]
Build a native plugin with kernel functions
Plugins encapsulate reusable skills. Create a TimePlugin that exposes the current time and a formatting function:
// TimePlugin.cs
using Microsoft.SemanticKernel;
public class TimePlugin
{
[KernelFunction("get_current_utc")]
[Description("Returns the current UTC time in ISO 8601 format.")]
public string GetCurrentUtc() => DateTimeOffset.UtcNow.ToString("o");
[KernelFunction("format_local")]
[Description("Converts a UTC ISO 8601 timestamp to the specified IANA time zone.")]
public string FormatLocal(
[Description("UTC timestamp in ISO 8601 format")] string utcIso,
[Description("IANA time zone identifier, e.g., America/Los_Angeles")] string timeZoneId)
{
if (!TimeZoneInfo.TryFindSystemTimeZoneById(timeZoneId, out var tz))
{
return $"Unknown time zone: {timeZoneId}";
}
var utc = DateTimeOffset.Parse(utcIso, null, System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal);
var local = TimeZoneInfo.ConvertTime(utc, tz);
return local.ToString("o");
}
}
Register the plugin in Program.cs before builder.Build():
builder.Services.AddKernel()
.AddOpenAIChatCompletion(...)
.Plugins.AddFromType<TimePlugin>();
Now create an endpoint that lets the model call the plugin automatically:
app.MapPost("/chat/tools", async (ChatRequest request, Kernel kernel, HttpContext http, CancellationToken ct) =>
{
var chatService = kernel.GetRequiredService<IChatCompletionService>();
var history = new ChatHistory(request.SystemPrompt ?? "You have access to a time plugin. Use it when users ask for the time.");
foreach (var msg in request.Messages)
{
history.AddMessage(msg.Role switch
{
"user" => AuthorRole.User,
"assistant" => AuthorRole.Assistant,
"system" => AuthorRole.System,
_ => AuthorRole.User
}, msg.Content);
}
var executionSettings = new OpenAIPromptExecutionSettings
{
ToolCallBehavior = ToolCallBehavior.AutoInvokeKernelFunctions
};
http.Response.ContentType = "text/event-stream";
http.Response.Headers.CacheControl = "no-cache";
await foreach (var chunk in chatService.GetStreamingChatMessageContentsAsync(history, executionSettings, kernel, ct))
{
if (!string.IsNullOrEmpty(chunk.Content))
{
await http.Response.WriteAsync($"data: {chunk.Content}\n\n", ct);
await http.Response.Body.FlushAsync(ct);
}
// Tool calls appear as metadata in the chunk; log them for visibility
if (chunk.Metadata?.TryGetValue("tool_calls", out var toolCalls) == true)
{
Console.WriteLine($"[TOOL CALL] {toolCalls}");
}
}
await http.Response.WriteAsync("data: [DONE]\n\n", ct);
})
.WithName("ChatWithTools")
.WithOpenApi();
Test the tool-calling endpoint:
curl -N -X POST http://localhost:5xxx/chat/tools \
-H "Content-Type: application/json" \
-d '{
"messages": [{"role": "user", "content": "What time is it in Tokyo right now?"}]
}'
You’ll see the model invoke get_current_utc, then format_local with Asia/Tokyo, and finally stream the formatted answer.
Capture token usage and metadata
Production systems need observability. The ChatMessageContent items returned from streaming (and non-streaming) calls carry Metadata with token counts when the provider surfaces them. Wrap the invocation in a small service to centralize this:
// Services/ChatCompletionService.cs
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.ChatCompletion;
public interface ITrackedChatService
{
IAsyncEnumerable<StreamingChatMessageContent> StreamAsync(ChatHistory history, Kernel kernel, CancellationToken ct = default);
Task<ChatResult> CompleteAsync(ChatHistory history, Kernel kernel, CancellationToken ct = default);
}
public record ChatResult(string Content, TokenUsage? Usage);
public record TokenUsage(int PromptTokens, int CompletionTokens, int TotalTokens);
public class TrackedChatService : ITrackedChatService
{
private readonly IChatCompletionService _chat;
public TrackedChatService(IChatCompletionService chat) => _chat = chat;
public async IAsyncEnumerable<StreamingChatMessageContent> StreamAsync(ChatHistory history, Kernel kernel, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default)
{
var settings = new OpenAIPromptExecutionSettings { ToolCallBehavior = ToolCallBehavior.AutoInvokeKernelFunctions };
await foreach (var chunk in _chat.GetStreamingChatMessageContentsAsync(history, settings, kernel, ct))
{
yield return chunk;
}
}
public async Task<ChatResult> CompleteAsync(ChatHistory history, Kernel kernel, CancellationToken ct = default)
{
var settings = new OpenAIPromptExecutionSettings { ToolCallBehavior = ToolCallBehavior.AutoInvokeKernelFunctions };
var result = await _chat.GetChatMessageContentAsync(history, settings, kernel, ct);
TokenUsage? usage = null;
if (result.Metadata?.TryGetValue("usage", out var usageObj) == true && usageObj is System.Text.Json.JsonElement usageJson)
{
usage = new TokenUsage(
usageJson.GetProperty("prompt_tokens").GetInt32(),
usageJson.GetProperty("completion_tokens").GetInt32(),
usageJson.GetProperty("total_tokens").GetInt32()
);
}
return new ChatResult(result.Content ?? string.Empty, usage);
}
}
Register it in Program.cs:
builder.Services.AddScoped<ITrackedChatService, TrackedChatService>();
Update the /chat endpoint to return usage at the end of the stream:
app.MapPost("/chat", async (ChatRequest request, ITrackedChatService trackedChat, HttpContext http, CancellationToken ct) =>
{
var history = new ChatHistory(request.SystemPrompt ?? "You are a helpful assistant.");
foreach (var msg in request.Messages)
{
history.AddMessage(msg.Role switch
{
"user" => AuthorRole.User,
"assistant" => AuthorRole.Assistant,
"system" => AuthorRole.System,
_ => AuthorRole.User
}, msg.Content);
}
http.Response.ContentType = "text/event-stream";
http.Response.Headers.CacheControl = "no-cache";
TokenUsage? finalUsage = null;
await foreach (var chunk in trackedChat.StreamAsync(history, http.RequestServices.GetRequiredService<Kernel>(), ct))
{
if (!string.IsNullOrEmpty(chunk.Content))
{
await http.Response.WriteAsync($"data: {chunk.Content}\n\n", ct);
await http.Response.Body.FlushAsync(ct);
}
if (chunk.Metadata?.TryGetValue("usage", out var usageObj) == true && usageObj is System.Text.Json.JsonElement usageJson)
{
finalUsage = new TokenUsage(
usageJson.GetProperty("prompt_tokens").GetInt32(),
usageJson.GetProperty("completion_tokens").GetInt32(),
usageJson.GetProperty("total_tokens").GetInt32()
);
}
}
if (finalUsage != null)
{
await http.Response.WriteAsync($"event: usage\ndata: {System.Text.Json.JsonSerializer.Serialize(finalUsage)}\n\n", ct);
}
await http.Response.WriteAsync("data: [DONE]\n\n", ct);
});
The client now receives a final event: usage frame with structured token counts before [DONE].
Add resilience with Polly
LLM endpoints fail — rate limits, transient network blips, provider degradation. The Microsoft.Extensions.Http.Resilience package (added earlier) lets you attach a standard resilience pipeline to the HTTP client Semantic Kernel uses under the hood.
In Program.cs, after AddOpenAIChatCompletion:
builder.Services.AddHttpClient("semantic-kernel")
.AddStandardResilienceHandler(options =>
{
options.Retry.MaxRetryAttempts = 3;
options.Retry.BackoffType = DelayBackoffType.Exponential;
options.Retry.UseJitter = true;
options.CircuitBreaker.SamplingDuration = TimeSpan.FromSeconds(30);
options.CircuitBreaker.FailureRatio = 0.5;
options.CircuitBreaker.MinimumThroughput = 5;
options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(120);
});
// Point the kernel at the named client
builder.Services.AddKernel()
.AddOpenAIChatCompletion(
modelId: builder.Configuration["OpenAI:Model"] ?? "gpt-4o-mini",
apiKey: builder.Configuration["OpenAI:ApiKey"] ?? throw new InvalidOperationException("Missing OpenAI:ApiKey"),
serviceId: "default",
httpClient: new HttpClient() { BaseAddress = new Uri("https://api.openai.com/v1/") }) // or your gateway base URL
.Services.AddHttpClient("semantic-kernel"); // ensures the named client is used
Note: The
AddOpenAIChatCompletionoverload accepting anHttpClientlets you inject the resilient client. If you’re using a gateway that speaks OpenAI-compatible API (like n4n.ai), pointBaseAddressat its endpoint and the same resilience policy applies.
Structured logging for kernel events
Semantic Kernel emits EventSource events and ILogger messages at Debug and Trace levels. The appsettings.Development.json earlier set Microsoft.SemanticKernel to Debug. In production, raise it to Information and add a structured logger (Serilog, Datadog, etc.) to capture:
- Function invocations (plugin name, function name, arguments, duration)
- Token usage per request
- Retry attempts and circuit breaker state changes
Example Serilog enrichment in Program.cs:
builder.Host.UseSerilog((ctx, cfg) => cfg
.ReadFrom.Configuration(ctx.Configuration)
.Enrich.FromLogContext()
.Enrich.WithProperty("service", "sk-aspnet-demo")
.WriteTo.Console(new Serilog.Formatting.Json.JsonFormatter()));
Now every kernel function call logs structured JSON you can query in your observability stack.
Run the complete application
dotnet run
Hit the Swagger UI at http://localhost:5xxx/swagger and exercise both endpoints. Verify:
/chatstreams tokens and emits a finalusageevent./chat/toolsinvokesTimePluginfunctions automatically when you ask for the time in a specific zone.- Logs show function calls, token counts, and any retry/circuit-breaker activity.
What to take forward
- Kernel per request vs. singleton: The kernel is thread-safe and cheap to create; register as scoped if you attach per-request state (e.g., user-specific plugins), singleton otherwise.
- Plugin organization: Group functions into focused plugins (
TimePlugin,DatabasePlugin,EmailPlugin) and register them conditionally based on feature flags or user permissions. - Streaming vs. non-streaming: Use streaming for chat UIs; use
CompleteAsyncfor background jobs where you need the full response and usage atomically. - Provider portability: The
IChatCompletionServiceabstraction means swapping OpenAI for Azure OpenAI, Ollama, or an OpenAI-compatible gateway only changes the registration line — your endpoints and plugins stay untouched.
You now have a production-grade Semantic Kernel integration: DI-native, plugin-aware, streaming-capable, observable, and resilient. Ship it.