BrandGhost
Building Real Apps with GitHub Copilot SDK in C#: End-to-End Patterns and Architecture

Building Real Apps with GitHub Copilot SDK in C#: End-to-End Patterns and Architecture

There's a massive gap between the "hello world" demos you see on GitHub and building real apps with GitHub Copilot SDK in C# for production. I've spent considerable time bridging this gap and learned that the real challenge isn't just calling the API -- it's architecting applications that are maintainable, testable, and resilient. In this guide, I'm going to walk through the patterns and architectures you need when building real apps with GitHub Copilot SDK in C#, from CLI developer tools to ASP.NET Core APIs and autonomous console agents. This is the foundation that separates toy demos from production-ready code.

App Architecture with GitHub Copilot SDK in C#

When I build real applications with the GitHub Copilot SDK in C#, I follow a few core architectural principles that have proven themselves time and again. The most important principle is treating CopilotClient as a singleton -- you create it once at application startup and reuse it throughout your app's lifetime. Creating multiple clients is wasteful and can lead to resource exhaustion. Each conversation gets its own CopilotSession, which is lightweight and designed to be created per-request or per-conversation thread.

Here's how the architectural patterns compare across different application types:

Pattern CopilotClient Lifetime Session Lifetime Best For
CLI Tool Singleton (app lifetime) Per-conversation Interactive developer tools, code generation
ASP.NET Core API Singleton (DI) Scoped (per-request) Web APIs, mobile backends, microservices
Console Agent Singleton (app lifetime) Single long-running Batch processing, repository analysis
Desktop App Singleton (app lifetime) Per-window/tab IDE plugins, GUI assistants

I always layer the Copilot SDK behind an interface like IAiAssistant.This facade pattern gives me several benefits: it makes my code testable by allowing me to mock the AI layer, it decouples my business logic from the SDK's implementation details, and it provides a clean abstraction I can evolve over time. Here's the interface structure I typically use:

// AI assistant interface for clean architecture
public interface IAiAssistant
{
    IAsyncEnumerable<string> StreamResponseAsync(string userMessage, CancellationToken cancellationToken = default);
    Task<string> GetResponseAsync(string userMessage, CancellationToken cancellationToken = default);
}

public class CopilotAiAssistant : IAiAssistant, IAsyncDisposable
{
    private readonly CopilotSession _session;
    
    public CopilotAiAssistant(CopilotClient client)
    {
        // Sessions are created synchronously during DI construction for simplicity
        // In production, use an async factory pattern
        _session = client.CreateSessionAsync(new SessionConfig { Model = "gpt-5" }).GetAwaiter().GetResult();
    }
    
    public async Task<string> GetResponseAsync(string userMessage, CancellationToken cancellationToken = default)
    {
        var tcs = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
        var sb = new System.Text.StringBuilder();
        
        _session.On(evt =>
        {
            switch (evt)
            {
                case AssistantMessageEvent msg: sb.Append(msg.Data.Content); break;
                case SessionIdleEvent: tcs.TrySetResult(sb.ToString()); break;
                case SessionErrorEvent err: tcs.TrySetException(new Exception(err.Data.Message)); break;
            }
        });
        
        await _session.SendAsync(new MessageOptions { Prompt = userMessage });
        return await tcs.Task.WaitAsync(cancellationToken);
    }
    
