← Back to list

Semantic Kernel Plugins in C#: Expose Your .NET Services to an LLM Without an Intent Router

Expose your existing .NET services to an LLM using [KernelFunction], constructor injection, and FunctionChoiceBehavior.Auto() without…

Bhargava Koya - Fullstack .NET Developer in .Net Programming · 2026-05-29 19:53 · 106 claps · 22.3 min read
#semantic-kernel #csharp #dotnet #artificial-intelligence #aspnetcore
Open on Medium ↗
Wiki topics: LLM · Large Language Models AI · AI · General

Semantic Kernel Plugins in C#: Expose Your .NET Services to an LLM Without an Intent Router

Expose your existing .NET services to an LLM using [KernelFunction], constructor injection, and FunctionChoiceBehavior.Auto()without writing a single intent router.

You already have the C# services. The calendar service, the search service, the document repository, they exist, they are tested, and they work. The problem is that Semantic Kernel plugins are the missing bridge between those services and the LLM that needs to call them. Without plugins, you end up writing an intent router: a brittle string-matching layer that breaks every time a user phrases a request differently. Plugins replace that router with two attributes and a description. This post covers every pattern you need from [KernelFunction] registration to constructor injection to the FunctionChoiceBehavior.Auto() loop with a working ASP.NET Core POC at the end.

The Problem

You are three sprints into an AI feature. The requirement sounds simple: a chat interface where a user types “Summarise the Q1 budget doc and check if I have a meeting about it this week.”

Your first instinct is to detect intent, route to the right service, and stitch the response together in C#. You write a switch statement. Then a regex. Then an NLP classifier. The classifier handles one phrasing but misses another. You add more cases. The router becomes the most fragile file in the codebase.

The real problem is that you are writing orchestration logic that the LLM is already capable of doing — if only it could call your code. That is exactly what Semantic Kernel plugins provide.

What Semantic Kernel Plugins Are?

A plugin is a C# class whose public methods have been decorated with [KernelFunction] and [Description] attributes. Semantic Kernel reads those attributes using reflection, generates a JSON schema for each method, and sends that schema to the LLM alongside your prompt.

The LLM never calls your C# code directly. When it decides a function is relevant, it outputs a structured request — call get_calendar_events with date="2026-05-28"and SK intercepts that, invokes the method, and feeds the result back into the conversation. The model reasons with that result and decides whether to call another function or produce a final answer.

Plugins are the organisational layer wrapping this mechanism. They are ordinary C# classes. No base class. No interface requirement. Just attributes.

When to Use Plugins and When Not To

Use plugins when:

  • Your AI feature needs live data the LLM cannot know (calendar, database, external API)
  • You want the model to decide which action to take rather than writing a routing layer
  • You need multiple capabilities composable from a single prompt
  • You are building for the long term — SK plugins transfer to Microsoft Agent Framework without changes

Avoid plugins when:

  • The task is a pure language operation (translate, reformat, classify text you already have) — call InvokePromptAsync directly without tools
  • You need guaranteed, deterministic function execution — auto-calling means the model decides, and it can decide incorrectly
  • Your LLM deployment does not support function calling (some older or heavily quantised local models)
  • Latency is critical — each function invocation adds at least one LLM round-trip

Why It Matters in Enterprise Context?

An internal support desk application has three data sources: orders, inventory, and customer records. An agent types “Customer alice@company.com is asking if order #1001 shipped and whether replacement SKU-789 is in stock.” With auto-calling enabled, SK calls search_orders, then check_inventory, then combines both into a single response all from one prompt.

Without plugins, a developer writes and maintains an explicit orchestration layer for every combination of intent and data source. When a new data source is added, the orchestration layer changes. With plugins, you add a new class with [KernelFunction] methods and register it. The LLM discovers it automatically via the tool schema.

Concepts This Post Covers

  1. Plugin anatomy: the class structure and the two required attributes
  2. [KernelFunction]:registration, naming, and what the LLM actually receives
  3. [Description]: on methods and parameters
  4. Native functions: wrapping C# business logic as LLM-callable tools
  5. Prompt functions as plugins: registering LLM sub-calls within the plugin model
  6. Dependency Injection integration:AddFromType, AddFromObject, KernelPluginCollection, and lifecycle patterns
  7. FunctionChoiceBehavior.Auto():the auto-invocation loop that replaced deprecated planners
  8. Manual invocation: calling plugins explicitly without the auto loop
  9. Multi-plugin chaining: letting the model orchestrate several plugins from one prompt
  10. Hands-on POC:CalendarPlugin, SearchPlugin, and SummaryPlugin wired into a working ASP.NET Core endpoint

Concept 1 — Plugin Anatomy

What it is?

A plugin is a plain C# class with no required base class or interface. Public methods marked with [KernelFunction] become the tools the LLM can call.

Why it exists?

Without a structural convention, SK has no consistent way to discover callable methods across arbitrary classes. The two-attribute pair ([KernelFunction] + [Description]) acts as the contract between your C# code and the AI model.

How it works?

