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
reposcope (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
InvokeAsynccalledGitHub.fetch_issuewithowner,repo,numberfromKernelArguments.- The function returned a
GitHubIssue. The kernel stored it in the context under the function name. Summarize.summarize_issuewas invoked. Its parameterissuematched theGitHubIssuein context — no manual wiring.- The summary string became available for the next step.
Commit.generate_commit_messagereceived bothsummary(string) andissue(GitHubIssue) from context and returned the finalCommitMessage.
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
HumanReviewPluginthat 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.