Semantic Kernel plugins let you expose deterministic capabilities — APIs, databases, local calculations — to the planner so it can compose them with LLM reasoning. A weather plugin is the classic first example because it exercises the full loop: parameter validation, HTTP calls, response shaping, and registration. This tutorial walks through a production-ready implementation you can drop into a .NET 8 console app or ASP.NET Core service.
Step 1: Set up the project and dependencies
Create a new console project and add the Semantic Kernel packages. You’ll need the core library plus the OpenAI connector (or your preferred provider).
dotnet new console -n WeatherPluginDemo
cd WeatherPluginDemo
dotnet add package Microsoft.SemanticKernel
dotnet add package Microsoft.SemanticKernel.Connectors.OpenAI
dotnet add package Microsoft.Extensions.Http
dotnet add package Microsoft.Extensions.DependencyInjection
The Microsoft.Extensions.Http package gives you IHttpClientFactory for typed clients with retry policies — use it instead of raw HttpClient.
Step 2: Define the weather service contract
Keep the external API behind an interface. This makes the plugin testable and lets you swap providers (OpenWeatherMap, WeatherAPI, National Weather Service) without touching kernel code.
// Services/IWeatherService.cs
namespace WeatherPluginDemo.Services;
public interface IWeatherService
{
Task<CurrentWeather> GetCurrentAsync(double latitude, double longitude, CancellationToken ct = default);
Task<Forecast> GetForecastAsync(double latitude, double longitude, int days = 3, CancellationToken ct = default);
}
public record CurrentWeather(
double TemperatureCelsius,
double FeelsLikeCelsius,
int HumidityPercent,
double WindSpeedKph,
string Condition,
DateTimeOffset ObservedAt
);
public record ForecastDay(
DateOnly Date,
double MaxTempCelsius,
double MinTempCelsius,
string Condition,
double PrecipitationMm
);
public record Forecast(IReadOnlyList<ForecastDay> Days);
Step 3: Implement a typed HTTP client with resilience
Wire a named HttpClient with a retry policy and base address. Register it in DI so the kernel can resolve your service.
// Services/OpenWeatherMapService.cs
namespace WeatherPluginDemo.Services;
public sealed class OpenWeatherMapService : IWeatherService
{
private readonly HttpClient _http;
private readonly string _apiKey;
public OpenWeatherMapService(HttpClient http, IConfiguration config)
{
_http = http;
_apiKey = config["OpenWeatherMap:ApiKey"] ?? throw new InvalidOperationException("Missing OpenWeatherMap:ApiKey");
}
public async Task<CurrentWeather> GetCurrentAsync(double latitude, double longitude, CancellationToken ct = default)
{
var url = $"data/2.5/weather?lat={latitude}&lon={longitude}&units=metric&appid={_apiKey}";
var response = await _http.GetFromJsonAsync<OpenWeatherCurrentResponse>(url, ct);
return MapCurrent(response);
}
public async Task<Forecast> GetForecastAsync(double latitude, double longitude, int days = 3, CancellationToken ct = default)
{
var url = $"data/2.5/forecast?lat={latitude}&lon={longitude}&units=metric&appid={_apiKey}";
var response = await _http.GetFromJsonAsync<OpenWeatherForecastResponse>(url, ct);
return MapForecast(response, days);
}
private static CurrentWeather MapCurrent(OpenWeatherCurrentResponse? r) => r is null
? throw new InvalidOperationException("Empty response from weather provider")
: new CurrentWeather(
TemperatureCelsius: r.Main.Temp,
FeelsLikeCelsius: r.Main.FeelsLike,
HumidityPercent: r.Main.Humidity,
WindSpeedKph: r.Wind.Speed * 3.6, // m/s to kph
Condition: r.Weather[0].Main,
ObservedAt: DateTimeOffset.FromUnixTimeSeconds(r.Dt)
);
private static Forecast MapForecast(OpenWeatherForecastResponse? r, int days)
{
if (r is null || r.List.Count == 0) throw new InvalidOperationException("Empty forecast response");
var daily = r.List
.GroupBy(x => DateTimeOffset.FromUnixTimeSeconds(x.Dt).Date)
.Take(days)
.Select(g => new ForecastDay(
Date: DateOnly.FromDateTime(g.Key),
MaxTempCelsius: g.Max(x => x.Main.TempMax),
MinTempCelsius: g.Min(x => x.Main.TempMin),
Condition: g.First().Weather[0].Main,
PrecipitationMm: g.Sum(x => x.Rain?.ThreeHour ?? 0) + g.Sum(x => x.Snow?.ThreeHour ?? 0)
))
.ToList();
return new Forecast(daily);
}
// DTOs — keep internal
private sealed class OpenWeatherCurrentResponse
{
public MainBlock Main { get; set; } = new();
public WindBlock Wind { get; set; } = new();
public WeatherBlock[] Weather { get; set; } = [];
public long Dt { get; set; }
}
private sealed class OpenWeatherForecastResponse
{
public List<ForecastItem> List { get; set; } = [];
}
private sealed class ForecastItem
{
public MainBlock Main { get; set; } = new();
public WeatherBlock[] Weather { get; set; } = [];
public RainBlock? Rain { get; set; }
public SnowBlock? Snow { get; set; }
public long Dt { get; set; }
}
private sealed class MainBlock { public double Temp { get; set; } public double FeelsLike { get; set; } public int Humidity { get; set; } public double TempMax { get; set; } public double TempMin { get; set; } }
private sealed class WindBlock { public double Speed { get; set; } }
private sealed class WeatherBlock { public string Main { get; set; } = ""; }
private sealed class RainBlock { public double ThreeHour { get; set; } }
private sealed class SnowBlock { public double ThreeHour { get; set; } }
}
Register the client in Program.cs with a sensible retry policy:
// Program.cs (partial)
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http.Resilience;
using WeatherPluginDemo.Services;
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddHttpClient<IWeatherService, OpenWeatherMapService>(client =>
{
client.BaseAddress = new Uri("https://api.openweathermap.org/");
client.Timeout = TimeSpan.FromSeconds(10);
})
.AddStandardResilienceHandler(options =>
{
options.Retry.MaxRetryAttempts = 3;
options.Retry.BackoffType = DelayBackoffType.Exponential;
options.Retry.Delay = TimeSpan.FromSeconds(1);
options.CircuitBreaker.SamplingDuration = TimeSpan.FromSeconds(30);
options.CircuitBreaker.FailureRatio = 0.5;
});
builder.Services.AddSingleton<Kernel>(sp =>
{
var kernelBuilder = Kernel.CreateBuilder();
kernelBuilder.Services.AddSingleton(sp.GetRequiredService<IWeatherService>());
return kernelBuilder.Build();
});
var host = builder.Build();
Step 4: Build the native plugin class
Semantic Kernel discovers public methods on a class marked with [KernelFunction]. Each method becomes a callable function. Use Description attributes on the method and every parameter — the planner reads these to decide when and how to invoke the function.
// Plugins/WeatherPlugin.cs
using Microsoft.SemanticKernel;
using WeatherPluginDemo.Services;
namespace WeatherPluginDemo.Plugins;
public sealed class WeatherPlugin
{
private readonly IWeatherService _weather;
public WeatherPlugin(IWeatherService weather) => _weather = weather;
[KernelFunction("get_current_weather")]
[Description("Returns current weather for the given coordinates. Use when the user asks for right-now conditions.")]
public async Task<string> GetCurrentWeatherAsync(
[Description("Latitude in decimal degrees, e.g., 37.7749")] double latitude,
[Description("Longitude in decimal degrees, e.g., -122.4194")] double longitude,
CancellationToken ct = default)
{
ValidateCoordinates(latitude, longitude);
var current = await _weather.GetCurrentAsync(latitude, longitude, ct);
return FormatCurrent(current);
}
[KernelFunction("get_forecast")]
[Description("Returns a multi-day forecast for the given coordinates. Use when the user asks for upcoming days.")]
public async Task<string> GetForecastAsync(
[Description("Latitude in decimal degrees")] double latitude,
[Description("Longitude in decimal degrees")] double longitude,
[Description("Number of days to return, 1-7")] int days = 3,
CancellationToken ct = default)
{
ValidateCoordinates(latitude, longitude);
days = Math.Clamp(days, 1, 7);
var forecast = await _weather.GetForecastAsync(latitude, longitude, days, ct);
return FormatForecast(forecast);
}
private static void ValidateCoordinates(double lat, double lon)
{
if (lat < -90 || lat > 90) throw new ArgumentOutOfRangeException(nameof(lat), "Latitude must be between -90 and 90");
if (lon < -180 || lon > 180) throw new ArgumentOutOfRangeException(nameof(lon), "Longitude must be between -180 and 180");
}
private static string FormatCurrent(CurrentWeather w) => $$"""
Current weather (observed {{w.ObservedAt:yyyy-MM-dd HH:mm}}):
- Temperature: {{w.TemperatureCelsius:F1}}°C (feels like {{w.FeelsLikeCelsius:F1}}°C)
- Humidity: {{w.HumidityPercent}}%
- Wind: {{w.WindSpeedKph:F1}} km/h
- Condition: {{w.Condition}}
""";
private static string FormatForecast(Forecast f) => $$"""
{{f.Days.Count}}-day forecast:
{{string.Join("\n", f.Days.Select(d => $"- {d.Date:yyyy-MM-dd}: {d.Condition}, High {d.MaxTempCelsius:F1}°C / Low {d.MinTempCelsius:F1}°C, Precip {d.PrecipitationMm:F1}mm"))}}
""";
}
Return string from kernel functions. The planner treats the string as the function result and feeds it back to the model. Structured JSON works too, but plain text is easier to debug and often sufficient for weather summaries.
Step 5: Register the plugin with the kernel
Plugins can be added from a type, an instance, or a delegate. Since WeatherPlugin depends on IWeatherService, resolve it from the container and add the instance.
// Program.cs (continued)
using WeatherPluginDemo.Plugins;
var kernel = host.Services.GetRequiredService<Kernel>();
var weatherPlugin = host.Services.GetRequiredService<WeatherPlugin>();
kernel.Plugins.AddFromObject(weatherPlugin, "Weather");
If you prefer attribute-only registration without DI, use kernel.Plugins.AddFromType<WeatherPlugin>() and let the kernel activate it — but you lose control over the IWeatherService lifetime.
Step 6: Invoke the plugin from a prompt
Now wire a simple chat loop that lets the planner decide which function to call. The kernel handles function calling automatically when you enable AutoInvokeKernelFunctions.
// Program.cs (final)
using Microsoft.SemanticKernel.ChatCompletion;
using Microsoft.SemanticKernel.Connectors.OpenAI;
var chat = kernel.GetRequiredService<IChatCompletionService>();
var history = new ChatHistory("You are a helpful weather assistant. Use the Weather plugin when users ask about conditions or forecasts.");
Console.WriteLine("Weather assistant ready. Type 'exit' to quit.\n");
while (true)
{
Console.Write("> ");
var input = Console.ReadLine();
if (string.IsNullOrWhiteSpace(input) || input.Trim().Equals("exit", StringComparison.OrdinalIgnoreCase)) break;
history.AddUserMessage(input!);
var settings = new OpenAIPromptExecutionSettings
{
ToolCallBehavior = ToolCallBehavior.AutoInvokeKernelFunctions,
Temperature = 0.1
};
var result = await chat.GetChatMessageContentAsync(history, settings, kernel);
history.AddMessage(result.Role, result.Content ?? "");
Console.WriteLine($"\n{result.Content}\n");
}
Run it:
dotnet run
Try prompts like:
- “What’s the weather in San Francisco right now?” (triggers
get_current_weather) - “Give me a 5-day forecast for Tokyo.” (triggers
get_forecastwithdays=5)
The planner extracts coordinates from the city name using its own knowledge, then calls your plugin with those coordinates.
Step 7: Add a geocoding helper (optional but practical)
LLMs hallucinate coordinates. Give the plugin a geocoding function so the planner can resolve place names deterministically.
// Plugins/GeocodingPlugin.cs
using Microsoft.SemanticKernel;
using System.Text.Json;
namespace WeatherPluginDemo.Plugins;
public sealed class GeocodingPlugin
{
private readonly HttpClient _http;
public GeocodingPlugin(HttpClient http) => _http = http;
[KernelFunction("geocode")]
[Description("Converts a place name to latitude/longitude. Use before calling weather functions when you only have a city name.")]
public async Task<string> GeocodeAsync(
[Description("City name, optionally with state/country, e.g., 'San Francisco, CA, USA'")] string place,
CancellationToken ct = default)
{
var url = $"https://nominatim.openstreetmap.org/search?format=json&q={Uri.EscapeDataString(place)}&limit=1";
_http.DefaultRequestHeaders.UserAgent.ParseAdd("WeatherPluginDemo/1.0");
var results = await _http.GetFromJsonAsync<JsonElement[]>(url, ct);
if (results is null || results.Length == 0)
throw new InvalidOperationException($"Could not geocode '{place}'");
var first = results[0];
var lat = first.GetProperty("lat").GetDouble();
var lon = first.GetProperty("lon").GetDouble();
var display = first.GetProperty("display_name").GetString() ?? place;
return $$"""{"latitude": {{lat}}, "longitude": {{lon}}, "display_name": "{{display}}"}""";
}
}
Register it the same way:
builder.Services.AddHttpClient<GeocodingPlugin>(c => c.Timeout = TimeSpan.FromSeconds(10));
kernel.Plugins.AddFromObject(host.Services.GetRequiredService<GeocodingPlugin>(), "Geocoding");
Now the planner can chain: user asks “weather in Paris” → planner calls Geocoding.geocode → gets coordinates → calls Weather.get_current_weather.
Step 8: Verify success with unit tests
Test the plugin logic in isolation from the kernel. Mock IWeatherService and assert the formatted strings.
// Tests/WeatherPluginTests.cs
using Microsoft.SemanticKernel;
using Moq;
using WeatherPluginDemo.Plugins;
using WeatherPluginDemo.Services;
using Xunit;
namespace WeatherPluginDemo.Tests;
public class WeatherPluginTests
{
private readonly Mock<IWeatherService> _weatherMock = new();
private readonly WeatherPlugin _plugin;
public WeatherPluginTests() => _plugin = new WeatherPlugin(_weatherMock.Object);
[Fact]
public async Task GetCurrentWeatherAsync_ReturnsFormattedString()
{
var now = DateTimeOffset.UtcNow;
_weatherMock.Setup(w => w.GetCurrentAsync(37.7749, -122.4194, It.IsAny<CancellationToken>()))
.ReturnsAsync(new CurrentWeather(18.5, 17.2, 65, 12.3, "Clear", now));
var result = await _plugin.GetCurrentWeatherAsync(37.7749, -122.4194);
Assert.Contains("18.5°C", result);
Assert.Contains("feels like 17.2°C", result);
Assert.Contains("65%", result);
Assert.Contains("12.3 km/h", result);
Assert.Contains("Clear", result);
}
[Fact]
public async Task GetForecastAsync_ClampsDaysToSeven()
{
_weatherMock.Setup(w => w.GetForecastAsync(0, 0, 10, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Forecast([
new ForecastDay(DateOnly.FromDateTime(DateTime.Today), 20, 10, "Rain", 5)
]));
var result = await _plugin.GetForecastAsync(0, 0, 10); // request 10 days
Assert.Contains("1-day forecast", result); // clamped to 7, but only 1 day returned by mock
}
[Fact]
public async Task GetCurrentWeatherAsync_ThrowsOnInvalidLatitude()
{
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() =>
_plugin.GetCurrentWeatherAsync(91, 0));
}
}
Run dotnet test — all three should pass.
Step 9: Deploy considerations
- Secrets: Store the OpenWeatherMap API key in Azure Key Vault, AWS Secrets Manager, or user secrets (
dotnet user-secrets set OpenWeatherMap:ApiKey "..."). Never commit it. - Rate limits: OpenWeatherMap free tier allows 60 calls/minute. The resilience handler retries on 429, but add a
RateLimiterpolicy if you expect burst traffic. - Caching: Weather data changes slowly. Wrap
IWeatherServicewith aMemoryCachedecorator keyed by(lat, lon, endpoint)with a 10-minute TTL to reduce provider calls. - Observability: Add
ILogger<WeatherPlugin>and log each function invocation with latency and outcome. Correlate with kernel telemetry viaActivitySource.
// Example cache decorator (sketch)
public sealed class CachedWeatherService : IWeatherService
{
private readonly IWeatherService _inner;
private readonly IMemoryCache _cache;
public async Task<CurrentWeather> GetCurrentAsync(double lat, double lon, CancellationToken ct)
{
var key = $"current:{lat:F4},{lon:F4}";
return await _cache.GetOrCreateAsync(key, async entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);
return await _inner.GetCurrentAsync(lat, lon, ct);
})!;
}
// ... forecast similarly
}
Register it as builder.Services.Decorate<IWeatherService, CachedWeatherService>(); (requires Scrutor package).
Step 10: Extend with function filters for cross-cutting concerns
Semantic Kernel’s IFunctionInvocationFilter lets you run logic before and after every plugin call — logging, auth checks, quota enforcement — without cluttering the plugin class.
// Filters/PluginLoggingFilter.cs
using Microsoft.SemanticKernel;
namespace WeatherPluginDemo.Filters;
public sealed class PluginLoggingFilter(ILogger<PluginLoggingFilter> logger) : IFunctionInvocationFilter
{
public async Task OnFunctionInvocationAsync(FunctionInvocationContext context, Func<FunctionInvocationContext, Task> next)
{
var sw = Stopwatch.StartNew();
logger.LogInformation("Invoking {Plugin}.{Function} with args: {Args}",
context.Function.PluginName, context.Function.Name, context.Arguments);
try
{
await next(context);
logger.LogInformation("Completed {Plugin}.{Function} in {Elapsed}ms",
context.Function.PluginName, context.Function.Name, sw.ElapsedMilliseconds);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed {Plugin}.{Function} after {Elapsed}ms",
context.Function.PluginName, context.Function.Name, sw.ElapsedMilliseconds);
throw;
}
}
}
Register it once:
builder.Services.AddSingleton<IFunctionInvocationFilter, PluginLoggingFilter>();
builder.Services.AddSingleton<Kernel>(sp =>
{
var kernelBuilder = Kernel.CreateBuilder();
kernelBuilder.Services.AddSingleton(sp.GetRequiredService<IWeatherService>());
kernelBuilder.Services.AddSingleton(sp.GetRequiredService<IFunctionInvocationFilter>());
return kernelBuilder.Build();
});
Every plugin invocation now emits structured logs automatically.
You now have a weather plugin that is typed, tested, resilient, observable, and ready for the planner to compose. The same pattern applies to any deterministic capability: wrap the external dependency in an interface, expose a thin kernel function layer, and let DI handle the rest.