When you call kernel.Plugins.AddFromType<MyPlugin>() or kernel.Plugins.AddFromObject(instance), SK uses reflection to scan all public methods on the class. Methods missing [KernelFunction] are silently ignored — there is no convention-based discovery. Methods with the attribute are registered as KernelFunction objects inside a KernelPlugin. That collection is serialised into JSON schema and sent to the LLM as "tools" on every inference request.

Semantic Kernel plugin anatomy showing how [KernelFunction] attributes are reflected into a KernelPlugin and serialised as JSON tool schemas for the LLM

Semantic Kernel plugin anatomy showing how [KernelFunction] attributes are reflected into a KernelPlugin and serialised as JSON tool schemas for the LLM

Code Sample

using System.ComponentModel;
using Microsoft.SemanticKernel;

// No base class. No interface. Just attributes on public methods.
public class OrderPlugin
{
    [KernelFunction("get_order")]          // The name the LLM will use in its function call
    [Description(
        "Retrieve order details by order ID. " +
        "Returns current status, items ordered, and estimated delivery date. " +
        "Use when the user asks about a specific order.")]
    public async Task<string> GetOrderAsync(
        [Description("Numeric order ID, e.g. 1001")] int orderId)
    {
        // In production: query your order database
        return $"Order {orderId}: 2 items, Status: Shipped, ETA: June 3";
    }

    [KernelFunction("cancel_order")]
    [Description(
        "Cancel an unshipped order. " +
        "Returns confirmation or a failure reason if the order has already shipped.")]
    public async Task<string> CancelOrderAsync(
        [Description("The order ID to cancel")] int orderId)
    {
        return $"Order {orderId} cancelled. Refund processed in 3–5 business days.";
    }
}

Concept 2 — [KernelFunction] in Depth

What it is?

[KernelFunction] is an attribute in the Microsoft.SemanticKernel namespace. It accepts an optional string name parameter. If you omit the name, SK derives one from the method name by stripping the Async suffix and converting to snake_case.

Why it exists?

Explicit registration prevents accidental exposure. If SK used convention-based scanning like ASP.NET Core controllers do, every public helper method on your class would become an LLM-callable tool — including methods never intended for external invocation.

How it works?

The attribute name is the exact string the LLM must emit in its function call request. If your attribute says "get_order", the LLM says "get_order". Mismatches produce a KernelException: Function 'PluginName-FunctionName' not found error at runtime. Use snake_case — LLMs are trained on OpenAI's function calling specification, which uses that convention. PascalCase technically works but increases the chance of the model generating malformed requests.

KernelFunction attribute name resolution flow in Semantic Kernel showing explicit vs derived tool names

KernelFunction attribute name resolution flow in Semantic Kernel showing explicit vs derived tool names

Code Sample

using Microsoft.SemanticKernel;
using System.ComponentModel;

public class CalendarPlugin
{
    // Explicit name — the LLM emits "get_events", not "GetEventsAsync"
    [KernelFunction("get_events")]
    [Description(
        "Get calendar events for a specific date. " +
        "Returns meeting titles, start times, and durations. " +
        "Use when the user asks about their schedule, meetings, or agenda.")]
    public async Task<string> GetEventsAsync(
        [Description("Date in yyyy-MM-dd format, e.g. 2026-05-28")] string date)
    {
        return $"Events on {date}: 09:00 Standup (30m), 14:00 Architecture Review (1h)";
    }

    // No explicit name — SK derives "add_event" from "AddEventAsync"
    [KernelFunction]
    [Description(
        "Add a new event to the calendar. " +
        "Returns confirmation with the generated event ID.")]
    public async Task<string> AddEventAsync(
        [Description("Event title")] string title,
        [Description("Start time in ISO 8601 format")] string startTime,
        [Description("Duration in minutes")] int durationMinutes)
    {
        return $"Event '{title}' created at {startTime} for {durationMinutes} minutes. ID: EVT-9234";
    }
}

Concept 3 — [Description]: The Most Important Attribute

What it is?

[Description] comes from System.ComponentModelnot from Semantic Kernel. SK reads it to populate the description field of the JSON tool schema the LLM receives.

Why it exists?

The LLM has no access to your source code at inference time. The only signal it has for deciding which tool to call, and with what arguments, is the description string you write. Poor descriptions are the single most common reason auto-calling fails silently: the model skips tools it cannot understand or calls them with wrong arguments.

How it works?

SK serialises your method description into the top-level description field of the tool schema. Parameter descriptions go into the description field of each parameter. A method description that says "Gets data" gives the model nothing. One that says "Search the internal document repository by keyword. Returns up to 5 matching titles, authors, and snippets. Use when the user asks to find, locate, or look up documents." tells the model exactly when to use the tool and what to expect back.

Write descriptions as if you are documenting a public REST API for a consumer who has no access to your source code. Because that is exactly the situation.

How [Description] attributes in Semantic Kernel C# plugins map to JSON tool schema fields consumed by the LLM

How [Description] attributes in Semantic Kernel C# plugins map to JSON tool schema fields consumed by the LLM

Code Sample

using System.ComponentModel;
using Microsoft.SemanticKernel;

