n4nAI

Semantic Kernel .NET tutorial: ASP.NET Core integration

Build a production-ready Semantic Kernel integration in ASP.NET Core with dependency injection, plugins, and streaming responses.

n4n Team3 min read603 words

Audio narration

Coming soon — every post will get a voice note here.

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 AddOpenAIChatCompletion overload accepting an HttpClient lets you inject the resilient client. If you’re using a gateway that speaks OpenAI-compatible API (like n4n.ai), point BaseAddress at 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:

  1. /chat streams tokens and emits a final usage event.
  2. /chat/tools invokes TimePlugin functions automatically when you ask for the time in a specific zone.
  3. 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 CompleteAsync for background jobs where you need the full response and usage atomically.
  • Provider portability: The IChatCompletionService abstraction 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.

Tagssemantic-kerneldotnetaspnet-coretutorial

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All semantic kernel for .net enterprise apps posts →