BrandGhost
Build a Repository Analysis Bot with GitHub Copilot SDK in C#

Build a Repository Analysis Bot with GitHub Copilot SDK in C#

When you build a repository analysis bot with GitHub Copilot SDK in C#, the design is fundamentally different from an interactive agent. There's no REPL loop -- the bot runs once, analyzes a repository using a set of read-only tools, and saves a complete markdown report. The two patterns that make it work are SystemMessageMode.Replace for a specialist agent persona and dual console/StringBuilder output for live feedback plus a persistent artifact.

What We're Building: a Repository Analysis Bot with GitHub Copilot SDK in C#

This is a console application with a single job: analyze a repository and produce a structured markdown report. It accepts a path to a repository as a command-line argument, spins up one CopilotSession with a software architect system prompt, and runs to completion. No interaction required.

The bot provides four read-only tools to the model -- list_structure, read_file, find_files, and count_usage. The model uses these to explore the repository, then writes the report as streaming output that hits both the terminal in real time and a StringBuilder that gets saved to disk.

Here's the project layout:

ai-repo-analyzer/
  ai-repo-analyzer.csproj
  appsettings.json
  Program.cs
  Configuration/
    AnalyzerConfig.cs
  Tools/
    RepositoryTools.cs    (4 read-only tools)

Full source: devleader/copilot-sdk-examples

SystemMessageMode.Replace: Installing a Specialist Persona

This is the most important design decision in the entire bot. The default Copilot model persona is a general-purpose coding assistant. That's not what you want here -- you want a software architect and code reviewer that produces structured Markdown output and reads files systematically.

SystemMessageMode.Replace completely replaces the default system prompt instead of adding to it. The model receives only your instructions, with no Copilot assistant defaults mixed in:

await using var session = await client.CreateSessionAsync(new SessionConfig
{
    Streaming = true,
    SystemMessage = new SystemMessageConfig
    {
        Mode = SystemMessageMode.Replace,
        Content = """
            You are an expert software architect and code reviewer.
            Analyze the repository using your tools and produce a comprehensive Markdown report.
            Be specific and thorough -- read multiple files to form accurate conclusions.
            Format the report with clear ## headings for each section.
            """
    },
    Tools = tools.CreateAll()
});

Compare this to SystemMessageMode.Append, which is the right choice for the interactive coding agent in Build an Interactive Coding Agent with GitHub Copilot SDK in C#. Append adds your instructions to the end of the default prompt -- the model follows both the Copilot defaults AND your additions, which works well when you want conversational behavior augmented with custom tools.

For a batch analysis bot, that blended behavior is a problem. The model might hedge, add unnecessary caveats, or drift from the structured report format. Replace gives you a deterministic specialist: it knows exactly what it is and what it needs to produce. Use Append when you want to extend Copilot's defaults; use Replace when you want to own the persona entirely.

The Four Read-Only Tools

Every tool in this bot is intentionally read-only. A bot that cannot modify files is safe to run against any repository -- production code, client repos, open source projects. That constraint is a feature, not a limitation.

Here's the full RepositoryTools.cs:

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

namespace AiRepoAnalyzer.Tools;

public sealed class RepositoryTools
{
    private readonly string _repoRoot;

    public RepositoryTools(string repoRoot)
    {
        _repoRoot = Path.GetFullPath(repoRoot);
    }

    [Description("List the directory structure of the repository, showing folders and files up to a given depth")]
    public string ListStructure(
        [Description("Subdirectory to list, relative to repo root (use '.' for root)")] string directory = ".",
        [Description("File pattern filter, e.g. '*.cs', '*.csproj', or '*' for all")] string pattern = "*",
        [Description("Maximum depth to recurse (1 = top level only, default 2)")] int maxDepth = 2)
    {
        var fullPath = ResolvePath(directory);
        if (!Directory.Exists(fullPath))
            return $"[Error] Directory not found: {directory}";

        var sb = new StringBuilder();
        AppendTree(fullPath, fullPath, pattern, 0, maxDepth, sb);
        return sb.Length == 0 ? "[Empty directory]" : sb.ToString();
    }

    [Description("Read the content of a file in the repository")]
    public string ReadFile(
        [Description("Path to the file, relative to the repository root")] string path)
    {
        var fullPath = ResolvePath(path);
        if (!File.Exists(fullPath))
            return $"[Error] File not found: {path}";

        var size = new FileInfo(fullPath).Length;
        if (size > 60_000)
            return $"[Error] File too large ({size:N0} bytes). Consider reading a subsection.";

        return File.ReadAllText(fullPath);
    }