public class SearchPlugin
{
    // BAD — the LLM has no idea when or how to use this
    [KernelFunction("search")]
    [Description("Search")]
    public string BadSearch([Description("query")] string q) => "";

    // GOOD — purpose, scope, return shape, and usage hint are all present
    [KernelFunction("search_documents")]
    [Description(
        "Search the internal document repository by keyword or phrase. " +
        "Returns up to 5 matching document titles, authors, and snippet previews. " +
        "Use when the user asks to find, locate, or look up documents, reports, or files.")]
    public async Task<string> SearchDocumentsAsync(
        [Description("Search query — keywords or a short phrase, e.g. 'Q1 budget report'")] string query,
        [Description("Maximum number of results to return. Defaults to 5 if not specified.")] int maxResults = 5)
    {
        return $"Found 3 results for '{query}': " +
               "1) [DOC-1001] Q1 Budget 2026 (Finance, Jan 2026) — Overview of Q1 2026 budget... " +
               "2) [DOC-1002] Budget Guidelines (HR, Dec 2025) — Annual budget submission... " +
               "3) [DOC-1003] Cost Analysis (Finance, Feb 2026) — Detailed cost breakdown...";
    }
}

Concept 4 — Native Functions

What it is?

A native function is any C# method marked with [KernelFunction]. It is called by SK directly in your process, as opposed to a prompt function, which triggers another LLM call.

Why it exists?

LLMs have a training cutoff and no runtime access to your environment. Native functions bridge that gap giving the model access to live data, external APIs, databases, file systems, and any system your C# code can reach.

How it works?

SK treats the method’s return type as the function output. string returns are passed directly to the LLM. Complex types are serialised to JSON. Input arguments arrive from the LLM as JSON and are deserialised by SK into the declared parameter types. If the LLM sends a value that cannot be deserialised to the declared type, SK throws a KernelException with a type conversion message. Use string or primitive types for maximum reliability. Avoid Stream, HttpResponseMessage, or types with circular references — SK cannot serialise them.

Semantic Kernel native function invocation pipeline from LLM JSON call through C# method execution to result serialisation

Semantic Kernel native function invocation pipeline from LLM JSON call through C# method execution to result serialisation

Code Sample

using System.ComponentModel;
using Microsoft.SemanticKernel;

// Return type — SK serialises this to JSON for the LLM
public record DocumentSummaryResult(
    string Title,
    string Author,
    string Summary,
    string[] Keywords);

public class SummaryPlugin
{
    private readonly IDocumentService _documents;

    // Constructor injection — SK resolves this from the DI container
    public SummaryPlugin(IDocumentService documents)
        => _documents = documents;

    [KernelFunction("summarise_document")]
    [Description(
        "Retrieve and summarise a document by its unique ID. " +
        "Returns the document title, author, a 3-sentence summary, and up to 5 keywords. " +
        "Use after search_documents to get full content of a specific result. " +
        "Requires a document ID in the format DOC-####.")]
    public async Task<DocumentSummaryResult> SummariseDocumentAsync(
        [Description("Document ID from search results, e.g. DOC-1001")] string documentId)
    {
        // Real implementation queries your document storage
        var doc = await _documents.GetByIdAsync(documentId);

        // SK serialises DocumentSummaryResult → JSON → sent to LLM as the tool result
        return new DocumentSummaryResult(
            Title: doc.Title,
            Author: doc.Author,
            Summary: doc.ExtractSummary(sentences: 3),
            Keywords: doc.ExtractKeywords(max: 5));
    }
}

Concept 5 — Prompt Functions as Plugins

What it is?

A prompt function is a text template registered as a callable KernelFunction. The LLM can invoke another LLM call as if it were regular code — a sub-prompt within the plugin model.

Why it exists?

Some steps inside a workflow are better handled by a focused sub-prompt than by C# logic. A classification step, a tone normalisation step, or a format conversion step can all live inside the plugin model, making them composable with native functions.

How it works?

You create a KernelFunction from a prompt string using kernel.CreateFunctionFromPrompt. Name, description, and parameter descriptions work identically to a native function. When the LLM calls this tool, SK renders the template with the provided arguments and issues a new LLM call to produce the result.

Code Sample

// Partial example — assumes 'kernel' is already built with plugins registered

var classifyFunction = kernel.CreateFunctionFromPrompt(
    promptTemplate: """
        Classify the following support ticket into exactly one category:
        Categories: Billing, Technical, Shipping, Other

        Ticket: {{$ticket}}

        Respond with only the category name. Nothing else.
        """,
    functionName: "classify_ticket",
    description:
        "Classify a support ticket into one of four categories: Billing, Technical, Shipping, or Other. " +
        "Returns a single category word. " +
        "Use when routing or categorising incoming support requests.");

// Register alongside native plugins under a shared plugin name
kernel.Plugins.AddFromFunctions("SupportTools", [classifyFunction]);

Concept 6 — Dependency Injection Integration

What it is?

DI integration means your plugin classes declare constructor dependencies — ILogger<T>, DbContext, HttpClient, domain services and have them resolved from the ASP.NET Core container.

Why it exists?

