n4nAI

Semantic Kernel plugins tutorial: chaining functions

Learn to chain Semantic Kernel functions into pipelines with native plugins, kernel arguments, and automatic parameter passing — runnable code included.

n4n Team4 min read827 words

Audio narration

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

Semantic Kernel’s function chaining lets you compose small, testable skills into multi-step pipelines without writing orchestration glue. This tutorial walks through building a practical chain: fetch a GitHub issue, summarize it, then generate a commit message — each step a separate native function. You’ll see how the kernel passes arguments automatically, how to handle typed inputs and outputs, and where to inject cross-cutting concerns like logging or retries.

Prerequisites

  • .NET 8 SDK or later
  • A GitHub personal access token with repo scope (for the fetch step)
  • Basic familiarity with C# and dependency injection

Create a new console project and add the Semantic Kernel packages:

dotnet new console -n SkChainDemo
cd SkChainDemo
dotnet add package Microsoft.SemanticKernel
dotnet add package Microsoft.Extensions.DependencyInjection
dotnet add package Microsoft.Extensions.Logging.Console

Project structure

SkChainDemo/
├── Program.cs
├── Plugins/
│   ├── GitHubPlugin.cs
│   ├── SummarizePlugin.cs
│   └── CommitMessagePlugin.cs
└── Models/
    ├── GitHubIssue.cs
    └── CommitMessage.cs

We’ll keep each plugin in its own file so the chain stays readable and each skill is independently testable.

Define the data models

Strongly typed models make the pipeline self-documenting and let the kernel validate arguments at startup.

// Models/GitHubIssue.cs
namespace SkChainDemo.Models;

public sealed record GitHubIssue(
    int Number,
    string Title,
    string Body,
    string HtmlUrl,
    string UserLogin,
    string State
);
// Models/CommitMessage.cs
namespace SkChainDemo.Models;

public sealed record CommitMessage(
    string ShortMessage,
    string ExtendedDescription
);

Build the GitHub fetch plugin

This plugin calls the GitHub REST API directly. In production you’d use Octokit, but a raw HttpClient keeps the example dependency-light.

// Plugins/GitHubPlugin.cs
using Microsoft.SemanticKernel;
using SkChainDemo.Models;
using System.Text.Json;

namespace SkChainDemo.Plugins;

public sealed class GitHubPlugin
{
    private readonly HttpClient _http;
    private readonly string _token;

    public GitHubPlugin(HttpClient http, string token)
    {
        _http = http;
        _token = token;
    }

    [KernelFunction("fetch_issue")]
    [Description("Fetches a GitHub issue by owner, repo, and issue number.")]
    public async Task<GitHubIssue> FetchIssueAsync(
        [Description("Repository owner (user or org)")] string owner,
        [Description("Repository name")] string repo,
        [Description("Issue number")] int number,
        CancellationToken ct = default)
    {
        var request = new HttpRequestMessage(HttpMethod.Get, $"https://api.github.com/repos/{owner}/{repo}/issues/{number}");
        request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _token);
        request.Headers.UserAgent.ParseAdd("SkChainDemo/1.0");

        using var response = await _http.SendAsync(request, ct);
        response.EnsureSuccessStatusCode();

        var json = await response.Content.ReadAsStringAsync(ct);
        var doc = JsonDocument.Parse(json);
        var root = doc.RootElement;

        return new GitHubIssue(
            Number: root.GetProperty("number").GetInt32(),
            Title: root.GetProperty("title").GetString() ?? "",
            Body: root.GetProperty("body").GetString() ?? "",
            HtmlUrl: root.GetProperty("html_url").GetString() ?? "",
            UserLogin: root.GetProperty("user").GetProperty("login").GetString() ?? "",
            State: root.GetProperty("state").GetString() ?? ""
        );
    }
}

Register the HttpClient and plugin in Program.cs — we’ll wire the full container shortly.

Build the summarization plugin

This plugin uses the LLM to condense the issue body. The function signature declares a GitHubIssue input and returns a plain string; the kernel handles serialization automatically.

// Plugins/SummarizePlugin.cs
using Microsoft.SemanticKernel;
using SkChainDemo.Models;

namespace SkChainDemo.Plugins;

