n4nAI

Building a Semantic Kernel plugin from scratch

Step-by-step guide to build semantic kernel plugin from scratch in C#: define native functions, semantic functions, wire up LLM, and verify with tests.

n4n Team4 min read989 words

Audio narration

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

To build semantic kernel plugin from scratch, you need to understand the two function types the SDK recognizes: native functions written in C# and semantic functions defined by prompt templates. This walkthrough constructs a working plugin that combines both, targeting .NET 8 and the Microsoft.SemanticKernel NuGet package, so you can call LLM and code through one orchestration layer.

Step 1: Scaffold the project and add the SDK

Create a fresh console app. Semantic Kernel runs on .NET 6+, but .NET 8 is the clean baseline for new work.

dotnet new console -n PluginDemo
cd PluginDemo
dotnet add package Microsoft.SemanticKernel --version 1.20.0

Pin the version. The API surface shifts between minor releases; a pinned package prevents silent breakage when you return to this code in three months. If you plan to use Azure OpenAI or a third-party gateway, add the corresponding connector package such as Microsoft.SemanticKernel.Connectors.OpenAI.

You also need an LLM endpoint. The default OpenAIChatCompletionService expects an OpenAI key, but any OpenAI-compatible endpoint works. If you want one gateway that fronts 240+ models with automatic fallback when a provider is degraded, point the kernel at n4n.ai’s OpenAI-compatible endpoint and pass the model name in the request.

Step 2: Write a native function for deterministic work

Native functions are plain C# methods. The kernel invokes them when a prompt or planner decides they are useful. Mark them with [KernelFunction] and give them descriptive names; the name is the identifier the LLM sees.

Create a file TextTools.cs:

using Microsoft.SemanticKernel;

public class TextTools
{
    [KernelFunction("count_words")]
    [Description("Count the number of whitespace-separated words in a string.")]
    public int CountWords(string input)
    {
        if (string.IsNullOrWhiteSpace(input))
            return 0;
        return input.Split([' ', '\t', '\n'], StringSplitOptions.RemoveEmptyEntries).Length;
    }

    [KernelFunction("reverse_text")]
    [Description("Return the input string reversed character by character.")]
    public string ReverseText(string input) => new string(input.Reverse().ToArray());
}

Rules we enforce in production: native functions must be side-effect free unless the description says otherwise, and they must validate arguments. The kernel does not sandbox your code; a bad description leads the model to call a destructive method. Keep these methods pure and fast. If you need I/O, inject an HttpClient via the class constructor and register the class with DI rather than using AddFromType on a zero-arg type.

When you build semantic kernel plugin from scratch, resist the urge to put business logic in the prompt. Prompts are non-deterministic and hard to test. Use native functions for anything that parses, counts, formats, or calls internal services.

Step 3: Author a semantic function with a prompt template

Semantic functions live as prompt templates. You can store them as .txt files or define them inline. Inline is faster to iterate; file-based is better for version control of prompt logic. Below, we define a semantic function that uses the native count_words function inside the prompt via the {{plugin.function}} syntax.

using Microsoft.SemanticKernel;

var kernel = Kernel.CreateBuilder().Build(); // temporary, replaced in step 4
kernel.Plugins.AddFromType<TextTools>("TextTools");

const string promptTemplate = """
You are a writing assistant. The user provided this draft:

{{$input}}

Word count: {{TextTools.count_words $input}}
Reverse the draft only if the word count is under 5, otherwise summarize in one line.
""";

var semanticFunction = kernel.CreateFunctionFromPrompt(
    promptTemplate,
    new Microsoft.SemanticKernel.PromptTemplateConfig("gpt-4o-mini")
    {
        Description = "Conditionally reverse or summarize text based on length."
    });

The {{$input}} is the default variable passed to InvokeAsync. The {{TextTools.count_words $input}} is a function call embedded in the template—this is where you build semantic kernel plugin from scratch with real composition rather than string concatenation. The kernel resolves that reference at execution time and injects the integer result into the rendered prompt.

If you prefer file-based prompts, create Writer/ConditionallyReverse.txt and load it with kernel.CreateFunctionFromPromptFile("Writer/ConditionallyReverse.txt"). The file format supports YAML front-matter for the config block.

Step 4: Register the plugin and configure the LLM

Now wire a chat completion service and register both function types as a single plugin. We use KernelPluginFactory.CreateFromFunctions to bundle the semantic function alongside the native class.

using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.OpenAI;

var builder = Kernel.CreateBuilder();

// OpenAI-compatible chat service
builder.AddOpenAIChatCompletion(
    modelId: "gpt-4o-mini",
    apiKey: Environment.GetEnvironmentVariable("OPENAI_API_KEY")!);