Plugins without DI are isolated. The moment a plugin needs a database connection or an external service client, you either use a service locator pattern (which undermines testability) or wire dependencies manually (which breaks when service lifetimes change). SK’s DI support lets plugins participate in the same container lifecycle as the rest of your application.

How it works?

There are three patterns, each with a different trade-off.

Pattern 1 — AddFromType<T>() with a shared service collection. You call builder.Plugins.AddFromType<T>() during host setup. SK's kernel builder shares the same IServiceProvider, so it resolves constructor dependencies automatically. This is the simplest pattern for singletons.

Pattern 2 — AddFromObject() with a pre-resolved instance. You resolve your plugin from IServiceProvider and pass the live instance to AddFromObject. This gives you control over when the instance is created and is the safest pattern when the plugin depends on scoped services.

Pattern 3 — KernelPluginCollection + transient Kernel (recommended for production). Plugins are registered as singletons in the container, assembled into a KernelPluginCollection, and passed to a transient Kernel registration. One plugin collection created at startup; one kernel per request.

Circular dependency warning. Never inject Kernel into a service that your plugin also depends on. If ServiceA depends on Kernel, and Kernel depends on PluginA, and PluginA depends on ServiceA, the DI container will throw a stack overflow or unresolved service error. Always register plugins as singletons and kernels as transient.

Semantic Kernel DI integration pattern in ASP.NET Core showing singleton plugins, KernelPluginCollection, and transient Kernel lifetime management

Semantic Kernel DI integration pattern in ASP.NET Core showing singleton plugins, KernelPluginCollection, and transient Kernel lifetime management

Code Sample

// Program.cs — ASP.NET Core Web API

var builder = WebApplication.CreateBuilder(args);

// --- Step 1: Register domain services as singletons ---
builder.Services.AddSingleton<ICalendarService, GoogleCalendarService>();
builder.Services.AddSingleton<ISearchService, ElasticSearchService>();
builder.Services.AddSingleton<IDocumentService, DocumentService>();

// --- Step 2: Register plugins as singletons ---
// Each plugin's constructor will receive its ICalendarService / ISearchService / IDocumentService
// from the DI container when the singleton is first resolved.
builder.Services.AddSingleton<CalendarPlugin>();
builder.Services.AddSingleton<SearchPlugin>();
builder.Services.AddSingleton<SummaryPlugin>();

// --- Step 3: Build a KernelPluginCollection once at startup ---
// KernelPluginFactory.CreateFromObject wraps a live instance in a KernelPlugin.
builder.Services.AddSingleton<KernelPluginCollection>(sp =>
[
    KernelPluginFactory.CreateFromObject(sp.GetRequiredService<CalendarPlugin>()),
    KernelPluginFactory.CreateFromObject(sp.GetRequiredService<SearchPlugin>()),
    KernelPluginFactory.CreateFromObject(sp.GetRequiredService<SummaryPlugin>()),
]);

// --- Step 4: Register the chat completion service ---
builder.Services.AddSingleton<IChatCompletionService>(sp =>
{
    var config = sp.GetRequiredService<IConfiguration>();
    return new AzureOpenAIChatCompletionService(
        deploymentName: config["AzureOpenAI:Deployment"]!,
        endpoint: config["AzureOpenAI:Endpoint"]!,
        apiKey: config["AzureOpenAI:ApiKey"]!);
});

// --- Step 5: Register Kernel as transient ---
// Transient = a fresh Kernel per request. Lightweight — plugins are singletons, shared.
// Do NOT register Kernel as singleton — it holds per-request state.
builder.Services.AddTransient(sp =>
{
    var plugins = sp.GetRequiredService<KernelPluginCollection>();
    return new Kernel(sp, plugins);  // sp used for additional service resolution at runtime
});

builder.Services.AddControllers();
var app = builder.Build();
app.MapControllers();
app.Run();

Concept 7 — FunctionChoiceBehavior.Auto() and the Auto-Invocation Loop

What it is?

FunctionChoiceBehavior.Auto() is the execution setting that tells SK to enter an automatic function invocation loop: send tools to the LLM, invoke whatever it requests, feed the result back, and repeat until the model produces a final text response.

Why it exists?

The deprecated HandlebarsPlanner and FunctionCallingStepwisePlanner required a separate LLM call to generate a plan before executing anything. If the plan was wrong, the entire workflow failed — there was no mid-execution correction. Auto function calling removes the planning step entirely. The model self-corrects in real time based on what each function returns.

How it works?

When FunctionChoiceBehavior.Auto() is set on the execution settings:

  1. SK sends your prompt and all registered function schemas to the LLM.
  2. If the LLM responds with a function call, SK deserialises the arguments and invokes the method.
  3. SK appends the function result to chat history as a tool message.
  4. SK sends the updated history back to the LLM.
  5. Steps 2–4 repeat until the LLM produces a plain text response.

By default SK allows up to 128 loop iterations. You can set autoInvoke: false to intercept and handle function calls yourself. ToolCallBehavior.AutoInvokeKernelFunctions is the old API — it still compiles but is deprecated and should not appear in new code.

Semantic Kernel FunctionChoiceBehavior.Auto() multi-step auto-invocation loop showing three plugin calls chained from a single prompt