public sealed class SummarizePlugin
{
    [KernelFunction("summarize_issue")]
    [Description("Produces a concise summary of a GitHub issue.")]
    public string SummarizeIssue(
        [Description("The issue to summarize")] GitHubIssue issue)
    {
        // In a real app you'd call the LLM here via kernel.InvokePromptAsync.
        // For this tutorial we keep it deterministic so output is reproducible.
        var firstSentence = issue.Body.Split('.', 2)[0].Trim();
        return $"Issue #{issue.Number}: {issue.Title}. {firstSentence}.";
    }
}

Note the deterministic implementation. Swapping in an LLM call is a one-line change — await kernel.InvokePromptAsync("Summarize: {{issue.Body}}", new() { ["issue"] = issue }) — but keeping it pure makes the chain’s plumbing visible.

Build the commit message plugin

The final step turns the summary into a conventional commit message. It accepts the summary string and the original issue (for context) and returns a structured CommitMessage.

// Plugins/CommitMessagePlugin.cs
using Microsoft.SemanticKernel;
using SkChainDemo.Models;

namespace SkChainDemo.Plugins;

public sealed class CommitMessagePlugin
{
    [KernelFunction("generate_commit_message")]
    [Description("Generates a conventional commit message from a summary.")]
    public CommitMessage GenerateCommitMessage(
        [Description("Short summary of the issue")] string summary,
        [Description("Original issue for context")] GitHubIssue issue)
    {
        var type = issue.Title.StartsWith("fix", StringComparison.OrdinalIgnoreCase) ? "fix" : "feat";
        var scope = "github";
        var shortMsg = $"{type}({scope}): {summary}";
        var extended = $"Closes #{issue.Number}. See {issue.HtmlUrl}";

        return new CommitMessage(shortMsg, extended);
    }
}

Wire the kernel and run the chain

Now compose everything in Program.cs. The kernel’s InvokeAsync method automatically resolves parameters by name and type across function boundaries — FetchIssueAsync produces a GitHubIssue, which flows into SummarizeIssue, whose string output plus the original issue flow into GenerateCommitMessage.

// Program.cs
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.SemanticKernel;
using SkChainDemo.Plugins;
using SkChainDemo.Models;

var githubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN")
    ?? throw new InvalidOperationException("Set GITHUB_TOKEN env var");

var services = new ServiceCollection();
services.AddLogging(b => b.AddConsole().SetMinimumLevel(LogLevel.Information));
services.AddHttpClient<GitHubPlugin>();
services.AddSingleton<GitHubPlugin>();
services.AddSingleton<SummarizePlugin>();
services.AddSingleton<CommitMessagePlugin>();
services.AddKernel();

var provider = services.BuildServiceProvider();
var kernel = provider.GetRequiredService<Kernel>();

// Import plugins so the kernel knows their functions
kernel.Plugins.AddFromObject(provider.GetRequiredService<GitHubPlugin>(), "GitHub");
kernel.Plugins.AddFromObject(provider.GetRequiredService<SummarizePlugin>(), "Summarize");
kernel.Plugins.AddFromObject(provider.GetRequiredService<CommitMessagePlugin>(), "Commit");

// Run the chain: fetch → summarize → commit message
var args = new KernelArguments
{
    ["owner"] = "dotnet",
    ["repo"] = "runtime",
    ["number"] = 12345 // Replace with a real issue number
};

var result = await kernel.InvokeAsync<CommitMessage>("GitHub", "fetch_issue", args);
Console.WriteLine($"Short: {result.ShortMessage}");
Console.WriteLine($"Extended: {result.ExtendedDescription}");

Run it:

export GITHUB_TOKEN=ghp_yourtoken
dotnet run

Expected output (values will differ based on the issue):

Short: fix(github): Issue #12345: NullReferenceException in System.Text.Json. The serializer throws when...
Extended: Closes #12345. See https://github.com/dotnet/runtime/issues/12345

What just happened

  1. InvokeAsync called GitHub.fetch_issue with owner, repo, number from KernelArguments.
  2. The function returned a GitHubIssue. The kernel stored it in the context under the function name.
  3. Summarize.summarize_issue was invoked. Its parameter issue matched the GitHubIssue in context — no manual wiring.
  4. The summary string became available for the next step.
  5. Commit.generate_commit_message received both summary (string) and issue (GitHubIssue) from context and returned the final CommitMessage.

The kernel’s parameter resolver matches by name and type. If two functions produce the same type, the last one wins unless you disambiguate with KernelArguments or explicit FromContext attributes.

Inspecting intermediate results

