BrandGhost
Build an Interactive Coding Agent with GitHub Copilot SDK in C#

Build an Interactive Coding Agent with GitHub Copilot SDK in C#

When you build an interactive coding agent with GitHub Copilot SDK in C#, you get an AI that can explore your codebase and implement changes -- not just answer questions about code. The key architectural challenge is maintaining a persistent CopilotSession across multiple user inputs while keeping each message's response handler isolated. Once you understand how session.On() works, the design becomes straightforward.

What We're Building: an Interactive Coding Agent with GitHub Copilot SDK in C#

This is a console REPL app where the AI maintains full conversation memory across multiple exchanges. The session stays alive so the model remembers what files it read two messages ago, what edits it already applied, and what the build output said.

The agent has five tools: read_file, write_file, list_files, search_in_files, and run_dotnet_build. You point it at a working directory via a command-line argument or config. The slash commands /new, /exit, and /help round out the interface.

The folder structure is minimal:

ai-coding-agent/
  ai-coding-agent.csproj
  appsettings.json
  Program.cs              (REPL + agent execution)
  Configuration/
    AgentConfig.cs
  Tools/
    CodeTools.cs          (5 AIFunctionFactory tools)

The full source is in devleader/copilot-sdk-examples.

Project Setup

The project targets net10.0 and uses the same Copilot SDK version as the other examples in this series:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <RootNamespace>AiCodingAgent</RootNamespace>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="GitHub.Copilot.SDK" Version="0.1.25" />
    <PackageReference Include="Microsoft.Extensions.AI.Abstractions" Version="10.3.0" />
    <PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.3" />
    <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.3" />
    <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.3" />
  </ItemGroup>
</Project>

The AgentConfig class holds the GitHub token, system prompt, and working directory:

namespace AiCodingAgent.Configuration;

public sealed class AgentConfig
{
    public const string SectionName = "Agent";

    public string GithubToken { get; init; } = string.Empty;

    public string SystemPrompt { get; init; } =
        "You are an expert .NET coding agent. Analyze code, suggest improvements, and implement " +
        "changes when asked. Use read_file and list_files to explore the codebase before making " +
        "changes. Use write_file to create or update files. Use run_dotnet_build to verify changes " +
        "compile successfully. Always explain what you are doing before taking action.";

    public string WorkingDirectory { get; init; } = ".";
}

The SystemPrompt is fully configurable -- replace the default with a domain-specific instruction for your team. Point it at a monorepo conventions document, a set of architectural rules, or a style guide. The model will apply those instructions to every message in the session.

The Five Code Tools

All five tools are methods on a single CodeTools class. AIFunctionFactory.Create wraps each one at startup:

using Microsoft.Extensions.AI;
using System.ComponentModel;
using System.Diagnostics;
using System.Text;

namespace AiCodingAgent.Tools;

public sealed class CodeTools
{
    private readonly string _workingDirectory;

    public CodeTools(string workingDirectory)
    {
        _workingDirectory = Path.GetFullPath(workingDirectory);
    }

    [Description("Read the contents of a source code file")]
    public string ReadFile(
        [Description("Path to the file (relative to the working directory or absolute)")] string path)
    {
        try
        {
            var fullPath = ResolvePath(path);
            if (!File.Exists(fullPath))
                return $"[Error] File not found: {fullPath}";

            var size = new FileInfo(fullPath).Length;
            if (size > 100_000)
                return $"[Error] File too large ({size:N0} bytes). Use a more specific path.";

            return File.ReadAllText(fullPath);
        }
        catch (Exception ex)
        {
            return $"[Error] Could not read file: {ex.Message}";
        }
    }

    [Description("Write or overwrite a file with the given content, creating any necessary directories")]
    public string WriteFile(
        [Description("Path to the file to write (relative to working directory or absolute)")] string path,
        [Description("The complete file content to write")] string content)
    {
        try
        {
            var fullPath = ResolvePath(path);
            var dir = Path.GetDirectoryName(fullPath);
            if (!string.IsNullOrEmpty(dir))
                Directory.CreateDirectory(dir);

            File.WriteAllText(fullPath, content);
            return $"[Success] Written {content.Length:N0} characters to {fullPath}";
        }
        catch (Exception ex)
        {
            return $"[Error] Could not write file: {ex.Message}";
        }
    }