Semantic Kernel FunctionChoiceBehavior.Auto() multi-step auto-invocation loop showing three plugin calls chained from a single prompt

Code Sample

// Partial example — assumes 'kernel' is injected with all three plugins registered

using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Connectors.AzureOpenAI;

var executionSettings = new AzureOpenAIPromptExecutionSettings
{
    // Auto(): LLM picks which functions to call; SK invokes them automatically.
    FunctionChoiceBehavior = FunctionChoiceBehavior.Auto()

    // Alternative — let LLM see tools but handle invocation yourself:
    // FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(autoInvoke: false)

    // Alternative — force at least one function call:
    // FunctionChoiceBehavior = FunctionChoiceBehavior.Required()
};

// Single prompt triggers a multi-step auto-calling chain
var result = await kernel.InvokePromptAsync(
    "Find documents about the Q1 budget, summarise the most relevant one, " +
    "and check if I have any meetings tomorrow.",
    new KernelArguments(executionSettings));

Console.WriteLine(result);
// Auto-calling loop:
//   Round 1: LLM calls search_documents("Q1 budget") → returns DOC-1001, DOC-1002, DOC-1003
//   Round 2: LLM calls summarise_document("DOC-1001") → returns title, author, 3-sentence summary
//   Round 3: LLM calls get_events("2026-05-29") → returns tomorrow's meetings
//   Final:   LLM composes a natural-language response from all three results

Concept 8 — Manual Plugin Invocation

What it is?

Manual invocation calls a registered plugin function directly from C# code, bypassing the LLM entirely.

Why it exists?

Not every function call needs the model in the loop. If you know exactly which function to call and with what arguments, invoking it directly is faster, cheaper (no token cost), and fully deterministic.

Code Sample

// Partial example — assumes CalendarPlugin is registered on the kernel

// Direct kernel invocation — no LLM call, no auto-calling loop
var result = await kernel.InvokeAsync<string>(
    pluginName: "CalendarPlugin",    // Must match the registered plugin name
    functionName: "get_events",      // Must match the [KernelFunction] name
    arguments: new KernelArguments
    {
        ["date"] = "2026-05-28"      // Parameter name must match the method signature
    });

Console.WriteLine(result);
// Output: "Events on 2026-05-28: 09:00 Standup (30m), 14:00 Architecture Review (1h)"

Hands-On POC: CalendarPlugin, SearchPlugin, SummaryPlugin

Problem Statement

You are building an internal AI assistant for a .NET engineering team. Users ask questions like “Summarise the architecture doc from last month and tell me if there is a related meeting this week.” A single ASP.NET Core endpoint should accept the prompt, auto-invoke the right combination of three plugins, and return a composed answer.

The goal is a runnable starting point swap the stub implementations for real services when the demo proves the pattern works.

Step 1 — Create the Project and Install Packages

Start here to ensure all attribute and SK types are available before writing any plugin code.

dotnet new webapi -n AIAssistant
cd AIAssistant
dotnet add package Microsoft.SemanticKernel
dotnet add package Microsoft.SemanticKernel.Connectors.AzureOpenAI

Step 2 — Define Domain Interfaces and Stubs

Define interfaces first, then stubs. This separation means the plugin code depends on abstractions, not on infrastructure and you can test plugins without a real database or calendar API.

// Interfaces/ICalendarService.cs
public interface ICalendarService
{
    Task<IEnumerable<string>> GetEventsForDateAsync(DateOnly date);
}

// Interfaces/ISearchService.cs
public interface ISearchService
{
    Task<IEnumerable<SearchResult>> SearchAsync(string query, int maxResults);
}

public record SearchResult(string Id, string Title, string Author, string Snippet);

// Interfaces/IDocumentService.cs
public interface IDocumentService
{
    Task<Document> GetByIdAsync(string documentId);
}

public record Document(string Id, string Title, string Author, string FullText)
{
    public string ExtractSummary(int sentences) =>
        string.Join(" ", FullText.Split('.').Take(sentences).Select(s => s.Trim() + "."));
}
// Stubs/StubCalendarService.cs
public class StubCalendarService : ICalendarService
{
    public Task<IEnumerable<string>> GetEventsForDateAsync(DateOnly date)
    {
        IEnumerable<string> events = date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday
            ? []
            : ["09:00 Daily Standup (30m)", "14:00 Architecture Review (1h)"];
        return Task.FromResult(events);
    }
}

// Stubs/StubSearchService.cs
public class StubSearchService : ISearchService
{
    private static readonly List<SearchResult> _docs =
    [
        new("DOC-1001", "Q1 Budget 2026", "Finance Team",
            "Overview of Q1 2026 budget allocations across infrastructure and engineering."),
        new("DOC-1002", "Architecture Decision: Plugin System", "Platform Team",
            "Decision to adopt Semantic Kernel plugins for AI capability composition."),
        new("DOC-1003", "Team Handbook 2026", "HR",
            "Guidelines for remote work, communication, and performance reviews.")
    ];