Sometimes you need the summary before the final step — for logging, UI, or branching logic. Capture it by invoking the chain incrementally:

// Fetch
var issue = await kernel.InvokeAsync<GitHubIssue>("GitHub", "fetch_issue", args);
Console.WriteLine($"Fetched: {issue.Title}");

// Summarize (pass the issue explicitly)
var summaryArgs = new KernelArguments(args) { ["issue"] = issue };
var summary = await kernel.InvokeAsync<string>("Summarize", "summarize_issue", summaryArgs);
Console.WriteLine($"Summary: {summary}");

// Commit message
var commitArgs = new KernelArguments(summaryArgs) { ["summary"] = summary };
var commit = await kernel.InvokeAsync<CommitMessage>("Commit", "generate_commit_message", commitArgs);
Console.WriteLine($"Commit: {commit.ShortMessage}");

This style also makes unit testing trivial — each plugin is a plain class with a public method.

Adding cross-cutting concerns

Function chaining shines when you need retries, caching, or observability across steps. Wrap the kernel with a delegating handler or use a filter:

// Simple retry filter
kernel.FunctionInvoking += async (sender, e) =>
{
    var attempt = 0;
    while (true)
    {
        try
        {
            await e.Function.InvokeAsync(e.Arguments, e.CancellationToken);
            break;
        }
        catch (HttpRequestException) when (attempt++ < 3)
        {
            await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)), e.CancellationToken);
        }
    }
};

Filters run for every function in the chain, so you get consistent behavior without decorating each plugin.

Passing routing directives from the client

If you’re calling this pipeline through an inference gateway that supports per-request routing — for example, directing the summarization step to a specific model — you can thread that directive through KernelArguments and read it inside the plugin:

[KernelFunction("summarize_issue")]
public async Task<string> SummarizeIssueAsync(
    GitHubIssue issue,
    Kernel kernel,
    [Description("Optional model override")] string? modelId = null,
    CancellationToken ct = default)
{
    var prompt = $"Summarize in one sentence: {issue.Body}";
    var args = new KernelArguments { ["model"] = modelId };
    return await kernel.InvokePromptAsync(prompt, args, cancellationToken: ct);
}

The caller sets modelId in the initial KernelArguments; the kernel forwards it through the chain. This pattern works well when you’re routing through a gateway that honors client-specified model hints — n4n.ai forwards provider cache-control hints and respects routing directives passed this way.

Common pitfalls

Pitfall Symptom Fix
Duplicate parameter names Wrong value flows into a step Use distinct names or FromContext("exact_name")
Missing KernelFunction attribute Function not discoverable Add attribute to every public method in the plugin
Async void in plugin Exceptions swallowed, no await Always return Task<T> or ValueTask<T>
Overloading by type only Resolver picks arbitrarily Disambiguate with argument names

Testing plugins in isolation

Because plugins are plain classes, you can test them without the kernel:

[Fact]
public void CommitMessagePlugin_GeneratesConventionalMessage()
{
    var plugin = new CommitMessagePlugin();
    var issue = new GitHubIssue(42, "Fix login crash", "Null ref in AuthService", "url", "user", "open");

    var result = plugin.GenerateCommitMessage("Null ref in AuthService", issue);

    Assert.StartsWith("fix(", result.ShortMessage);
    Assert.Contains("#42", result.ExtendedDescription);
}

No mocking framework required.

Scaling the pattern

Real pipelines grow: validation, enrichment, external API calls, human-in-the-loop gates. Keep each function single-purpose and idempotent where possible. Use KernelArguments as the shared bus — it’s just a dictionary — but define a schema (record types) for every step’s input and output. That schema becomes your contract, enforced by the compiler and visible in IntelliSense.

For long-running chains, consider persisting KernelArguments to a store (Redis, SQL) between steps so you can resume after failures. The dictionary serializes cleanly to JSON.

Next steps

  • Replace the deterministic summarizer with a prompt function that calls your preferred model.
  • Add a validation plugin that rejects issues missing reproduction steps.
  • Introduce a HumanReviewPlugin that pauses the chain and writes a pending-approval record.
  • Emit OpenTelemetry spans from a kernel filter for end-to-end tracing.

Function chaining in Semantic Kernel gives you a composable, type-safe way to build LLM-powered workflows without a separate orchestration engine. Start small, keep functions pure, and let the kernel handle the plumbing.

Tagssemantic-kernelpluginsfunction-chainingpipeline

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 →