    public async IAsyncEnumerable<string> StreamResponseAsync(
        string userMessage, 
        [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
    {
        var channel = System.Threading.Channels.Channel.CreateUnbounded<string?>();
        
        _session.On(evt =>
        {
            switch (evt)
            {
                case AssistantMessageDeltaEvent delta: channel.Writer.TryWrite(delta.Data.DeltaContent); break;
                case SessionIdleEvent: channel.Writer.TryWrite(null); break;
                case SessionErrorEvent err: channel.Writer.TryComplete(new Exception(err.Data.Message)); break;
            }
        });
        
        await _session.SendAsync(new MessageOptions { Prompt = userMessage });
        
        await foreach (var token in channel.Reader.ReadAllAsync(cancellationToken))
        {
            if (token is null) yield break;
            yield return token;
        }
    }
    
    public async ValueTask DisposeAsync() => await _session.DisposeAsync();
}

Notice how I'm using IAsyncDisposable to properly clean up the session when it's no longer needed. The session-per-conversation pattern means each instance of CopilotAiAssistant maintains its own conversation context, which is perfect for scoped dependency injection in web APIs or for creating multiple concurrent conversations in CLI tools.

Pattern 1: CLI Developer Tools

Building a CLI tool with the GitHub Copilot SDK C# is one of my favorite application patterns because it provides immediate, interactive value to developers. The architecture centers around an interactive loop where you read user input, send it to the Copilot SDK, stream the response back to the terminal in real-time, and repeat until the user exits. I typically use libraries like Spectre.Console for rich terminal UI or System.CommandLine for robust command parsing.

The basic loop structure looks like this -- you initialize your CopilotClient and create a session, then enter a while loop that reads input and streams responses. Here's the architectural skeleton:

using GitHub.Copilot.SDK;
using Spectre.Console;

public class InteractiveCli
{
    private readonly CopilotClient _client;
    
    public InteractiveCli(CopilotClient client)
    {
        _client = client;
    }
    
    public async Task RunAsync(CancellationToken cancellationToken)
    {
        await using var session = await _client.CreateSessionAsync(new SessionConfig 
        { 
            Model = "gpt-5",
            SystemPrompt = "You are a helpful coding assistant."
        });
        
        AnsiConsole.MarkupLine("[bold green]AI CLI Tool[/] - Type 'exit' to quit");
        
        while (!cancellationToken.IsCancellationRequested)
        {
            var userInput = AnsiConsole.Ask<string>("[blue]You:[/]");
            if (userInput.Equals("exit", StringComparison.OrdinalIgnoreCase))
                break;
            
            AnsiConsole.Markup("[yellow]Assistant:[/] ");
            
            await foreach (var token in StreamResponseAsync(session, userInput, cancellationToken))
            {
                AnsiConsole.Markup(token);
            }
            
            AnsiConsole.WriteLine();
        }
    }
    
    private async IAsyncEnumerable<string> StreamResponseAsync(
        CopilotSession session, 
        string prompt,
        [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
    {
        var channel = System.Threading.Channels.Channel.CreateUnbounded<string?>();
        
        session.On(evt =>
        {
            switch (evt)
            {
                case AssistantMessageDeltaEvent delta: 
                    channel.Writer.TryWrite(delta.Data.DeltaContent); 
                    break;
                case SessionIdleEvent: 
                    channel.Writer.TryWrite(null); 
                    break;
            }
        });
        
        await session.SendAsync(new MessageOptions { Prompt = prompt });
        
        await foreach (var token in channel.Reader.ReadAllAsync(cancellationToken))
        {
            if (token is null) yield break;
            yield return token;
        }
    }
}

This pattern provides real-time streaming feedback to the user, which is critical for a good CLI experience. I'll be publishing a complete deep-dive article on building a full-featured AI CLI tool that includes command history, syntax highlighting, and multi-turn conversations -- but this skeleton shows you the core architectural pattern you need to understand.

Pattern 2: ASP.NET Core AI API Endpoints

Exposing the GitHub Copilot SDK C# as REST endpoints in an ASP.NET Core application opens up powerful scenarios like web-based AI assistants, mobile app backends, and microservices architectures. The key architectural decision is choosing between request/response endpoints for simple queries and streaming endpoints for real-time token delivery. I typically implement both patterns in my ASP.NET Core applications.

For request/response endpoints, you inject your IAiAssistant as a scoped service and return the complete response:

using Microsoft.AspNetCore.Mvc;

[ApiController]
[Route("api/[controller]")]
public class AiAssistantController : ControllerBase
{
    private readonly IAiAssistant _assistant;
    
    public AiAssistantController(IAiAssistant assistant)
    {
        _assistant = assistant;
    }
    
    [HttpPost("ask")]
    public async Task<ActionResult<string>> AskAsync(
        [FromBody] AskRequest request, 
        CancellationToken cancellationToken)
    {
        try
        {
            var response = await _assistant.GetResponseAsync(request.Prompt, cancellationToken);
            return Ok(response);
        }
        catch (Exception ex)
        {
            return StatusCode(500, new { error = ex.Message });
        }
    }
    
    [HttpPost("stream")]
    public async Task StreamAsync(
        [FromBody] AskRequest request,
        CancellationToken cancellationToken)
    {
        Response.ContentType = "text/event-stream";
        
        await foreach (var token in _assistant.StreamResponseAsync(request.Prompt, cancellationToken))
        {
            await Response.WriteAsync($"data: {token}

", cancellationToken);
            await Response.Body.FlushAsync(cancellationToken);
        }
    }
}

public record AskRequest(string Prompt);

For even more interactive scenarios like chat applications, I use SignalR to push tokens to connected clients in real-time. The architecture is similar, but you send tokens through a SignalR hub instead of an HTTP response stream. This pattern is particularly powerful when you have multiple clients that need to see the same AI conversation in real-time.

Pattern 3: Console Agent with Iterative Loops

The agentic loop pattern is where the GitHub Copilot SDK C# really shines for building autonomous systems. This pattern implements a "check → decide → act → observe → repeat" cycle where your agent continuously evaluates its environment, makes decisions, takes actions, and observes the results. I use this pattern for building code analysis tools, automated refactoring agents, and repository scanning bots.

The core architecture uses a CopilotSession inside a while loop with conditional logic that determines when to continue and when to stop:

public class CodeAnalysisAgent
{
    private readonly CopilotClient _client;
    private readonly ILogger<CodeAnalysisAgent> _logger;
    
    public CodeAnalysisAgent(CopilotClient client, ILogger<CodeAnalysisAgent> logger)
    {
        _client = client;
        _logger = logger;
    }
    
    public async Task AnalyzeRepositoryAsync(string repoPath, CancellationToken cancellationToken)
    {
        await using var session = await _client.CreateSessionAsync(new SessionConfig
        {
            Model = "gpt-5",
            SystemPrompt = @"You are a code analysis agent. Analyze code and suggest improvements.
When you're done analyzing, respond with 'ANALYSIS_COMPLETE'."
        });
        
        var files = Directory.GetFiles(repoPath, "*.cs", SearchOption.AllDirectories);
        var currentFileIndex = 0;
        var isComplete = false;
        
        while (!isComplete && currentFileIndex < files.Length && !cancellationToken.IsCancellationRequested)
        {
            // Act: Send the next file for analysis
            var fileContent = await File.ReadAllTextAsync(files[currentFileIndex], cancellationToken);
            var prompt = $"Analyze this C# file and suggest improvements:

{fileContent}";
            
            // Observe: Collect the agent's response
            var response = await GetResponseAsync(session, prompt, cancellationToken);
            
            _logger.LogInformation("Analyzed {File}: {Response}", files[currentFileIndex], response);
            
            // Decide: Check if we should continue
            if (response.Contains("ANALYSIS_COMPLETE", StringComparison.OrdinalIgnoreCase))
            {
                isComplete = true;
            }
            else
            {
                currentFileIndex++;
            }
            
            // Optional: Add delay to respect rate limits
            await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
        }
        
        _logger.LogInformation("Repository analysis complete. Analyzed {Count} files.", currentFileIndex);
    }
    
    private async Task<string> GetResponseAsync(
        CopilotSession session, 
        string prompt, 
        CancellationToken cancellationToken)
    {
        var tcs = new TaskCompletionSource<string>();
        var sb = new System.Text.StringBuilder();
        
        session.On(evt =>
        {
            switch (evt)
            {
                case AssistantMessageEvent msg: sb.Append(msg.Data.Content); break;
                case SessionIdleEvent: tcs.TrySetResult(sb.ToString()); break;
            }
        });
        
        await session.SendAsync(new MessageOptions { Prompt = prompt });
        return await tcs.Task.WaitAsync(cancellationToken);
    }
}

This pattern gives you incredible flexibility to build agents that can work through complex multi-step tasks autonomously. You can add sophisticated decision logic, maintain state across iterations, and implement feedback loops that allow the agent to learn from its actions.

Dependency Injection: Registering CopilotClient

Proper dependency injection is absolutely critical for building maintainable applications with the GitHub Copilot SDK C#. I register CopilotClient as a singleton because it's expensive to create and designed to be reused throughout the application lifetime. I also use an IHostedService to ensure the client is properly started before any requests come in and gracefully disposed when the application shuts down.

Here's the complete DI registration pattern I use in all my applications, following IServiceCollection best practices:

using GitHub.Copilot.SDK;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

var builder = Host.CreateApplicationBuilder(args);

builder.Services.AddSingleton<CopilotClient>(sp =>
{
    var client = new CopilotClient();
    return client;
});

builder.Services.AddHostedService<CopilotStartupService>();
builder.Services.AddScoped<IAiAssistant, CopilotAiAssistant>();

var app = builder.Build();
await app.RunAsync();

// Startup service ensures client is started before requests come in
public class CopilotStartupService : IHostedService
{
    private readonly CopilotClient _client;
    
    public CopilotStartupService(CopilotClient client) => _client = client;
    
    public Task StartAsync(CancellationToken cancellationToken) => _client.StartAsync();
    public Task StopAsync(CancellationToken cancellationToken) => _client.DisposeAsync().AsTask();
}

The scoped registration of IAiAssistant is perfect for ASP.NET Core applications where each HTTP request gets its own conversation session. For CLI tools or console applications where you manage the lifetime manually, you can register it as transient or create instances directly.

I often extend this with a fluent registration API that makes it easy to configure the Copilot SDK in Program.cs:

public static class CopilotServiceExtensions
{
    public static IServiceCollection AddCopilotSdk(
        this IServiceCollection services,
        Action<CopilotOptions>? configure = null)
    {
        var options = new CopilotOptions();
        configure?.Invoke(options);
        
        services.AddSingleton(options);
        services.AddSingleton<CopilotClient>(sp => new CopilotClient());
        services.AddHostedService<CopilotStartupService>();
        services.AddScoped<IAiAssistant, CopilotAiAssistant>();
        
        return services;
    }
}

public class CopilotOptions
{
    public string DefaultModel { get; set; } = "gpt-5";
    public string? SystemPrompt { get; set; }
}

This extension method pattern keeps your startup code clean and makes it trivial to configure different Copilot SDK settings across environments.

Error Handling and Resilience

Real applications need robust error handling and resilience patterns. The GitHub Copilot SDK C# exposes errors through SessionErrorEvent, which you should always handle in your event callbacks. I combine this with Polly retry policies to handle transient failures gracefully.

Here's my standard resilience pattern that wraps the AI assistant with exponential backoff retry logic and timeouts:

using Polly;
using Polly.Retry;

public class ResilientCopilotService
{
    private readonly IAiAssistant _assistant;
    private readonly ResiliencePipeline<string> _pipeline;
    
    public ResilientCopilotService(IAiAssistant assistant)
    {
        _assistant = assistant;
        _pipeline = new ResiliencePipelineBuilder<string>()
            .AddRetry(new RetryStrategyOptions<string>
            {
                MaxRetryAttempts = 3,
                Delay = TimeSpan.FromSeconds(1),
                BackoffType = DelayBackoffType.Exponential
            })
            .AddTimeout(TimeSpan.FromSeconds(30))
            .Build();
    }
    
    public async Task<string> AskWithResilienceAsync(string prompt)
    {
        return await _pipeline.ExecuteAsync(
            async ct => await _assistant.GetResponseAsync(prompt, ct));
    }
}

I also implement circuit breaker patterns when building services that make high-volume requests to the Copilot SDK. This prevents cascading failures and gives the underlying service time to recover. For graceful degradation, I often maintain a fallback response or cache of recent responses that I can return when the AI service is unavailable.

Error handling in streaming scenarios requires special attention. You need to handle errors mid-stream and decide whether to retry from the beginning, continue with partial data, or fail gracefully:

public async IAsyncEnumerable<string> StreamWithErrorHandlingAsync(
    string prompt,
    [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
    var retryCount = 0;
    var maxRetries = 3;
    
    while (retryCount <= maxRetries)
    {
        var channel = System.Threading.Channels.Channel.CreateUnbounded<string?>();
        Exception? capturedError = null;
        
        _session.On(evt =>
        {
            switch (evt)
            {
                case AssistantMessageDeltaEvent delta: 
                    channel.Writer.TryWrite(delta.Data.DeltaContent); 
                    break;
                case SessionIdleEvent: 
                    channel.Writer.TryWrite(null); 
                    break;
                case SessionErrorEvent err: 
                    capturedError = new Exception(err.Data.Message);
                    channel.Writer.TryWrite(null);
                    break;
            }
        });
        
        await _session.SendAsync(new MessageOptions { Prompt = prompt });
        
        await foreach (var token in channel.Reader.ReadAllAsync(cancellationToken))
        {
            if (token is null)
            {
                if (capturedError != null && retryCount < maxRetries)
                {
                    retryCount++;
                    await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, retryCount)), cancellationToken);
                    break; // Retry outer loop
                }
                yield break;
            }
            yield return token;
        }
        
        if (capturedError == null) yield break; // Success, exit
    }
    
    throw new Exception($"Failed after {maxRetries} retries");
}

Observability: Logging and Metrics

Production applications need comprehensive observability, and I instrument my GitHub Copilot SDK C# applications with structured logging and metrics from the start. The SDK events provide natural logging points -- I log every SessionErrorEvent, track response times, and monitor token usage when that data becomes available.

Here's my standard logging pattern that uses structured logging with semantic information:

public class ObservableCopilotService
{
    private readonly IAiAssistant _assistant;
    private readonly ILogger<ObservableCopilotService> _logger;
    
    public ObservableCopilotService(IAiAssistant assistant, ILogger<ObservableCopilotService> logger)
    {
        _assistant = assistant;
        _logger = logger;
    }
    
    public async Task<string> GetResponseWithLoggingAsync(string prompt, CancellationToken cancellationToken)
    {
        var stopwatch = System.Diagnostics.Stopwatch.StartNew();
        
        try
        {
            _logger.LogInformation(
                "Sending prompt to Copilot SDK. PromptLength={PromptLength}", 
                prompt.Length);
            
            var response = await _assistant.GetResponseAsync(prompt, cancellationToken);
            
            stopwatch.Stop();
            
            _logger.LogInformation(
                "Received response from Copilot SDK. ResponseLength={ResponseLength}, Duration={Duration}ms",
                response.Length,
                stopwatch.ElapsedMilliseconds);
            
            return response;
        }
        catch (Exception ex)
        {
            stopwatch.Stop();
            
            _logger.LogError(
                ex,
                "Error calling Copilot SDK. Duration={Duration}ms",
                stopwatch.ElapsedMilliseconds);
            
            throw;
        }
    }
}

I also integrate with OpenTelemetry when building distributed systems, using activities to trace requests across service boundaries. This is particularly valuable in microservices architectures where an AI request might trigger multiple downstream service calls:

using System.Diagnostics;

public class TelemetryCopilotService
{
    private static readonly ActivitySource ActivitySource = new("Copilot.SDK");
    private readonly IAiAssistant _assistant;
    
    public TelemetryCopilotService(IAiAssistant assistant)
    {
        _assistant = assistant;
    }
    
    public async Task<string> GetResponseWithTelemetryAsync(string prompt, CancellationToken cancellationToken)
    {
        using var activity = ActivitySource.StartActivity("GetCopilotResponse");
        activity?.SetTag("prompt.length", prompt.Length);
        
        try
        {
            var response = await _assistant.GetResponseAsync(prompt, cancellationToken);
            activity?.SetTag("response.length", response.Length);
            return response;
        }
        catch (Exception ex)
        {
            activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
            throw;
        }
    }
}

For metrics, I track request counts, error rates, response times (p50, p95, p99), and queue depths when using background processing. These metrics feed into dashboards that help me understand usage patterns and identify performance issues before they impact users.

The 4 App-Building Articles Coming Up

This article has covered the foundational patterns and architectures you need to build real applications with the GitHub Copilot SDK C#, but we're just getting started. I'm publishing four in-depth follow-up articles that take these patterns and turn them into complete, working applications you can learn from and adapt to your own needs.

First, I'll show you how to build a full-featured AI-powered CLI developer tool that uses Spectre.Console for rich terminal UI, implements command history, provides syntax highlighting, and supports multi-turn conversations with context preservation. This article will include the complete source code for a production-ready CLI tool.

Second, I'll walk through building an ASP.NET Core AI assistant API that exposes both request/response and streaming endpoints, integrates with SignalR for real-time chat, implements authentication and rate limiting, and deploys to Azure. You'll get a complete API you can use as the backend for web and mobile applications.

Third, I'll demonstrate building an interactive coding agent that can analyze your codebase, suggest refactorings, and implement changes autonomously. This article covers the agentic loop pattern in depth, including decision-making logic, state management across iterations, and safety guardrails.

Fourth, I'll show you how to build a repository analysis bot that scans GitHub repositories, identifies code smells and security issues, and generates comprehensive reports. This article brings together all the patterns -- CLI interaction, API integration, and autonomous agent loops -- into one powerful tool.

Each of these articles builds on the architectural foundation we've established here, so make sure you understand the patterns in this guide before diving into the specific application types. You can find the complete series starting from the GitHub Copilot SDK .NET Complete Guide.

Frequently Asked Questions

Here are the most common questions I receive about building applications with the GitHub Copilot SDK C# and implementing these architectural patterns in production.

What is the GitHub Copilot SDK in C# used for?

The GitHub Copilot SDK in C# enables developers to build custom AI-powered applications that integrate conversational AI capabilities. You can create CLI tools, ASP.NET Core APIs, console agents, and any .NET application that needs AI assistance.

How do I register CopilotClient in dependency injection?

Register CopilotClient as a singleton in your DI container because it's expensive to create and designed for reuse. Use an IHostedService to start the client during application startup and dispose it gracefully on shutdown. This ensures proper resource management.

What's the difference between streaming and non-streaming responses?

Non-streaming responses wait for the complete AI response before returning, which is simpler but creates a delay. Streaming responses deliver tokens in real-time as they're generated, providing better user experience for interactive applications like CLI tools and chat interfaces.

Should I create a new CopilotSession for each conversation?

Yes, create a new CopilotSession for each independent conversation or request. Sessions are lightweight and maintain conversation context. In ASP.NET Core, register your session wrapper as scoped so each HTTP request gets its own session.

How do I handle errors in the GitHub Copilot SDK in C#?

Handle SessionErrorEvent in your event callbacks and wrap your calls with resilience patterns using Polly. Implement exponential backoff retry logic for transient failures and timeouts to prevent hanging requests. For streaming scenarios, decide whether to retry from the beginning or fail gracefully.

Conclusion

Building real applications with the GitHub Copilot SDK in C# requires more than just knowing how to call the API -- it requires understanding architectural patterns that make your code maintainable, testable, and resilient. I've shown you the three core patterns I use: CLI developer tools with interactive loops and streaming, ASP.NET Core APIs with request/response and streaming endpoints, and console agents with iterative loops for autonomous operation. I've also covered the critical supporting patterns: proper dependency injection with singleton CopilotClient and scoped sessions, error handling with Polly retry policies, and observability with structured logging and OpenTelemetry integration.

These patterns form the foundation for every AI application I build with the GitHub Copilot SDK in C#. They've proven themselves in production across CLI tools, web APIs, and autonomous agents, and they'll serve you well as you build your own AI-powered applications. Start with the getting started guide if you haven't already set up the SDK, then apply these architectural patterns to your specific use case. The four follow-up articles will give you complete working examples of each pattern, but the principles you've learned here are the key to building AI applications that go beyond demos and deliver real value in production.

Session Hooks and Event Handling in GitHub Copilot SDK for C#

Master session hooks and event handling in GitHub Copilot SDK for C#. Intercept requests, handle streaming, and build observable AI apps.

Custom AI Tools with AIFunctionFactory in GitHub Copilot SDK for C#

Learn to build custom AI tools with AIFunctionFactory in GitHub Copilot SDK for C#. Working code examples and best practices included.

Build an AI CLI Developer Tool with GitHub Copilot SDK in C#

Build an AI CLI developer tool with GitHub Copilot SDK in C#. Learn CopilotClient, AIFunctionFactory tools, streaming responses, and session management.

An error has occurred. This application may no longer respond until reloaded. Reload