Semantic Kernel native functions let you expose plain C# methods to the planner so the model can call them like tools. This tutorial walks through creating a plugin project, registering functions with dependency injection, handling async and cancellation, and invoking them from both code and the planner. You’ll end up with a runnable console app that demonstrates the full loop.
Prerequisites
- .NET 8 SDK or later
- An OpenAI-compatible API key (OpenAI, Azure OpenAI, or a gateway like n4n.ai)
- Basic familiarity with Semantic Kernel concepts: kernel, plugins, and the planner
Install the required packages:
dotnet new console -n SKNativeFunctionsDemo
cd SKNativeFunctionsDemo
dotnet add package Microsoft.SemanticKernel
dotnet add package Microsoft.Extensions.DependencyInjection
dotnet add package Microsoft.Extensions.Hosting
dotnet add package Microsoft.Extensions.Logging.Console
Project structure
SKNativeFunctionsDemo/
├── Program.cs
├── Plugins/
│ ├── TimePlugin.cs
│ ├── WeatherPlugin.cs
│ └── CalculatorPlugin.cs
└── SKNativeFunctionsDemo.csproj
A minimal native function
Native functions are plain C# methods decorated with [KernelFunction] and described with [Description] attributes. The planner uses these descriptions to decide when to call the function.
Create Plugins/TimePlugin.cs:
using Microsoft.SemanticKernel;
namespace SKNativeFunctionsDemo.Plugins;
public sealed class TimePlugin
{
[KernelFunction]
[Description("Returns the current UTC time in ISO 8601 format.")]
public string GetCurrentUtcTime() => DateTimeOffset.UtcNow.ToString("o");
}
That’s it. No base classes, no interfaces. The attribute is the contract.
Registering plugins with dependency injection
In Program.cs, build a host that registers the kernel, your plugins, and logging. This pattern scales to real applications where plugins need scoped services (DbContext, HttpClient, etc.).
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.SemanticKernel;
using SKNativeFunctionsDemo.Plugins;
var builder = Host.CreateApplicationBuilder(args);
// OpenAI-compatible endpoint — replace with your key and endpoint
builder.Services.AddKernel()
.AddOpenAIChatCompletion(
modelId: "gpt-4o-mini",
apiKey: Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? "your-key-here",
httpClient: new HttpClient() // or configure via IHttpClientFactory
);
// Register plugins as transient so each kernel invocation gets a fresh instance
builder.Services.AddTransient<TimePlugin>();
builder.Services.AddTransient<WeatherPlugin>();
builder.Services.AddTransient<CalculatorPlugin>();
// Plugins can also be registered via kernel.ImportPluginFromType<T>()
// but DI registration enables constructor injection into plugins.
builder.Services.AddLogging(c => c.AddConsole().SetMinimumLevel(LogLevel.Information));
var app = builder.Build();
var kernel = app.Services.GetRequiredService<Kernel>();
// Import plugins from DI — this resolves each plugin and registers its functions
kernel.ImportPluginFromObject(app.Services.GetRequiredService<TimePlugin>(), "time");
kernel.ImportPluginFromObject(app.Services.GetRequiredService<WeatherPlugin>(), "weather");
kernel.ImportPluginFromObject(app.Services.GetRequiredService<CalculatorPlugin>(), "calculator");
Console.WriteLine("Kernel ready. Registered plugins:");
foreach (var plugin in kernel.Plugins)
{
Console.WriteLine($" - {plugin.Name}: {string.Join(", ", plugin.Select(f => f.Name))}");
}
Run it:
dotnet run
Expected output:
Kernel ready. Registered plugins:
- time: GetCurrentUtcTime
- weather: GetCurrentWeather
- calculator: Add, Subtract, Multiply, Divide
A plugin with dependencies and async
Real plugins need HttpClient, configuration, or database access. Inject them via the constructor. Semantic Kernel supports async Task<T> return types and CancellationToken parameters natively.
Create Plugins/WeatherPlugin.cs:
using Microsoft.SemanticKernel;
using System.Text.Json;
namespace SKNativeFunctionsDemo.Plugins;
public sealed class WeatherPlugin
{
private readonly HttpClient _http;
private readonly string _apiKey;
public WeatherPlugin(HttpClient http, IConfiguration config)
{
_http = http;
_apiKey = config["WeatherApiKey"] ?? throw new InvalidOperationException("WeatherApiKey not configured");
}
[KernelFunction]
[Description("Gets current weather for a city. Returns temperature in Celsius and a short description.")]
public async Task<string> GetCurrentWeatherAsync(
[Description("City name, e.g. 'Seattle' or 'Tokyo'")] string city,
CancellationToken cancellationToken = default)
{
var url = $"https://api.openweathermap.org/data/2.5/weather?q={Uri.EscapeDataString(city)}&appid={_apiKey}&units=metric";
using var response = await _http.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync(cancellationToken);
using var doc = JsonDocument.Parse(json);
var temp = doc.RootElement.GetProperty("main").GetProperty("temp").GetDouble();
var desc = doc.RootElement.GetProperty("weather")[0].GetProperty("description").GetString();
return $"{city}: {temp:F1}°C, {desc}";
}
}
Register HttpClient and configuration in Program.cs before building:
builder.Services.AddHttpClient<WeatherPlugin>();
builder.Configuration.AddJsonFile("appsettings.json", optional: true)
.AddEnvironmentVariables();
Add appsettings.json (copy to output directory):
{
"WeatherApiKey": "your-openweathermap-key"
}
A calculator plugin — multiple functions, validation, and overloads
Create Plugins/CalculatorPlugin.cs:
using Microsoft.SemanticKernel;
namespace SKNativeFunctionsDemo.Plugins;
public sealed class CalculatorPlugin
{
[KernelFunction]
[Description("Adds two numbers.")]
public double Add(double a, double b) => a + b;
[KernelFunction]
[Description("Subtracts b from a.")]
public double Subtract(double a, double b) => a - b;
[KernelFunction]
[Description("Multiplies two numbers.")]
public double Multiply(double a, double b) => a * b;
[KernelFunction]
[Description("Divides a by b. Throws if b is zero.")]
public double Divide(double a, double b)
{
if (b == 0) throw new DivideByZeroException("Cannot divide by zero");
return a / b;
}
// Overload with int parameters — the planner will pick the best match
[KernelFunction]
[Description("Adds two integers.")]
public int Add(int a, int b) => a + b;
}
Invoking functions directly from code
You don’t need the planner to call native functions. Use kernel.InvokeAsync for direct invocation — useful for testing, background jobs, or when you know exactly which function to call.
Add to Program.cs after plugin registration:
// Direct invocation
var timeResult = await kernel.InvokeAsync("time", "GetCurrentUtcTime");
Console.WriteLine($"\nDirect call — Current UTC time: {timeResult}");
var calcResult = await kernel.InvokeAsync("calculator", "Add", new() { ["a"] = 10, ["b"] = 32 });
Console.WriteLine($"Direct call — 10 + 32 = {calcResult}");
// Invoke with named arguments (order doesn't matter)
var divResult = await kernel.InvokeAsync("calculator", "Divide", new() { ["b"] = 4, ["a"] = 100 });
Console.WriteLine($"Direct call — 100 / 4 = {divResult}");
Expected output:
Direct call — Current UTC time: 2025-01-15T14:30:45.1234567+00:00
Direct call — 10 + 32 = 42
Direct call — 100 / 4 = 25
Invoking via the planner
The planner decides which functions to call based on the user’s prompt. Use FunctionCallingStepwisePlanner for multi-step reasoning (requires a model that supports function calling — gpt-4o-mini works).
Add to Program.cs:
using Microsoft.SemanticKernel.Planners;
// ...
var planner = new FunctionCallingStepwisePlanner();
// Example 1: Single function call
var plan1 = await planner.ExecuteAsync(kernel, "What time is it in UTC?");
Console.WriteLine($"\nPlanner result: {plan1}");
// Example 2: Multi-step — weather then calculation
var plan2 = await planner.ExecuteAsync(kernel, "Get the weather in Tokyo, then add 10 to the temperature (round down).");
Console.WriteLine($"\nPlanner result: {plan2}");
// Example 3: Chained calculation
var plan3 = await planner.ExecuteAsync(kernel, "Calculate (15 * 4) + (100 / 5) - 3");
Console.WriteLine($"\nPlanner result: {plan3}");
Expected output (weather varies):
Planner result: The current UTC time is 2025-01-15T14:30:45.1234567+00:00.
Planner result: The current temperature in Tokyo is 12.5°C. Adding 10 gives 22.5, rounded down to 22.
Planner result: The result is 77.
Handling function calling manually
For full control — streaming, custom tool selection, or non-OpenAI models — use ChatCompletionAgent with AutoInvokeKernelFunctions or invoke the chat completion service directly with KernelFunction metadata.
using Microsoft.SemanticKernel.ChatCompletion;
using Microsoft.SemanticKernel.Connectors.OpenAI;
// ...
var chat = kernel.GetRequiredService<IChatCompletionService>();
var history = new ChatHistory("You are a helpful assistant with access to tools.");
history.AddUserMessage("What's 25 * 4? Also tell me the current UTC time.");
var executionSettings = new OpenAIPromptExecutionSettings
{
ToolCallBehavior = ToolCallBehavior.AutoInvokeKernelFunctions
};
var result = await chat.GetChatMessageContentAsync(history, executionSettings, kernel);
Console.WriteLine($"\nManual function calling result: {result}");
This bypasses the planner entirely — the model sees function schemas in the system prompt and emits tool calls, which the kernel executes automatically.
Passing complex types and arrays
Native functions accept primitive types, string, DateTime, Guid, arrays, and POCOs. For POCOs, the planner serializes arguments as JSON.
[KernelFunction]
[Description("Calculates statistics for a list of numbers.")]
public string CalculateStats(double[] numbers)
{
if (numbers.Length == 0) return "Empty array";
var avg = numbers.Average();
var min = numbers.Min();
var max = numbers.Max();
return $"Count: {numbers.Length}, Avg: {avg:F2}, Min: {min}, Max: {max}";
}
The planner can call this with a JSON array: [1, 2, 3, 4, 5].
For POCOs, define a record:
public record Coordinates(double Latitude, double Longitude);
[KernelFunction]
[Description("Gets weather for coordinates.")]
public async Task<string> GetWeatherByCoordsAsync(Coordinates coords, CancellationToken ct = default)
{
// ... use coords.Latitude, coords.Longitude
}
Cancellation and timeouts
Always accept CancellationToken in async functions. The kernel passes the caller’s token. For long-running operations, respect it:
[KernelFunction]
[Description("Simulates a long-running task.")]
public async Task<string> LongTaskAsync(int seconds, CancellationToken cancellationToken)
{
for (int i = 0; i < seconds; i++)
{
cancellationToken.ThrowIfCancellationRequested();
await Task.Delay(1000, cancellationToken);
}
return $"Completed after {seconds} seconds";
}
Call with a timeout from code:
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
try
{
var result = await kernel.InvokeAsync("time", "LongTaskAsync", new() { ["seconds"] = 10 }, cancellationToken: cts.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("Timed out as expected");
}
Testing native functions in isolation
Since native functions are plain methods, unit test them without the kernel:
// TimePluginTests.cs
using Xunit;
using SKNativeFunctionsDemo.Plugins;
public class TimePluginTests
{
[Fact]
public void GetCurrentUtcTime_ReturnsIso8601()
{
var plugin = new TimePlugin();
var result = plugin.GetCurrentUtcTime();
Assert.Matches(@"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z$", result);
}
}
For integration tests with a real kernel, use Microsoft.SemanticKernel.Testing or spin up a test host:
var kernel = Kernel.CreateBuilder()
.AddOpenAIChatCompletion("gpt-4o-mini", "test-key")
.Build();
kernel.ImportPluginFromObject(new CalculatorPlugin());
var result = await kernel.InvokeAsync("calculator", "Add", new() { ["a"] = 2, ["b"] = 3 });
Assert.Equal(5, result.GetValue<double>());
Common pitfalls
| Issue | Cause | Fix |
|---|---|---|
| Function not found | Plugin not imported or wrong name | Check kernel.Plugins names; import with explicit plugin name |
| Planner ignores function | Description too vague or missing | Write clear, specific [Description] attributes |
| Type mismatch | Planner passes JSON, function expects POCO | Ensure POCO has parameterless constructor or use JsonConstructor |
| Cancellation not working | Missing CancellationToken parameter |
Add CancellationToken cancellationToken = default to async methods |
| DI scope issues | Plugin registered as singleton but needs scoped services | Register plugins as Transient or Scoped |
Performance notes
- Function invocation adds ~10-50ms overhead per call (serialization, reflection, kernel plumbing)
- The planner may call multiple functions per turn — budget latency accordingly
- Cache
HttpClientand expensive resources in plugin constructors (DI handles lifetime) - For high-throughput scenarios, consider
kernel.InvokeAsyncwithKernelArgumentsreuse
What’s next
- Streaming results: Use
kernel.InvokeStreamingAsyncfor progressive output - Filters: Implement
IFunctionInvocationFilterfor logging, auth, or retries across all functions - Dynamic plugins: Load plugins from assemblies at runtime with
kernel.ImportPluginFromType - Semantic functions: Combine native functions with prompt templates for hybrid skills
The complete runnable project is in the accompanying repository. Clone, add your API keys to appsettings.json or environment variables, and run dotnet run to see the planner orchestrate your C# code.