    [Description("List files in a directory, with optional extension filter")]
    public string ListFiles(
        [Description("Directory path (relative to working directory)")] string directory,
        [Description("File pattern filter, e.g. '*.cs', '*.json', '*.*'")] string pattern = "*.*")
    {
        try
        {
            var fullPath = ResolvePath(directory);
            if (!Directory.Exists(fullPath))
                return $"[Error] Directory not found: {fullPath}";

            var sb = new StringBuilder();
            var dirs = Directory.GetDirectories(fullPath)
                .Where(d => !Path.GetFileName(d).StartsWith('.'))
                .Select(d => $"[DIR]  {Path.GetRelativePath(_workingDirectory, d)}/");
            var files = Directory.GetFiles(fullPath, pattern, SearchOption.TopDirectoryOnly)
                .Select(f => $"[FILE] {Path.GetRelativePath(_workingDirectory, f)}");

            foreach (var item in dirs.Concat(files).Take(100))
                sb.AppendLine(item);

            return sb.Length == 0 ? "[Empty directory]" : sb.ToString();
        }
        catch (Exception ex)
        {
            return $"[Error] Could not list directory: {ex.Message}";
        }
    }

    [Description("Search for text across source files in a directory")]
    public string SearchInFiles(
        [Description("Directory to search (relative to working directory)")] string directory,
        [Description("Text to search for")] string searchText,
        [Description("File extension filter, e.g. '*.cs'")] string pattern = "*.cs")
    {
        try
        {
            var fullPath = ResolvePath(directory);
            if (!Directory.Exists(fullPath))
                return $"[Error] Directory not found: {fullPath}";

            var sb = new StringBuilder();
            var matchCount = 0;

            foreach (var file in Directory.GetFiles(fullPath, pattern, SearchOption.AllDirectories))
            {
                var lines = File.ReadAllLines(file);
                for (var i = 0; i < lines.Length; i++)
                {
                    if (lines[i].Contains(searchText, StringComparison.OrdinalIgnoreCase))
                    {
                        var rel = Path.GetRelativePath(_workingDirectory, file);
                        sb.AppendLine($"{rel}:{i + 1}: {lines[i].Trim()}");
                        if (++matchCount >= 50) break;
                    }
                }
                if (matchCount >= 50) break;
            }

            return matchCount == 0
                ? $"[No matches for '{searchText}' in {pattern} files]"
                : sb.ToString();
        }
        catch (Exception ex)
        {
            return $"[Error] Search failed: {ex.Message}";
        }
    }