    [Description("Find all files matching a name pattern anywhere in the repository")]
    public string FindFiles(
        [Description("File name pattern, e.g. '*.csproj', 'README*', 'Program.cs'")] string pattern)
    {
        var files = Directory.GetFiles(_repoRoot, pattern, SearchOption.AllDirectories)
            .Select(f => Path.GetRelativePath(_repoRoot, f))
            .Where(f => !f.StartsWith(".git", StringComparison.OrdinalIgnoreCase))
            .OrderBy(f => f)
            .Take(50)
            .ToList();

        return files.Count == 0
            ? $"[No files matching '{pattern}']"
            : string.Join("
", files);
    }

    [Description("Count how many times a text pattern appears across source files to understand usage frequency")]
    public string CountUsage(
        [Description("Text pattern to count occurrences of")] string pattern,
        [Description("File extension filter, e.g. '*.cs'")] string fileExtension = "*.cs")
    {
        var files = Directory.GetFiles(_repoRoot, fileExtension, SearchOption.AllDirectories)
            .Where(f => !f.Contains(".git", StringComparison.OrdinalIgnoreCase));

        var total = 0;
        var fileMatches = new List<(string File, int Count)>();

        foreach (var file in files)
        {
            var content = File.ReadAllText(file);
            var count = CountOccurrences(content, pattern);
            if (count > 0)
            {
                fileMatches.Add((Path.GetRelativePath(_repoRoot, file), count));
                total += count;
            }
        }

        if (total == 0)
            return $"[Pattern '{pattern}' not found in {fileExtension} files]";

        var sb = new StringBuilder();
        sb.AppendLine($"Total occurrences of '{pattern}': {total}");
        foreach (var (file, count) in fileMatches.OrderByDescending(x => x.Count).Take(20))
            sb.AppendLine($"  {count,4}x  {file}");

        return sb.ToString();
    }

    public ICollection<AIFunction> CreateAll() =>
    [
        AIFunctionFactory.Create(ListStructure, name: "list_structure"),
        AIFunctionFactory.Create(ReadFile, name: "read_file"),
        AIFunctionFactory.Create(FindFiles, name: "find_files"),
        AIFunctionFactory.Create(CountUsage, name: "count_usage"),
    ];

    private void AppendTree(string basePath, string current, string pattern, int depth, int maxDepth, StringBuilder sb)
    {
        var indent = new string(' ', depth * 2);
        if (depth < maxDepth)
        {
            foreach (var dir in Directory.GetDirectories(current)
                .Where(d => !Path.GetFileName(d).StartsWith('.'))
                .OrderBy(d => d))
            {
                sb.AppendLine($"{indent}{Path.GetFileName(dir)}/");
                AppendTree(basePath, dir, pattern, depth + 1, maxDepth, sb);
            }
        }
        var matchPattern = pattern == "*" ? "*.*" : pattern;
        foreach (var file in Directory.GetFiles(current, matchPattern).OrderBy(f => f).Take(50))
            sb.AppendLine($"{indent}{Path.GetFileName(file)}");
    }

    private static int CountOccurrences(string text, string pattern)
    {
        var count = 0;
        var index = 0;
        while ((index = text.IndexOf(pattern, index, StringComparison.OrdinalIgnoreCase)) >= 0)
        {
            count++;
            index += pattern.Length;
        }
        return count;
    }

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

A few design choices worth calling out:

CountUsage is the high-leverage tool. It lets the model profile a pattern across a 100-file codebase without reading every file individually -- the tool scans and counts, returns a summary, and the model gets the signal it needs at a fraction of the context cost.

The 60 KB file size guard in ReadFile prevents context window overflow on large generated files, binaries masquerading as text, or minified bundles. A clean error message tells the model to try a different approach.

.git/ directory filtering in FindFiles is essential. Without it, the model's first few tool calls might waste time on git object files rather than actual source code.

The Analysis Session (Program.cs)

The core of the bot is the RunAnalysisAsync method. It creates one session, sends one prompt, waits for SessionIdleEvent, and returns the accumulated report string:

static async Task<string> RunAnalysisAsync(CopilotClient client, RepositoryTools tools)
{
    const string SystemPrompt = """
        You are an expert software architect and code reviewer.
        Analyze the repository using your tools and produce a comprehensive Markdown report.
        Be specific and thorough -- read multiple files to form accurate conclusions.
        Format the report with clear ## headings for each section.
        """;

    const string AnalysisPrompt = """
        Please analyze this repository and generate a comprehensive report.

        Follow these steps in order:
        1. Use list_structure to understand the top-level layout
        2. Use find_files to locate README, solution files, and project files
        3. Read the README and key project files
        4. Read the main entry points (Program.cs or equivalent)
        5. Sample a few representative source files to understand patterns
        6. Use count_usage to identify the most-used patterns/frameworks

        Then write a Markdown report with these sections:
        ## Project Overview
        ## Architecture & Structure
        ## Technologies & Dependencies
        ## Code Patterns & Practices
        ## Observations & Recommendations
        """;

    var reply = new System.Text.StringBuilder();
    var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);

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

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

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

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

            case SessionIdleEvent:
                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;
        }
    });

    await session.SendAsync(new MessageOptions { Prompt = AnalysisPrompt });
    await tcs.Task;

    return reply.ToString();
}