// If using a gateway:
// builder.AddOpenAIChatCompletion(
//     modelId: "anthropic/claude-3.5-sonnet",
//     endpoint: new Uri("https://api.n4n.ai/v1"),
//     apiKey: Environment.GetEnvironmentVariable("N4N_API_KEY")!);

var kernel = builder.Build();

// Native functions
kernel.Plugins.AddFromType<TextTools>("TextTools");

// Semantic function wrapped as its own plugin
var semanticPlugin = KernelPluginFactory.CreateFromFunctions(
    "Writer",
    "Text writing helpers",
    new[] { semanticFunction });
kernel.Plugins.Add(semanticPlugin);

At this point the kernel holds two plugins: TextTools (native) and Writer (semantic). The model can route between them. When you build semantic kernel plugin from scratch, keep the plugin granularity at the capability level, not per-method; it makes the planner’s job easier. A plugin named TextTools with three related functions beats three plugins named CountWords, ReverseText, Slugify.

Step 5: Invoke the plugin end to end

Call the semantic function directly, and also show a manual native call to prove both paths work.

var draft = "Semantic Kernel binds prompts and code without a heavy framework.";

// Direct native call
var words = await kernel.InvokeAsync<int>(kernel.Plugins["TextTools"]["count_words"], new() { ["input"] = draft });
Console.WriteLine($"Native word count: {words}");

// Semantic function call (uses native inside prompt)
var result = await kernel.InvokeAsync(kernel.Plugins["Writer"]["FunctionFromPrompt"], new() { ["input"] = draft });
Console.WriteLine($"Model output: {result.GetValue<string>()}");

If the draft has more than five words, the model should print a one-line summary. If you pass "hello world", it reverses to "world hello". That branching happens because the prompt embedded the native output.

For production, wrap the kernel in a host service and expose InvokeAsync through your API. Streaming requires IChatCompletionService.GetStreamingChatMessageContentsAsync and a PromptExecutionSettings with FunctionChoiceBehavior.Auto. Do not block on streaming and function calls simultaneously unless you have tested the connector’s support.

Step 6: Verify success with a test harness

Create a separate test project or just assert in Program.cs. A minimal verification:

using Microsoft.SemanticKernel;

var kernel = Kernel.CreateBuilder().Build();
kernel.Plugins.AddFromType<TextTools>("TextTools");

var count = await kernel.InvokeAsync<int>(
    kernel.Plugins["TextTools"]["count_words"],
    new KernelArguments { ["input"] = "one two three" });

if (count != 3)
    throw new Exception($"Expected 3, got {count}");

Console.WriteLine("Native function verified.");

Run dotnet run. You should see the native count printed, then the model’s response. If the semantic call returns an empty string, check that the model ID is valid and the API key has quota. The prompt template compilation is strict: a missing plugin reference throws at invoke time, not at registration.

To verify the semantic path without burning tokens, mock the chat service with a stub IChatCompletionService that returns a fixed ChatMessageContent. That isolates prompt wiring from LLM availability and keeps your CI fast.

Step 7: Extract the plugin into a class library

For reuse across services, move TextTools.cs and the prompt files into a separate .csproj named MyPlugins. Expose a static method that returns a configured KernelPlugin:

public static class PluginRegistry
{
    public static KernelPlugin CreateWriterPlugin(Kernel kernel)
    {
        kernel.Plugins.AddFromType<TextTools>("TextTools");
        var fn = kernel.CreateFunctionFromPromptFile("Writer/ConditionallyReverse.txt");
        return KernelPluginFactory.CreateFromFunctions("Writer", "Text helpers", new[] { fn });
    }
}

Now any host can call PluginRegistry.CreateWriterPlugin(kernel) and get the full capability set. This is the clean end state when you build semantic kernel plugin from scratch: a versioned library, deterministic native code, and prompt templates that compose them.

Gotchas when you build semantic kernel plugin from scratch

Parameter names in C# methods map to lowercase variables in prompts. CountWords(string input) becomes $input. If you name the parameter InputText, the template must use $InputText exactly. Mismatches fail silently or throw ambiguous errors.

Descriptions matter more than code comments. The LLM uses the [Description] text to decide whether to call your function. Write descriptions as imperative sentences: “Count the number of whitespace-separated words in a string.” Not “this counts words”.

Streaming and function calling do not mix automatically in older SDK versions. If you need token streaming while invoking plugins, set FunctionChoiceBehavior.Auto and consume StreamingKernelContent items, checking for FunctionCallContent in the stream.

Finally, never log raw KernelArguments with API keys. The arguments bag is convenient but will happily serialize secrets if you call .ToString() in a catch block.

Building a semantic kernel plugin from scratch is mostly disciplined bookkeeping: native methods for deterministic transforms, prompts for fuzzy reasoning, and a kernel that routes between them. Do that, and you have a composable tool the model can actually use.

Tagssemantic-kernelpluginshow-tofunctions

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 plugins & native functions posts →