May 7, 20264 min read/2026/05/07/getting-started-github-copilot-sdk-part-3-custom-tools-aifunction/

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.Create and 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 JsonSerializerContext and 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) and ExcludedTools
    (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.ToolsDemo from the
course repo.