    public Task<IEnumerable<SearchResult>> SearchAsync(string query, int maxResults)
    {
        var lower = query.ToLowerInvariant();
        var results = _docs
            .Where(d => d.Title.Contains(lower, StringComparison.OrdinalIgnoreCase)
                     || d.Snippet.Contains(lower, StringComparison.OrdinalIgnoreCase))
            .Take(maxResults);
        return Task.FromResult(results);
    }
}

// Stubs/StubDocumentService.cs
public class StubDocumentService : IDocumentService
{
    private static readonly Dictionary<string, Document> _store = new()
    {
        ["DOC-1001"] = new("DOC-1001", "Q1 Budget 2026", "Finance Team",
            "The Q1 2026 budget allocates $2.4M to infrastructure. " +
            "Cloud spend increased 18% year-on-year due to AI workloads. " +
            "Cost optimisation initiatives are targeting a 12% reduction by Q2."),
        ["DOC-1002"] = new("DOC-1002", "Architecture Decision: Plugin System", "Platform Team",
            "The team evaluated LangChain, Semantic Kernel, and a custom solution. " +
            "Semantic Kernel was selected for its native .NET support and DI integration. " +
            "The plugin model allows teams to add capabilities without modifying the core AI layer.")
    };

    public Task<Document> GetByIdAsync(string documentId) =>
        Task.FromResult(_store.TryGetValue(documentId, out var doc)
            ? doc
            : throw new KeyNotFoundException($"Document {documentId} not found."));
}

Step 3 — Write the Three Plugins

Descriptions are the most critical part of this step. Do not move on until each description clearly states what the function does, what it returns, and when to call it.

// Plugins/CalendarPlugin.cs
using System.ComponentModel;
using Microsoft.SemanticKernel;

public class CalendarPlugin
{
    private readonly ICalendarService _calendar;

    public CalendarPlugin(ICalendarService calendar) => _calendar = calendar;

    [KernelFunction("get_events")]
    [Description(
        "Get all calendar events for a specific date. " +
        "Returns event titles, start times, and durations. " +
        "Use when the user asks about their schedule, meetings, or what is on their calendar.")]
    public async Task<string> GetEventsAsync(
        [Description("Date to query in yyyy-MM-dd format")] string date)
    {
        var parsed = DateOnly.Parse(date);
        var events = await _calendar.GetEventsForDateAsync(parsed);
        return events.Any()
            ? string.Join(", ", events)
            : $"No events found for {date}.";
    }
}

// Plugins/SearchPlugin.cs
public class SearchPlugin
{
    private readonly ISearchService _search;

    public SearchPlugin(ISearchService search) => _search = search;

    [KernelFunction("search_documents")]
    [Description(
        "Search the internal document repository by keyword or phrase. " +
        "Returns up to 5 matching documents with ID, title, author, and a short snippet. " +
        "Use when the user asks to find, locate, or look up documents, reports, or files.")]
    public async Task<string> SearchDocumentsAsync(
        [Description("Search query — keywords or a short phrase")] string query,
        [Description("Maximum number of results to return, default 5")] int maxResults = 5)
    {
        var results = await _search.SearchAsync(query, maxResults);

        if (!results.Any())
            return $"No documents found for '{query}'.";

        // Structured format so the LLM can extract document IDs for follow-up calls
        return string.Join("\n", results.Select((r, i) =>
            $"{i + 1}. [{r.Id}] {r.Title} by {r.Author} — {r.Snippet}"));
    }
}

// Plugins/SummaryPlugin.cs
public class SummaryPlugin
{
    private readonly IDocumentService _documents;

    public SummaryPlugin(IDocumentService documents) => _documents = documents;

    [KernelFunction("summarise_document")]
    [Description(
        "Retrieve and summarise a document by its unique ID. " +
        "Returns the title, author, and a 3-sentence summary. " +
        "Use after search_documents to get full content of a specific result. " +
        "Requires a document ID from search results in the format DOC-####.")]
    public async Task<string> SummariseDocumentAsync(
        [Description("Document ID from search results, e.g. DOC-1001")] string documentId)
    {
        var doc = await _documents.GetByIdAsync(documentId);
        return $"Title: {doc.Title}\nAuthor: {doc.Author}\nSummary: {doc.ExtractSummary(sentences: 3)}";
    }
}

Step 4 — Register Everything in Program.cs

The order matters here. Domain services first, plugins second, plugin collection third, kernel last. The kernel depends on the collection; the collection depends on the plugin instances; the plugin instances depend on the services.

// Program.cs
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.ChatCompletion;
using Microsoft.SemanticKernel.Connectors.AzureOpenAI;

var builder = WebApplication.CreateBuilder(args);

// 1. Domain services — singletons because they wrap thread-safe infrastructure
builder.Services.AddSingleton<ICalendarService, StubCalendarService>();
builder.Services.AddSingleton<ISearchService, StubSearchService>();
builder.Services.AddSingleton<IDocumentService, StubDocumentService>();

// 2. Plugins — singletons so dependencies are created once
builder.Services.AddSingleton<CalendarPlugin>();
builder.Services.AddSingleton<SearchPlugin>();
builder.Services.AddSingleton<SummaryPlugin>();

