Getting Started with the GitHub Copilot SDK — Part 3: Custom Tools with AIFunction

This is Part 3 of my hands-on series on the GitHub Copilot SDK for .NET. The
companion code lives at
egarim/GettingStartedWithGithubCopilotSDK
— each numbered folder runs on its own.
In Part 1 we built the client. In Part 2 we held a conversation. Now we give the model
hands.
A tool is just a C# method the model is allowed to call when it decides it needs to.
Query a database, hit an API, run business logic — the model figures out when, the SDK
wires up the how. Let's build them up.
The whole trick: AIFunctionFactory.Create
The SDK leans on Microsoft.Extensions.AI. You write a normal method, wrap it with
AIFunctionFactory.Create, and register it on the session. That's it.
[Description("Encrypts a string by converting it to uppercase")]
static string EncryptString([Description("String to encrypt")] string input)
=> input.ToUpperInvariant();
var session = await client.CreateSessionAsync(new SessionConfig
{
Tools = [AIFunctionFactory.Create(EncryptString, "encrypt_string")]
});
var answer = await session.SendAndWaitAsync(
new MessageOptions { Prompt = "Use encrypt_string to encrypt: Hello World" });
Console.WriteLine(answer?.Data.Content); // -> HELLO WORLD
The [Description] attributes are not decoration. They're the API the model reads.
The method description tells it what the tool does; the parameter descriptions tell it
what to pass. Vague descriptions = a model that calls your tool wrong.
The flow is fully automatic: model decides it needs the tool → calls it with the right
args → gets the result → writes its answer.
Multiple tools — let the model orchestrate
Register more than one and the model picks. Ask one question that needs both, and it'll
call both.
var session = await client.CreateSessionAsync(new SessionConfig
{
Tools =
[
AIFunctionFactory.Create(GetWeather, "get_weather"),
AIFunctionFactory.Create(GetTime, "get_time"),
]
});
var answer = await session.SendAndWaitAsync(new MessageOptions {
Prompt = "What's the weather in Madrid and what time is it there?" });
One prompt, two tool calls, one combined answer. You don't orchestrate anything — the
model does.
Complex types: records in, arrays out
Real tools don't take a string. They take structured input and return structured output.
Use C# record types — but there's a catch for NativeAOT.
City[] PerformDbQuery(DbQueryOptions query, AIFunctionArguments rawArgs)
{
Console.WriteLine(quot; [Tool] Table={query.Table}, IDs=[{string.Join(",", query.Ids)}]");
return [new(19, "Passos", 135460), new(12, "San Lorenzo", 204356)];
}
record DbQueryOptions(string Table, int[] Ids, bool SortAscending);
record City(int CountryId, string CityName, int Population);
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web)]
[JsonSerializable(typeof(DbQueryOptions))]
[JsonSerializable(typeof(City[]))]
[JsonSerializable(typeof(JsonElement))]
partial class DemoJsonContext : JsonSerializerContext;
Register it with your source-generated serializer options:
Tools = [AIFunctionFactory.Create(PerformDbQuery, "db_query",
serializerOptions: DemoJsonContext.Default.Options)]
If you skip the
JsonSerializerContext, reflection-based serialization bites you the
moment you go NativeAOT.
The source generator keeps your tool's I/O AOT-safe. Wire it once per project and forget
about it.
Error handling — secure by default
Here's the one I want you to remember. Make a tool that throws something sensitive:
var failingTool = AIFunctionFactory.Create(
() => { throw new Exception("Secret Internal Error - Melbourne"); },
"get_user_location", "Gets the user's location");
Then ask for the location:
var answer = await session.SendAndWaitAsync(new MessageOptions {
Prompt = "What is my location? If you can't find out, just say 'unknown'." });
Console.WriteLine(answer?.Data.Content); // -> unknown
Console.WriteLine(answer?.Data.Content?.Contains("Melbourne")); // -> False
The model says "unknown." The word Melbourne never reaches it.
The SDK catches the exception and hands the model a generic failure — your stack
traces, connection strings, and internal error text stay yours.
This is the right default. You don't have to remember to scrub error messages before they
hit the model; the SDK already did.
Filtering the built-in tools
Copilot ships its own tools (view, edit, …). You can control which ones a session
sees. Allowlist with AvailableTools, denylist with ExcludedTools.
// allowlist: only these
var s1 = await client.CreateSessionAsync(new SessionConfig {
AvailableTools = new List<string> { "view", "edit" } });
// denylist: everything except these
var s2 = await client.CreateSessionAsync(new SessionConfig {
ExcludedTools = new List<string> { "view" } });
Ask each session "what tools do you have?" and they report different sets. Handy when you
want to gate capabilities by user role or context — a read-only session, an edit-enabled
one, whatever your app needs.
Takeaways
- A tool is a plain C# method wrapped with
AIFunctionFactory.Createand registered
on the session. [Description]attributes are the contract the model reads — write them like docs,
not comments.- Register multiple tools and let the model orchestrate which to call.
- For records/arrays, add a
JsonSerializerContextand pass its options — NativeAOT
safety, sorted once. - Exceptions are caught and never leaked to the model. Secure by default.
- Shape capabilities per session with
AvailableTools(allow) andExcludedTools
(deny).
Tools give the model hands. Next we put a chaperone next to those hands — in Part 4 —
Pre/Post Tool-Use Hooks
we intercept tool calls before and after they run, to validate, log, or block them.
Want to follow along? You'll need the .NET 10 SDK and GitHub Copilot access. Then
dotnet run --project 03.ToolsDemofrom the
course repo.