When you build an ASP.NET Core AI Assistant API with GitHub Copilot SDK in C#, you get all the SDK patterns -- lifecycle management, streaming, tools -- wrapped in a REST interface that any client can call. You're not limited to console apps or a single runtime; any frontend, mobile app, or service that speaks HTTP can now talk to GitHub Copilot. This article walks every layer of that integration, from project setup through SSE streaming and AIFunctionFactory tools.
What We're Building: an ASP.NET Core AI Assistant API with GitHub Copilot SDK in C#
The project is a minimal ASP.NET Core API with two AI endpoints and one health endpoint. POST /chat returns a complete, blocking response as JSON. GET /chat/stream returns tokens as they arrive using Server-Sent Events. GET /health is a quick liveness check you can hook into any load balancer or container orchestrator.
A single CopilotClient instance is shared across all requests. It is created during application startup via IHostedService and disposed cleanly on shutdown. AIFunctionFactory tools are baked into every session so the model can invoke calculator functions directly without any extra configuration per request.
Here is the folder structure:
ai-assistant-api/
ai-assistant-api.csproj (Microsoft.NET.Sdk.Web, net10.0)
appsettings.json
Program.cs
Services/
CopilotService.cs (IHostedService + ChatAsync + StreamAsync)
Endpoints/
ChatEndpoints.cs (POST /chat, GET /chat/stream, GET /health)
Models/
ChatRequest.cs
ChatResponse.cs
Tools/
CalculatorTool.cs
The full source is available in the devleader/copilot-sdk-examples repository.
Project Setup
Add these two NuGet packages to get everything you need:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>ai-assistant-api</AssemblyName>
<RootNamespace>AiAssistantApi</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="GitHub.Copilot.SDK" Version="0.1.25" />
<PackageReference Include="Microsoft.Extensions.AI.Abstractions" Version="10.3.0" />
</ItemGroup>
</Project>
Microsoft.NET.Sdk.Web pulls in all hosting, configuration, and dependency injection packages automatically -- you do not need to add Microsoft.Extensions.Hosting or Microsoft.AspNetCore.App manually.
Your appsettings.json can hold defaults and non-secret configuration. Put your GitHub token in appsettings.Development.json, which should be listed in .gitignore so it never ends up in source control:
{
"GitHub": {
"Token": "ghp_your_token_here"
}
}
The Entry Point: Program.cs
The entry point is intentionally small:
using AiAssistantApi.Services;
using AiAssistantApi.Endpoints;
var builder = WebApplication.CreateBuilder(args);
// Register CopilotService as a singleton so the same CopilotClient instance
// is shared across all requests, with IHostedService managing its lifecycle.
builder.Services.AddSingleton<CopilotService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<CopilotService>());
var app = builder.Build();
app.MapChatEndpoints();
app.Run();
The double-registration pattern is deliberate. AddSingleton<CopilotService>() puts CopilotService in the DI container so endpoints can resolve it by type. AddHostedService(sp => sp.GetRequiredService<CopilotService>()) reuses that exact same instance for StartAsync/StopAsync lifecycle management.
If you call AddHostedService<CopilotService>() directly instead, ASP.NET Core creates a second, separate instance to manage the lifecycle. The instance your endpoints receive via DI will never have StartAsync called on it -- it will always throw InvalidOperationException at runtime. The factory overload is the only safe path here.
CopilotService as IHostedService
CopilotService owns the CopilotClient and exposes ChatAsync for blocking calls and StreamAsync for token-by-token streaming:
using GitHub.Copilot.SDK;
using Microsoft.Extensions.AI;
using System.Runtime.CompilerServices;
using System.Threading.Channels;
using AiAssistantApi.Tools;
namespace AiAssistantApi.Services;
public sealed class CopilotService : IHostedService
{
private readonly IConfiguration _configuration;
private readonly ILogger<CopilotService> _logger;
private CopilotClient? _client;
public CopilotService(IConfiguration configuration, ILogger<CopilotService> logger)
{
_configuration = configuration;
_logger = logger;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
var token = _configuration["GitHub:Token"]
?? throw new InvalidOperationException(
"GitHub:Token configuration is required. Set it in appsettings.Development.json.");
_client = new CopilotClient(new CopilotClientOptions { GithubToken = token });
await _client.StartAsync();
_logger.LogInformation("CopilotClient started successfully");
}
public async Task StopAsync(CancellationToken cancellationToken)
{
if (_client is not null)
{
await _client.DisposeAsync();
_client = null;
_logger.LogInformation("CopilotClient stopped");
}
}
public async Task<string> ChatAsync(
string prompt,
string? systemPrompt = null,
CancellationToken ct = default)
{
EnsureStarted();
var reply = new System.Text.StringBuilder();
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
await using var session = await _client!.CreateSessionAsync(BuildSessionConfig(systemPrompt));
session.On(evt =>
{
switch (evt)
{
case AssistantMessageDeltaEvent delta:
reply.Append(delta.Data.DeltaContent);
break;
case AssistantMessageEvent msg:
reply.Append(msg.Data.Content);
break;
case SessionIdleEvent:
tcs.TrySetResult();
break;
case SessionErrorEvent err:
tcs.TrySetException(new Exception($"{err.Data.ErrorType}: {err.Data.Message}"));
break;
}
});
await session.SendAsync(new MessageOptions { Prompt = prompt });
using var reg = ct.Register(() => tcs.TrySetCanceled(ct));
await tcs.Task;
return reply.ToString();
}
public async IAsyncEnumerable<string> StreamAsync(
string prompt,
string? systemPrompt = null,
[EnumeratorCancellation] CancellationToken ct = default)
{
EnsureStarted();
var channel = Channel.CreateUnbounded<string>(new UnboundedChannelOptions { SingleWriter = true });
await using var session = await _client!.CreateSessionAsync(BuildSessionConfig(systemPrompt));
session.On(evt =>
{
switch (evt)
{
case AssistantMessageDeltaEvent delta:
channel.Writer.TryWrite(delta.Data.DeltaContent);
break;
case AssistantMessageEvent msg:
channel.Writer.TryWrite(msg.Data.Content);
break;
case SessionIdleEvent:
channel.Writer.TryComplete();
break;
case SessionErrorEvent err:
channel.Writer.TryComplete(new Exception($"{err.Data.ErrorType}: {err.Data.Message}"));
break;
}
});
await session.SendAsync(new MessageOptions { Prompt = prompt });
await foreach (var chunk in channel.Reader.ReadAllAsync(ct))
{
yield return chunk;
}
}
private SessionConfig BuildSessionConfig(string? systemPrompt) => new()
{
Streaming = true,
SystemMessage = new SystemMessageConfig
{
Mode = SystemMessageMode.Append,
Content = systemPrompt ?? "You are a helpful AI assistant. Be concise and accurate."
},
Tools = CalculatorTool.CreateAll()
};
private void EnsureStarted()
{
if (_client is null)
throw new InvalidOperationException(
"CopilotService has not been started. Ensure it is registered as IHostedService.");
}
}
Each call to ChatAsync or StreamAsync creates a fresh CopilotSession. Sessions are lightweight and do not carry cross-request state, so this is the correct model for an API where many concurrent requests may arrive simultaneously.
ChatAsync uses TaskCompletionSource to bridge the event-driven SDK into a single awaitable. When SessionIdleEvent fires, the model has finished generating and tcs.TrySetResult() unblocks the await tcs.Task line. SessionErrorEvent pushes an exception through the same path.
SystemMessageMode.Append merges your custom instructions with the default Copilot system prompt rather than replacing it. Use SystemMessageMode.Replace when you need a fully custom persona and do not want the default Copilot instructions involved.
The Channel Bridge Pattern
The Channel bridge in StreamAsync solves a fundamental mismatch in the SDK. The SDK fires events through synchronous callbacks registered via session.On(...). IAsyncEnumerable<string> requires yield return inside an async iterator method. You cannot yield return from inside a lambda callback.
Channel<string> is an in-process, thread-safe message queue. The callback writes each token to channel.Writer as events arrive. The caller reads them asynchronously via channel.Reader.ReadAllAsync(ct), which is a standard IAsyncEnumerable<string>. When SessionIdleEvent fires, channel.Writer.TryComplete() signals the end of stream -- ReadAllAsync detects the completed writer and the await foreach loop exits cleanly.
This pattern also handles errors correctly. channel.Writer.TryComplete(exception) propagates the error out of ReadAllAsync as a thrown exception, which surfaces naturally to the HTTP response layer without any special error-handling plumbing.
The API Endpoints
All three endpoints live in a single static extension class:
using AiAssistantApi.Models;
using AiAssistantApi.Services;
using System.Diagnostics;
namespace AiAssistantApi.Endpoints;
public static class ChatEndpoints
{
public static IEndpointRouteBuilder MapChatEndpoints(this IEndpointRouteBuilder app)
{
app.MapPost("/chat", async (
ChatRequest request,
CopilotService copilot,
CancellationToken ct) =>
{
if (string.IsNullOrWhiteSpace(request.Prompt))
return Results.BadRequest("Prompt cannot be empty.");
var sw = Stopwatch.StartNew();
var reply = await copilot.ChatAsync(request.Prompt, request.SystemPrompt, ct);
sw.Stop();
return Results.Ok(new ChatResponse(reply, sw.ElapsedMilliseconds));
})
.WithName("Chat")
.WithSummary("Send a message and receive a complete response");
app.MapGet("/chat/stream", async (
string prompt,
string? systemPrompt,
CopilotService copilot,
HttpContext ctx,
CancellationToken ct) =>
{
if (string.IsNullOrWhiteSpace(prompt))
{
ctx.Response.StatusCode = 400;
await ctx.Response.WriteAsync("prompt query parameter is required", ct);
return;
}
ctx.Response.ContentType = "text/event-stream";
ctx.Response.Headers.CacheControl = "no-cache";
ctx.Response.Headers.Connection = "keep-alive";
await foreach (var chunk in copilot.StreamAsync(prompt, systemPrompt, ct))
{
var escaped = chunk.Replace("
", "").Replace("
", "\n");
await ctx.Response.WriteAsync($"data: {escaped}
", ct);
await ctx.Response.Body.FlushAsync(ct);
}
await ctx.Response.WriteAsync("data: [DONE]
", ct);
})
.WithName("ChatStream")
.WithSummary("Stream a response using Server-Sent Events");
app.MapGet("/health", () =>
Results.Ok(new { Status = "Healthy", Timestamp = DateTimeOffset.UtcNow }))
.WithName("Health");
return app;
}
}
SSE format is strict. The Content-Type must be text/event-stream, and every frame must be formatted as `data:
-- two newlines to close the frame. Newlines inside tokens are escaped to
(the literal string) so each frame stays on a single logical line. The[DONE]` sentinel at the end follows the OpenAI convention that most SSE clients already understand, making it straightforward to integrate with existing frontend libraries.
The FlushAsync call after each chunk is important. Without it, ASP.NET Core's response buffering may hold tokens in memory and send them all at once instead of streaming them to the client.
Adding AIFunctionFactory Tools
Tool definitions use static methods decorated with [Description] attributes:
using Microsoft.Extensions.AI;
using System.ComponentModel;
namespace AiAssistantApi.Tools;
public static class CalculatorTool
{
[Description("Add two numbers together")]
public static double Add(
[Description("First number")] double a,
[Description("Second number")] double b) => a + b;
[Description("Multiply two numbers together")]
public static double Multiply(
[Description("First number")] double a,
[Description("Second number")] double b) => a * b;
[Description("Calculate a percentage of a value")]
public static double Percentage(
[Description("The base value")] double value,
[Description("The percentage to calculate (0-100)")] double percent) =>
value * (percent / 100.0);
public static ICollection<AIFunction> CreateAll() =>
[
AIFunctionFactory.Create(Add, name: "add"),
AIFunctionFactory.Create(Multiply, name: "multiply"),
AIFunctionFactory.Create(Percentage, name: "percentage"),
];
}
AIFunctionFactory.Create uses reflection to read the [Description] attributes from the method and its parameters, producing a schema the model uses to decide when and how to call the function. The collection is passed into SessionConfig.Tools in BuildSessionConfig, so every session in both ChatAsync and StreamAsync has access to the same tools.
Replace CalculatorTool with any domain-specific functions you need -- database lookups, HTTP calls to internal services, file reads, or anything else. The model calls them automatically when the user's prompt warrants it.
Testing the API
Run the project and use these curl commands to exercise all three endpoints:
# POST /chat -- complete response
curl -X POST http://localhost:5000/chat
-H "Content-Type: application/json"
-d '{"prompt": "What is 15% of 200?"}'
# GET /chat/stream -- SSE
curl -N "http://localhost:5000/chat/stream?prompt=Explain+async+await+in+C%23"
# GET /health
curl http://localhost:5000/health
The -N flag on the streaming request disables curl's output buffering, so you see tokens as they arrive rather than all at once when the response finishes. The calculator tool is wired in, so "What is 15% of 200?" exercises tool invocation -- watch the logs to see the model calling the percentage function.
Key Discoveries
- The
AddSingleton + AddHostedService(sp => sp.GetRequiredService<T>())double-registration is the only way to share a hosted service instance with the DI container --AddHostedService<T>()creates a second instance that endpoints cannot resolve - Per-request
CopilotSession(not per-application) is the right model for an API -- sessions are lightweight and stateless, and fresh sessions avoid any cross-request state leakage Channel<string>is the correct bridge from SDK event callbacks toIAsyncEnumerable<string>-- you cannotyield returnfrom inside a callback, andChannel<T>cleanly decouples the two[EnumeratorCancellation]on theStreamAsyncCancellationTokenparameter is required for proper cancellation token wiring in async iterators -- omitting it means the caller's token is silently ignoredSystemMessageMode.Appendadds custom instructions to the default Copilot system prompt -- useReplacewhen you need a fully custom persona without any default Copilot behavior
What to Build Next
The patterns here extend naturally to more complex scenarios. These articles cover the next steps:
- Build an Interactive Coding Agent with GitHub Copilot SDK in C# -- persistent sessions and write-capable tools for a full agent loop
- Build a Repository Analysis Bot with GitHub Copilot SDK in C# -- batch processing with
SystemMessageMode.Replacefor a fully custom persona - Streaming Responses with GitHub Copilot SDK in C# -- deep dive into the Channel bridge and SSE mechanics
- CopilotClient and CopilotSession in C#: Core Concepts -- the foundational lifecycle model underneath
CopilotService - Custom AI Tools with AIFunctionFactory in GitHub Copilot SDK for C# -- building and registering domain-specific tools beyond the calculator example
- GitHub Copilot SDK for .NET: Complete Developer Guide -- the full reference for every SDK concept used here
- Building Real Apps with GitHub Copilot SDK in C# -- end-to-end patterns and architecture for production workloads