// 3. Plugin collection — assembled once, reused across all requests
builder.Services.AddSingleton<KernelPluginCollection>(sp =>
[
    KernelPluginFactory.CreateFromObject(sp.GetRequiredService<CalendarPlugin>()),
    KernelPluginFactory.CreateFromObject(sp.GetRequiredService<SearchPlugin>()),
    KernelPluginFactory.CreateFromObject(sp.GetRequiredService<SummaryPlugin>()),
]);

// 4. Chat completion service — singleton, shared across all kernels
builder.Services.AddSingleton<IChatCompletionService>(_ =>
    new AzureOpenAIChatCompletionService(
        deploymentName: builder.Configuration["AzureOpenAI:Deployment"]!,
        endpoint:        builder.Configuration["AzureOpenAI:Endpoint"]!,
        apiKey:          builder.Configuration["AzureOpenAI:ApiKey"]!));

// 5. Kernel — transient so each request gets a clean instance
//    The kernel is cheap to construct; plugins are the expensive part and are reused
builder.Services.AddTransient(sp =>
{
    var plugins = sp.GetRequiredService<KernelPluginCollection>();
    return new Kernel(sp, plugins);
});

builder.Services.AddControllers();
var app = builder.Build();
app.MapControllers();
app.Run();

Step 5 — Write the API Controller

The controller injects the transient Kernel, sets auto-calling, and returns the response. No routing logic. No intent detection.

// Controllers/AIController.cs
using Microsoft.AspNetCore.Mvc;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Connectors.AzureOpenAI;

[ApiController]
[Route("api/[controller]")]
public class AIController : ControllerBase
{
    private readonly Kernel _kernel;

    // Kernel is transient — a fresh instance per request with all plugins registered
    public AIController(Kernel kernel) => _kernel = kernel;

    [HttpPost("ask")]
    public async Task<IActionResult> Ask([FromBody] AskRequest request)
    {
        var settings = new AzureOpenAIPromptExecutionSettings
        {
            // Auto(): SK invokes whatever functions the LLM requests — no manual loop needed
            FunctionChoiceBehavior = FunctionChoiceBehavior.Auto()
        };

        var result = await _kernel.InvokePromptAsync(
            request.Prompt,
            new KernelArguments(settings));

        return Ok(new { answer = result.ToString() });
    }
}

public record AskRequest(string Prompt);

Step 6 — Add Configuration

Add your Azure OpenAI details to appsettings.Development.json. Never commit real keys to source control use User Secrets or environment variables in production.

{
  "AzureOpenAI": {
    "Deployment": "gpt-4o",
    "Endpoint": "https://your-resource.openai.azure.com/",
    "ApiKey": "your-api-key-here"
  }
}

Final Verification

Start the application:

dotnet run

Send a test prompt:

curl -X POST https://localhost:5001/api/ai/ask \
  -H "Content-Type: application/json" \
  -d '{"prompt": "Find documents about the Q1 budget, summarise the top result, and tell me what meetings I have tomorrow."}'

Expected auto-calling trace (visible with SK logging enabled):

  1. LLM calls search_documents("Q1 budget") → returns DOC-1001, DOC-1002
  2. LLM calls summarise_document("DOC-1001") → returns title, author, 3-sentence summary
  3. LLM calls get_events("<tomorrow's date>") → returns standup and architecture review
  4. LLM produces final text response

Expected response (approximate):

{
  "answer": "The Q1 Budget 2026 document by Finance Team covers infrastructure spending of $2.4M, an 18% increase in cloud costs driven by AI workloads, and a target 12% reduction by Q2. Tomorrow you have two meetings: a Daily Standup at 09:00 (30 minutes) and an Architecture Review at 14:00 (1 hour)."
}

Best Practices

1. Write descriptions for the model, not for a developer. State what the function returns, when to use it, and the expected format of each parameter. A two-word description is a bug that produces a silent failure.

2. Register plugins as singletons, kernels as transient. Plugins often hold expensive dependencies. Create them once. Kernels are lightweight — create one per request to avoid shared state between concurrent users.

3. Separate read plugins from write plugins. Query plugins (no side effects) can be registered freely. Mutation plugins — cancel order, send email, delete record should be guarded by an IAutoFunctionInvocationFilter that requires explicit confirmation before execution. The model will call destructive functions if they are available and the prompt hints at it.

4. Use snake_case for all function names. LLMs are trained on OpenAI’s function calling specification, which uses snake_case. PascalCase names technically work but increase the likelihood of the model generating malformed function call requests.

5. Return string or JSON-serialisable types only. SK serialises return values to JSON before feeding them to the LLM. Types with circular references, Stream, or HttpResponseMessage will throw at the serialisation step. For complex return types, use records.

6. Keep each plugin single-domain. One plugin per business capability. A UtilityPlugin with twenty unrelated methods gives the model too many options to distinguish between, which degrades tool selection accuracy.

Common Mistakes

  1. Missing [KernelFunction] on the method. SK does not scan for methods automatically. If the attribute is absent, the method is invisible to the plugin registry — no error is thrown at registration time. The function simply does not appear in the tool schema.

