Microsoft.Extensions.AI and Semantic Kernel solve overlapping but different problems. Microsoft.Extensions.AI gives you a thin, provider-agnostic abstraction layer for talking to language models. Semantic Kernel builds on top of that with plugins, prompt templates and orchestration.
Part 1 of this series looked at how the two relate conceptually. Here I want to walk through the actual code you write when you wire them together, since that is where most of the confusion happens in real projects.
Setting Up the Two Layers Together
Before touching any code, it helps to be clear on where each library’s responsibility ends. Microsoft.Extensions.AI ships abstractions like IChatClient and IEmbeddingGenerator<string, Embedding<float>>, and these map roughly to a single call against a model provider. Semantic Kernel wraps a Kernel object around these abstractions and adds plugins, prompt templates and workflow logic on top.
Once Semantic Kernel added native support for IChatClient, the two stopped being alternatives and became complementary. You pick Microsoft.Extensions.AI when you need a direct line to a model, and Semantic Kernel when you need orchestration around it.
Basic Chat Completion with IChatClient
The simplest starting point is building a kernel directly against a chat client using the fluent builder. This is what most tutorials show first because it hides almost all the wiring.
using Microsoft.Extensions.AI;
using Microsoft.SemanticKernel;
// Create a kernel with OpenAI chat client
var kernel = Kernel.CreateBuilder()
.AddOpenAIChatClient("gpt-4o", "your-api-key")
.Build();
// Simple chat completion
var response = await kernel.InvokePromptAsync("What is the capital of France?");
Console.WriteLine(response);
This creates a Kernel instance with an OpenAI chat client already registered, and InvokePromptAsync sends the prompt straight through. Nothing here is Semantic-Kernel-specific yet, it’s really just a convenience wrapper around Microsoft.Extensions.AI.
Using a Chat Client Directly with Azure OpenAI
Sometimes you don’t want the kernel abstraction at all and just need the raw IChatClient, for example inside a background service where dragging in the full Kernel object adds no value. You can pull it straight out of the container Semantic Kernel builds.
var kernel = Kernel.CreateBuilder()
.AddAzureOpenAIChatClient(
deploymentName: "gpt-4o",
endpoint: "https://your-resource.openai.azure.com/",
apiKey: "your-api-key")
.Build();
var client = kernel.GetRequiredService<IChatClient>();
var response = await client.GetResponseAsync([new(ChatRole.User, "Hello, AI!")]);
Console.WriteLine(response.Text);
GetResponseAsync takes a list of ChatMessage objects rather than a single string prompt, which is the Microsoft.Extensions.AI convention. If you’re used to Semantic Kernel’s InvokePromptAsync, this is the first place the two APIs diverge, and it trips people up when they copy code between the two styles without noticing the shape has changed.
Using Dependency Injection
In any real ASP.NET Core or worker service project, you register services through the DI container rather than building a kernel by hand. AddOpenAIChatClient and AddKernel both plug straight into the standard IServiceCollection.
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.AI;
using Microsoft.SemanticKernel;
var services = new ServiceCollection();
// Register the chat client
services.AddOpenAIChatClient("gpt-4o", "your-api-key");
// Register Semantic Kernel
services.AddKernel();
var serviceProvider = services.BuildServiceProvider();
var kernel = serviceProvider.GetRequiredService<Kernel>();
var response = await kernel.InvokePromptAsync("Tell me about artificial intelligence.");
Console.WriteLine(response);
This is the pattern I would recommend for production code. It keeps the chat client and the kernel as separate registrations, so you can swap the underlying provider by changing one line without touching anything else in the app that consumes IChatClient or Kernel.
Converting Between IChatCompletionService and IChatClient
Older Semantic Kernel code, and a fair amount of code still running in production, is written against IChatCompletionService, the interface that predates Microsoft.Extensions.AI. You don’t need to rewrite that code to adopt the newer abstraction. Semantic Kernel provides extension methods that convert in both directions.
using Microsoft.Extensions.AI;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.ChatCompletion;
// Get the chat completion service
var chatService = kernel.GetRequiredService<IChatCompletionService>();
// Convert to IChatClient when needed
IChatClient chatClient = chatService.AsChatClient();
// Or convert back
IChatCompletionService backToService = chatClient.AsChatCompletionService();
AsChatClient and AsChatCompletionService are thin adapter methods, not full reimplementations, so calling one from the other wraps the existing service rather than duplicating logic. This matters when you’re migrating a codebase gradually: new code can target IChatClient while legacy code keeps working against IChatCompletionService, and both end up calling the same underlying provider.
Generating Embeddings with IEmbeddingGenerator
Semantic Kernel used to have its own ITextEmbeddingGenerationService. That interface is being phased out in favour of Microsoft.Extensions.AI’s IEmbeddingGenerator<string, Embedding<float>>, which is a more general type that isn’t tied to Semantic Kernel at all.
using Microsoft.Extensions.AI;
using Microsoft.SemanticKernel;
#pragma warning disable SKEXP0010 // Type is for evaluation
var kernel = Kernel.CreateBuilder()
.AddOpenAIEmbeddingGenerator("text-embedding-ada-002", "your-api-key")
.Build();
var embeddingGenerator = kernel.GetRequiredService<IEmbeddingGenerator<string, Embedding<float>>>();
// Generate embeddings
var embeddings = await embeddingGenerator.GenerateAsync([
"Semantic Kernel is a lightweight, open-source development kit.",
"Microsoft.Extensions.AI provides foundational AI abstractions."
]);
foreach (var embedding in embeddings)
{
Console.WriteLine($"Generated embedding with {embedding.Vector.Length} dimensions");
}
Note the #pragma warning disable SKEXP0010 line. Several of the embedding extension methods are still marked experimental in Semantic Kernel, so the compiler flags them unless you suppress that specific warning. It’s worth actually reading what SKEXP0010 covers in the release notes before you disable it project-wide, rather than pasting the pragma everywhere out of habit.
Working with Azure OpenAI Embeddings
The Azure OpenAI variant follows the same shape, with an added options object if you need to control output dimensions.
using Microsoft.Extensions.AI;
using Microsoft.SemanticKernel;
#pragma warning disable SKEXP0010
var kernel = Kernel.CreateBuilder()
.AddAzureOpenAIEmbeddingGenerator(
deploymentName: "text-embedding-ada-002",
endpoint: "https://your-resource.openai.azure.com/",
apiKey: "your-api-key")
.Build();
var embeddingGenerator = kernel.GetRequiredService<IEmbeddingGenerator<string, Embedding<float>>>();
// Generate embeddings with custom dimensions (if supported by model)
var embeddings = await embeddingGenerator.GenerateAsync(
["Custom text for embedding"],
new EmbeddingGenerationOptions { Dimensions = 1536 });
Console.WriteLine($"Generated {embeddings.Count} embeddings");
Dimensions is only respected if the underlying model supports variable-length embeddings, which newer OpenAI embedding models do. If you pass it against a model that doesn’t support it, you’ll either get an error or the option gets silently ignored depending on the provider, so test this against your actual deployment rather than assuming it works everywhere.
Function Calling with KernelFunction as AIFunction
This is probably the most useful convergence between the two libraries. A KernelFunction in Semantic Kernel now implements AIFunction from Microsoft.Extensions.AI, so a plugin method you write for Semantic Kernel can be passed straight into a raw IChatClient call without any adapter code.
using Microsoft.Extensions.AI;
using Microsoft.SemanticKernel;
using System.ComponentModel;
var kernel = Kernel.CreateBuilder()
.AddOpenAIChatClient("gpt-4o", "your-api-key")
.Build();
// Import the function as a plugin
kernel.ImportPluginFromType<WeatherPlugin>();
// Use function calling
var settings = new PromptExecutionSettings
{
FunctionChoiceBehavior = FunctionChoiceBehavior.Auto()
};
var response = await kernel.InvokePromptAsync(
"What's the weather like in Seattle and what time is it?",
new(settings));
Console.WriteLine(response);
public class WeatherPlugin
{
[KernelFunction, Description("Get the current weather for a city")]
public static string GetWeather([Description("The city name")] string city)
{
return $"The weather in {city} is sunny and 72\u00b0F";
}
[KernelFunction, Description("Get the current time")]
public static string GetCurrentTime()
{
return DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
}
}
ImportPluginFromType scans the class for methods decorated with [KernelFunction] and registers them as callable tools. FunctionChoiceBehavior.Auto() tells the model it is free to decide whether to call zero, one, or both functions based on the prompt.
A common mistake here is forgetting that the model chooses whether to call a function at all. Your code still needs to handle the case where it answers directly without calling anything, especially if you’re building a pipeline that assumes a tool call always happens.
Working with KernelFunction Directly
You don’t have to go through plugin registration at all. KernelFunctionFactory.CreateFromMethod wraps a plain method or lambda into a KernelFunction, and because that type is already an AIFunction, you can hand it straight to ChatOptions.Tools on a raw IChatClient.
var kernel = Kernel.CreateBuilder()
.AddOpenAIChatClient("gpt-4o", "your-api-key")
.Build();
// Create a function from a method
var weatherFunction = KernelFunctionFactory.CreateFromMethod(
() => "Sunny and 75\u00b0F",
"GetWeather",
"Gets the current weather");
// KernelFunction is already an AIFunction, so you can use it directly
var chatOptions = new ChatOptions
{
Tools = [weatherFunction], // KernelFunction works directly as AITool
ToolMode = ChatToolMode.Auto
};
var chatClient = kernel.GetRequiredService<IChatClient>();
var messages = new List<ChatMessage>
{
new(ChatRole.User, "What's the weather like?")
};
var response = await chatClient.GetResponseAsync(messages, chatOptions);
Console.WriteLine(response.Text);
This is the pattern to reach for when you want function calling without pulling in the rest of Semantic Kernel’s plugin machinery, prompt templates, or the Kernel object at all. It keeps the dependency footprint small if function calling is the only Semantic Kernel capability you actually need in a given service.
Getting Strongly Typed Results with InvokeAsync<T>
kernel.InvokeAsync supports a generic overload that returns Microsoft.Extensions.AI types directly instead of Semantic Kernel’s own FunctionResult wrapper.
var kernel = Kernel.CreateBuilder()
.AddOpenAIChatClient("gpt-4o", "your-api-key")
.Build();
// Get Microsoft.Extensions.AI ChatResponse directly
var chatResponse = await kernel.InvokeAsync<ChatResponse>(
kernel.CreateFunctionFromPrompt("Tell me a joke"));
Console.WriteLine($"Model: {chatResponse.ModelId}");
Console.WriteLine($"Content: {chatResponse.Text}");
// Get List<ChatMessage> for conversation history
var message = await kernel.InvokeAsync<ChatMessage>(
kernel.CreateFunctionFromPrompt("Start a conversation about AI"));
Console.WriteLine($"Message Role: {message.Role}");
Console.WriteLine($"Message Content: {message.Text}");
// Get Microsoft.Extensions.AI TextContent directly
var textContent = await kernel.InvokeAsync<Microsoft.Extensions.AI.TextContent>(
kernel.CreateFunctionFromPrompt("Start a conversation about AI"));
Console.WriteLine($"Text Content: {textContent.Text}");
This saves you from manually unwrapping FunctionResult.GetValue<T>() calls and gives you the richer ChatResponse or ChatMessage types with fields like ModelId. It is a small convenience, but if you’re already standardising on Microsoft.Extensions.AI types across your codebase, it means you don’t have to write conversion code at every call site.
Selecting a Provider Among Multiple Registered Services
Larger applications often need to call different model providers depending on the task, routing cheap classification calls to one provider and expensive reasoning calls to another. Semantic Kernel’s service selection by serviceId works the same way whether the underlying registration came from Microsoft.Extensions.AI or Semantic Kernel’s own extension methods.
var services = new ServiceCollection();
// Register multiple chat clients
services.AddOpenAIChatClient("gpt-4", "openai-key", serviceId: "OpenAI");
services.AddAzureOpenAIChatClient(
"gpt-4",
"https://your-resource.openai.azure.com/",
"azure-key",
serviceId: "AzureOpenAI");
services.AddKernel();
var serviceProvider = services.BuildServiceProvider();
var kernel = serviceProvider.GetRequiredService<Kernel>();
// Use specific service
var settings = new PromptExecutionSettings { ServiceId = "AzureOpenAI" };
var response = await kernel.InvokePromptAsync<ChatResponse>(
"Explain machine learning",
new(settings));
Console.WriteLine("Model: " + response.ModelId);
Console.WriteLine("Content: " + response.Text);
PromptExecutionSettings.ServiceId picks the registration you want at call time. This is worth setting up early if you expect to add a second provider later, because retrofitting serviceId-based selection into a codebase that assumed a single provider from day one tends to touch more files than you would expect.
When to Reach for Which Abstraction
If your application only needs to send messages to a model and get text or structured output back, IChatClient on its own is enough, and pulling in the full Kernel object adds indirection without buying you anything. Once you need plugins, prompt templates, planners, or multi-step orchestration, Semantic Kernel earns its place.
The two are not competing choices for most non-trivial applications. You will likely end up using IChatClient for the raw provider connection and Semantic Kernel for everything built on top of it, and the conversion methods covered above are what let you move between the two without rewriting working code.
One thing worth flagging for production use: none of the examples above show retry policies, timeouts, or logging. Microsoft.Extensions.AI’s real strength is that these concerns can be added as middleware around IChatClient without touching your application code, but that is a topic for its own post. If you are evaluating this stack for production, budget time to wire up resilience middleware before you ship rather than treating it as an afterthought.
Leave a Reply