    [Description("Run dotnet build on a project or solution file to check for compilation errors")]
    public string RunDotnetBuild(
        [Description("Path to the .csproj or .sln file (relative to working directory)")] string projectPath)
    {
        try
        {
            var fullPath = ResolvePath(projectPath);
            if (!File.Exists(fullPath))
                return $"[Error] Project file not found: {fullPath}";

            var psi = new ProcessStartInfo("dotnet", $"build "{fullPath}" --nologo -v quiet")
            {
                RedirectStandardOutput = true,
                RedirectStandardError = true,
                UseShellExecute = false,
                WorkingDirectory = _workingDirectory
            };

            using var process = Process.Start(psi)!;
            var output = process.StandardOutput.ReadToEnd();
            var error = process.StandardError.ReadToEnd();
            process.WaitForExit(30_000);

            var combined = (output + "
" + error).Trim();
            return $"[Build exit code: {process.ExitCode}]
{combined}";
        }
        catch (Exception ex)
        {
            return $"[Error] Build failed: {ex.Message}";
        }
    }

    public ICollection<AIFunction> CreateAll() =>
    [
        AIFunctionFactory.Create(ReadFile, name: "read_file"),
        AIFunctionFactory.Create(WriteFile, name: "write_file"),
        AIFunctionFactory.Create(ListFiles, name: "list_files"),
        AIFunctionFactory.Create(SearchInFiles, name: "search_in_files"),
        AIFunctionFactory.Create(RunDotnetBuild, name: "run_dotnet_build"),
    ];

    private string ResolvePath(string path) =>
        Path.IsPathRooted(path)
            ? path
            : Path.GetFullPath(Path.Combine(_workingDirectory, path));
}

A few design choices worth calling out:

ResolvePath normalizes all paths against the working directory -- the AI cannot accidentally navigate outside the intended folder using relative paths. Absolute paths still work, so you can point the agent at a specific file anywhere on disk if needed.

Tool return values are plain strings. The [Error] and [Success] prefixes make outcomes visible to the model in a consistent, parseable format. When the AI sees [Error] File not found, it knows to check the path rather than retry blindly.

run_dotnet_build closes the feedback loop. The agent can write a file and immediately invoke the build to verify its edit compiles. Without this, the AI is writing code with no verification step -- it's the difference between a coding assistant and a coding agent.

The 100KB guard in ReadFile prevents a single large generated or binary file from flooding the context window. If the file is too large, the AI gets an explicit error with the file size, which is more useful than a silent truncation.

The Agent REPL Loop

Program.cs contains two core methods: RunAgentLoopAsync manages the session lifecycle and the input loop, and ExecuteTaskAsync handles a single user message:

static async Task RunAgentLoopAsync(CopilotClient client, AgentConfig config, CodeTools tools)
{
    CopilotSession? session = null;

    async Task StartNewSessionAsync()
    {
        if (session is not null)
            await session.DisposeAsync();

        session = await client.CreateSessionAsync(new SessionConfig
        {
            Streaming = true,
            SystemMessage = new SystemMessageConfig
            {
                Mode = SystemMessageMode.Append,
                Content = config.SystemPrompt
            },
            Tools = tools.CreateAll()
        });

        Console.ForegroundColor = ConsoleColor.DarkGray;
        Console.WriteLine("[New agent session started]");
        Console.ResetColor();
    }

    await StartNewSessionAsync();

    while (true)
    {
        Console.ForegroundColor = ConsoleColor.Green;
        Console.Write("agent> ");
        Console.ResetColor();

        var input = Console.ReadLine()?.Trim();
        if (string.IsNullOrWhiteSpace(input))
            continue;

        switch (input.ToLowerInvariant())
        {
            case "/exit":
            case "/quit":
                Console.WriteLine("Agent shutting down. Goodbye!");
                if (session is not null) await session.DisposeAsync();
                return;

            case "/new":
                await StartNewSessionAsync();
                continue;

            case "/help":
                PrintHelp();
                continue;
        }

        Console.WriteLine();
        await ExecuteTaskAsync(session!, input);
        Console.WriteLine();
    }
}

static async Task ExecuteTaskAsync(CopilotSession session, string task)
{
    var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);