How to catch it early: After registration, iterate kernel.Plugins and log every function name. Add a startup assertion: Debug.Assert(kernel.Plugins["CalendarPlugin"].Contains("get_events")).

2. Vague or missing [Description]. The model silently skips tools it cannot understand, or calls them with wrong arguments. This looks like the LLM saying "I don't have access to that information" even when a plugin exists for exactly that purpose.

How to catch it early: Test each function independently. Send a prompt that exactly matches the description’s stated use case and verify the function is called. If it is not, the description is the first thing to revise.

3. Using ToolCallBehavior.AutoInvokeKernelFunctions instead of FunctionChoiceBehavior.Auto(). The old API still compiles but is deprecated. SK emits compiler warnings for it — treat those warnings as errors in CI.

How to catch it early: Add <TreatWarningsAsErrors>true</TreatWarningsAsErrors> to your .csproj or configure it in your CI pipeline.

4. Injecting Kernel into a service that a plugin also depends on. This creates a circular dependency that manifests as a stack overflow or an unresolved service exception at startup, not at the circular dependency point.

How to catch it early: Never register Kernel as a singleton. If a service needs to invoke functions, inject the IKernelBuilder or the specific plugin directly instead.

Bottlenecks at Scale

  1. Token overhead from large plugin collections. Every registered function schema is included in the system message of every LLM call. With 20 plugins containing 5 functions each, the tool schema alone adds 2,000–4,000 tokens per request. At scale, this drives up cost and can push you over context window limits.

How companies handle it: Dynamic plugin registration — resolve only the subset of plugins relevant to the authenticated user’s role or the current task type. This requires a plugin selection layer before the main inference call, but it is the only sustainable approach at volume.

2. Multi-step auto-calling latency. Each function invocation adds at least one LLM round-trip. A three-step chain is three sequential LLM calls. At 800ms per call, that is 2.4 seconds before the user sees anything.

How companies handle it: Streaming responses for partial output so users see text as it arrives; result caching with IAutoFunctionInvocationFilter for frequent read queries; explicit sub-prompts for workflows where the call sequence is predictable.

3. Scoped service lifetime leakage. If your plugins are registered as singletons but depend on per-request state (user identity, tenant context, ambient transaction), that state leaks between concurrent users.

How companies handle it: Use IHttpContextAccessor carefully in singleton plugins, or register plugins as scoped and accept per-request instantiation overhead resolved via AddFromObject inside a middleware or factory.

Alternatives

  1. Raw function calling via Azure.AI.OpenAI SDK. Write the JSON tool schema by hand, manage the function call loop manually, and deserialise arguments yourself. Maximum control, maximum boilerplate. Worth it only when SK's abstractions are a blocking constraint.
  2. LangChain (Python or thin .NET wrappers). Richer ecosystem of pre-built tools and agents, but no first-class C# support. The .NET story is thin. Trade-off: broader tooling, significantly worse DI and ASP.NET Core integration.
  3. Microsoft Agent Framework (GA 2026). The successor layer built on top of Semantic Kernel. SK plugins work unchanged. Adds multi-agent orchestration, MCP integration, and human-in-the-loop checkpointing. Trade-off: additional framework surface area; still evolving rapidly as of mid-2026.

Closing Thought

The [Description] attribute is where most teams hit their first invisible wall. You have spent years writing code comments for developers. Descriptions for LLMs require a different discipline — the model has no context about your system beyond what you put in that string.

Look at your existing services. If an LLM could only read the method name and one sentence, would it know exactly when to call that method and what to pass? If the answer is no, the problem predates AI. It is the same clarity problem that lives in every codebase where Process, Handle, and Execute dominate the public surface. Semantic Kernel just makes the ambiguity immediately visible and expensive.

Source Code: AI_Engineer_Learnings/AIAssistant at main · bhargavkoya/AI_Engineer_Learnings

I hope you enjoyed reading this blog!

I’d love to hear your thoughts. please share your feedback or questions in the comments below and let me know if you’d like any clarifications on the topics covered. If you enjoyed this blog, don’t forget to like it and subscribe for more technology insights.

Stay tuned! In upcoming posts, I’ll be diving into advanced .NET topics such latest updates on .NET, UI frameworks like Angular and React, Blazor, AI, Machine learning, Python. Additionally, much more topics on DSA and System Design.

Thank you for joining me on this learning journey!

Stay curious, keep coding and never stop learning.

Connect with me:

  • Follow me on Medium and LinkedIn for regular updates
  • Share your thoughts and feedback in the comments
  • Suggest topics you’d like me to over next. Stay curious, keep coding and never stop learning.

메타데이터
post_id
b25af9c76321
slug
semantic-kernel-plugins-csharp-kernelfunction-di-auto-calling-b25af9c76321
url
https://medium.com/c-sharp-programming/semantic-kernel-plugins-csharp-kernelfunction-di-auto-calling-b25af9c76321
canonical_url
https://medium.com/c-sharp-programming/semantic-kernel-plugins-csharp-kernelfunction-di-auto-calling-b25af9c76321
author_url
https://medium.com/@bhargavkoya56
status
ok
fetched_at
2026-06-14 11:28:49