The TaskCompletionSource is the synchronization mechanism for batch jobs. SendAsync dispatches the prompt and returns immediately -- it doesn't wait for the model to finish. SessionIdleEvent fires when the model has no more output to produce, which is when tcs.TrySetResult() unblocks await tcs.Task. That's your clean exit point.

The Dual Output Pattern

Every AssistantMessageDeltaEvent and AssistantMessageEvent hits exactly two lines in the event handler:

  1. Console.Write(...) -- immediate terminal output as tokens arrive
  2. reply.Append(...) -- accumulates the full text in a StringBuilder

After await tcs.Task returns, reply.ToString() contains the complete report. Write it to repo-analysis.md and you're done. There's zero extra cost for this -- the same data flows to two destinations in the same synchronous event handler call. No extra awaits, no buffering, no memory overhead beyond the string itself.

This pattern applies to any batch AI job where you need both live visibility during a long-running operation and a persistent artifact at the end. Analysis bots, document generators, automated code review tools -- any of them can use this exact approach.

The ToolExecutionStartEvent handler shows what tool the model is invoking and with what arguments. That yellow output in the terminal tells you the bot is actively working, not frozen. It's particularly useful when the model is making multiple read_file calls in sequence and you want to see which files it's reading.

The Analysis Prompt Design

The numbered steps in AnalysisPrompt are deliberate. LLMs execute structured, ordered instructions more reliably than open-ended instructions like "analyze this repository." When you say "Follow these steps in order," the model treats it as a workflow rather than a suggestion.

Forcing list_structure as step 1 ensures the model understands the repo layout before it reads anything. Without that, the model might jump directly to reading a file that turns out to be the wrong starting point -- a generated file, a test fixture, something non-representative.

Step 6 (count_usage) comes last because it's a profiling step, not an exploration step. Once the model has read several files and formed an understanding of the codebase, counting usage of patterns it already knows exist produces more meaningful output than counting blindly upfront.

The named sections in the prompt (## Project Overview, ## Architecture & Structure, etc.) act as output scaffolding. The model sees them as required deliverables, not optional structure. This produces consistently formatted reports across wildly different repositories -- the same headings, predictable depth, comparable content.

Sample Terminal Output

Here's what a typical run looks like:

╔══════════════════════════════════════╗
║   Repository Analysis Bot            ║
║   Powered by GitHub Copilot SDK      ║
╚══════════════════════════════════════╝
Analyzing: C:devMyProject

[Exploring: list_structure({"directory": "."})]
[Exploring: find_files({"pattern": "*.csproj"})]
[Exploring: read_file({"path": "README.md"})]
[Exploring: read_file({"path": "src/MyProject.csproj"})]

## Project Overview
MyProject is a .NET 10 minimal API that...

Analysis complete! Report saved to: C:devMyProject
epo-analysis.md

The yellow tool invocation lines appear as the model works. The report text starts streaming once the model finishes its tool calls and begins writing. On a typical mid-size .NET repository (50-100 source files), the full run takes 30-60 seconds depending on how many files the model decides to read.

Key Discoveries

Working through this bot surfaces several patterns that apply broadly to batch AI jobs in C#:

SystemMessageMode.Replace is essential for controlled specialist behavior. It removes the default Copilot assistant persona entirely and installs yours. For any bot that needs deterministic, structured output -- analysis bots, document generators, automated reviewers -- this is the right mode.

The dual output pattern is zero overhead. Writing to both Console and StringBuilder in the same event handler costs nothing extra. You get live terminal visibility during the run and a complete artifact when it finishes.

Structured numbered prompts consistently outperform open-ended prompts for complex batch jobs. "Follow these steps in order" is not stylistic -- it changes how the model sequences its tool calls.

CountUsage is the high-leverage profiling tool. It can characterize a 100-file codebase's patterns without the model reading every file. Use it late in the workflow, after the model has formed an initial understanding.

await tcs.Task after SendAsync is the correct synchronization pattern for batch jobs. SessionIdleEvent is the signal that the model has finished. Everything after that line is guaranteed to have the complete report in reply.

What to Explore Next

This bot is one pattern in a larger family of GitHub Copilot SDK applications in C#. Here's where to go next depending on what you're building:

Weekly Recap: GitHub Copilot SDK, C# Source Generators, and Design Patterns [Mar 2026]

This week covers building AI developer tools with the GitHub Copilot SDK in C# -- from CLI tools and coding agents to ASP.NET Core AI APIs -- plus deep dives into C# source generators with incremental pipelines and Roslyn syntax trees, and practical guides on observer, singleton, decorator, and factory method design patterns.

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.

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