    session.On(evt =>
    {
        switch (evt)
        {
            case AssistantMessageDeltaEvent delta:
                Console.Write(delta.Data.DeltaContent);
                break;

            case AssistantMessageEvent msg:
                Console.Write(msg.Data.Content);
                break;

            case ToolExecutionStartEvent toolStart:
                Console.ForegroundColor = ConsoleColor.Yellow;
                Console.WriteLine($"
[Agent calling: {toolStart.Data.ToolName}({toolStart.Data.Arguments})]");
                Console.ResetColor();
                break;

            case SessionIdleEvent:
                Console.WriteLine();
                tcs.TrySetResult();
                break;

            case SessionErrorEvent err:
                Console.ForegroundColor = ConsoleColor.Red;
                Console.WriteLine($"
[Error] {err.Data.ErrorType}: {err.Data.Message}");
                Console.ResetColor();
                tcs.TrySetException(new Exception(err.Data.Message));
                break;
        }
    });

    try
    {
        await session.SendAsync(new MessageOptions { Prompt = task });
        await tcs.Task;
    }
    catch (Exception ex)
    {
        Console.ForegroundColor = ConsoleColor.Red;
        Console.WriteLine($"[Task execution failed] {ex.Message}");
        Console.ResetColor();
    }
}

The TaskCompletionSource pattern is what makes this work cleanly. SendAsync fires the message and returns immediately -- it doesn't block until the agent finishes. The tcs.Task does the actual waiting. SessionIdleEvent is the SDK's signal that the agent has finished its current turn (including any tool calls it made).

session.On() Is Per-Message, Not Per-Session

This is the most important behavior to understand before you ship a multi-turn agent.

session.On(handler) registers a callback for the next SendAsync() call. Each call to session.On replaces the previous handler -- it does not accumulate handlers. This has three direct implications for how you structure your code:

No handler accumulation. You are not subscribing to an event in the traditional .NET sense. There is no growing list of listeners. Calling session.On 50 times over 50 messages results in exactly one active handler at any moment -- the last one registered. No memory leak, no duplicate output.

You must register before every SendAsync. If you called session.On once at session startup and never again, it would work for the first message only. Every subsequent SendAsync would fire with no registered handler, meaning SessionIdleEvent would never resolve your TaskCompletionSource and the method would hang indefinitely.

ExecuteTaskAsync is a separate method for a reason. It is not inlined into the REPL loop. The separation exists precisely so that each call creates a new TaskCompletionSource and registers a fresh session.On(...) before invoking SendAsync. If tcs were shared across calls and reused, the second call would signal the first one's task. Isolation requires both values to be local to each invocation.

This design is different from typical .NET event patterns where you subscribe once and stay subscribed. In the Copilot SDK, session.On means "here is the handler for what comes next." Think of it as setting a callback slot, not adding to a collection.

Tool Execution in Action

When the AI invokes a tool, ToolExecutionStartEvent fires before the tool result is returned to the model. The handler prints:

[Agent calling: read_file({"path": "src/OrderProcessor.cs"})]

This makes the agent's exploration visible in real time. You can watch it read files, search for patterns, write changes, and then trigger a build -- all as a sequence of observable steps in the terminal. It is no longer a black box.

The ToolExecutionStartEvent fires before the tool result is sent back, so you see intent before outcome. If you also want to see the tool result, you can handle ToolExecutionResultEvent in the same switch statement.

Sample Interaction

Here is what a typical session looks like end to end:

╔══════════════════════════════════════╗
║   Interactive Coding Agent           ║
║   Powered by GitHub Copilot SDK      ║
╚══════════════════════════════════════╝
Working directory: C:devMyProject

agent> What classes are in the src directory?
[Agent calling: list_files({"directory": "src", "pattern": "*.cs"})]
The src directory contains: OrderProcessor.cs, CustomerRepository.cs, Product.cs, OrderResult.cs

agent> Add XML documentation to all public methods in OrderProcessor.cs
[Agent calling: read_file({"path": "src/OrderProcessor.cs"})]
[Agent calling: write_file({"path": "src/OrderProcessor.cs", "content": "..."})]
[Agent calling: run_dotnet_build({"projectPath": "MyProject.csproj"})]
Done -- I've added XML documentation to all public methods in OrderProcessor.cs.
The project still builds with 0 errors.

agent> /new
[New agent session started]

Notice what happened on the second message: the agent read the file first, applied the change, then verified the build. That is the full plan-implement-verify cycle in a single turn, driven by the system prompt's instruction to "use run_dotnet_build to verify changes compile successfully."

Key Discoveries

Working through this implementation surfaces a few behaviors worth knowing before you build your own:

  • session.On() replaces the previous handler on every call -- register a new one before each SendAsync, not once at session startup
  • A persistent CopilotSession maintains full context across turns -- "the file you just read" still works on turn 5 because the session remembers prior tool calls
  • /new creates a fresh session and clears all context without restarting the application -- useful for switching between unrelated tasks
  • write_file + run_dotnet_build gives the AI a true agentic loop: plan, implement, verify, iterate -- without this feedback mechanism it's a glorified autocomplete
  • AgentConfig.SystemPrompt is the best place to instill domain-specific behavior -- change it to reflect your codebase conventions, architectural patterns, or team standards

What to Explore Next

The interactive coding agent is one application of the Copilot SDK. The same CopilotSession and tool patterns apply across many other shapes:

Build a Multi-Agent Analysis System with GitHub Copilot SDK in C#

Build a multi-agent analysis system with GitHub Copilot SDK in C#. Learn the AgentBase reusable pattern, SystemMessageMode.Replace for agent isolation, sequential AgentPipeline orchestration, and specialist agent design.

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.

GitHub Copilot SDK for .NET: Complete Developer Guide

Learn the GitHub Copilot SDK for .NET in this complete developer guide. Build custom AI agents with CopilotClient, CopilotSession, streaming, tools, and multi-model support in C#.

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