.NET · .net-core · Azure Open Ai · azure-web-app · C# · Certificate · Copilot · Copilot Extension · GitHub

Building a GitHub Copilot Extension with GitHub Apps – Part 1

This is a first part of a series of posts where I am writing my learning building a GitHub Copilot extension, RAG, using your own model etc. 
- Second part
- Third part

Lately, I’ve been working with GitHub Copilot Extensions—powerful integrations that enhance Copilot Chat by connecting it to external tools, services, and custom workflows. These extensions allow developers to extend Copilot’s capabilities in a variety of ways, such as:

  • Querying documentation – Fetching information from third-party documentation sources.
  • AI-assisted coding – Using external AI models to provide code suggestions.
  • Data retrieval – Accessing structured data from APIs and databases.
  • Action execution – Automating tasks like updating tickets or posting messages.

Why Use GitHub Apps for Copilot Extensions?

One of the best ways to build a Copilot Extension is by leveraging GitHub Apps. This approach provides cross-platform compatibility, centralized app management, and first-class support from GitHub. Extensions built with GitHub Apps can be:

  • Private – Ideal for organizations needing tight control over access and customization.
  • Public – Suitable for open-source projects or internal collaboration across teams.
  • Listed on GitHub Marketplace – A great option for those who want to distribute their extension widely and integrate with developer workflows.

Each extension requires specific permissions to interact with GitHub securely. At a minimum, Copilot Chat permissions should be read-only, but depending on functionality, you may need additional access to repositories, organizations, or local editor context.

In this article, I’ll walk through how to build a GitHub Copilot Extension using a GitHub App, covering everything from setup to implementation.

GitHub Copilot App type

There are tow flavors of Copilot Extensions when built as GitHub App. Namely, Agent and SkillSet.

Skillsets are lightweight and streamlined, designed for developers who need Copilot to perform specific tasks (e.g., data retrieval or simple operations) with minimal setup. They handle routing, prompt crafting, function evaluation, and response generation automatically, making them ideal for quick and straightforward integrations. For more information about skillsets, see About Copilot skillsets.

Agents are for complex integrations that need full control over how requests are processed and responses are generated. They let you implement custom logic, integrate with other LLMs and/or the Copilot API, manage conversation context, and handle all aspects of the user interaction. While Agents require more engineering and maintenance, they offer maximum flexibility for sophisticated workflows. For more information about agents, see About Copilot agents.

In this article we are creating the Agent kind.

Create a Backend

To build our GitHub Copilot Extension, we need a backend service that acts as a bridge between GitHub Copilot and an external system. In this example, I will create a simple chat assistant that will use the GitHub models to generate AI responses.

The first step is to create a .NET Minimal API project, which will serve as the backend for our extension. This API will handle incoming requests from GitHub Copilot.

We’ll start by setting up a basic API with a few essential routes, defining endpoints. Here’s how to configure it:

var builder = WebApplication.CreateBuilder(args);
// add required services

var app = builder.Build();

app.UseSwagger();
app.UseSwaggerUI();
app.UseCors();
app.UseRouting();


var apiGroup = app.MapGroup("/");

apiGroup.MapGet("/", GreetingsEndpoint.Handler).WithOpenApi();
apiGroup.MapPost("/", new ChatEndpoint(app.Services).HandleAsync).WithOpenApi();

app.Run();

With this setup in place, we’re ready to move forward with configuring authentication and handling requests from GitHub Copilot. Let’s dive into the details in the next section!

Implementing API Endpoints

To enable GitHub Copilot to interact with Azure DevOps, we need to expose a POST endpoint in our backend API. This endpoint will act as the entry point for Copilot, allowing it to delegate control and we can then generate a response using AI models. For this example, we will be using the OOTB GitHub models to generate the responses. But you could use Azure Open AI or other 3rd party model if you will.

Setting Up the POST Endpoint

GitHub Copilot will send structured requests to our API, which we’ll process and respond to accordingly. Here’s how we can implement a minimal API in .NET to handle these requests:

    public class ChatEndpoint(IServiceProvider ServiceProvider) : EndpointBase(ServiceProvider)
    {
        public async Task HandleAsync(
            [FromServices] IHttpClientFactory HttpClientFactory,
            [FromServices] ILogger<ChatEndpoint> Logger,            
            [FromServices] IHttpContextAccessor HttpContextProvider,
            CancellationToken cancellationToken)
        {
            var httpContext = HttpContextProvider.HttpContext;
            ArgumentNullException.ThrowIfNull(httpContext);
            var requestHeaders = httpContext.Request.Headers;
            // Read the body stream once
            using var bodyStream = new MemoryStream();
            await httpContext.Request.Body.CopyToAsync(memoryStream, cancellationToken);


            var signatureVerifier = ServiceProvider.GetRequiredService<SignatureVerifier>();
            var validateSignature = await requestHeaders.ValidateSignatureAsync(signatureVerifier, memoryStream.ToArray(), cancellationToken);
            if (!validateSignature)
            {
                httpContext.Response.StatusCode = 401;
                await httpContext.Response.WriteAsync("Unauthorized", cancellationToken);
                return;
            }


            var userContext = await requestHeaders.ValidateUserTokenAsync();
            if (userContext == null || userContext.User == null)
            {
                httpContext.Response.StatusCode = 401;
                await httpContext.Response.WriteAsync("Unauthorized", cancellationToken);
                return;
            }

            var payload = await DeserializePayloadAsync<CopilotChatPayload>(bodyStream, cancellationToken);

            var userName = userContext.User?.Login;

            List<Message> messages =
                [
                    new Message("system", "You are a helpful assistant that replies to user messages.", [], []),
                    new Message("system", $"Start every response with the user's name, which is @{userName}", [], []),
                ];
            messages.AddRange(payload.Messages);


            // Create HttpClient
            var httpClient = CreateHttpClient();
            // Prepare request to Copilot API
            var bodyJsonText = JsonSerializer.Serialize(new { messages, stream = true });
            var copilotRequest = new HttpRequestMessage(HttpMethod.Post, "https://api.githubcopilot.com/chat/completions")
            {
                Headers = { { "Authorization", $"Bearer {userContext.Token}" } },
                Content = new StringContent(bodyJsonText, Encoding.UTF8, "application/json")
            };

            // Send request and get response
            var copilotResponse = await httpClient.SendAsync(copilotRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
            // Stream the response back to the client
            await using var responseStream = await copilotResponse.Content.ReadAsStreamAsync(cancellationToken);
            await responseStream.CopyToAsync(httpContext.Response.Body, cancellationToken);
        }
    }

This code defines a ChatEndpoint class, which acts as an API endpoint for handling incoming requests from GitHub Copilot. Here’s what it does step by step:

  1. Extracts HTTP Context & Headers
    • Retrieves the incoming HTTP request, including headers and body.
    • Copies the request body into a MemoryStream to read it multiple times if needed.
  2. Validates the Request Signature
    • Uses a SignatureVerifier service to verify the request signature, ensuring that it comes from a trusted source.
    • If verification fails, it responds with 401 Unauthorized.
  3. Validates the User Token
    • Extracts user details from the request headers using ValidateUserTokenAsync().
    • If the token is invalid or the user is missing, it responds with 401 Unauthorized.
  4. Processes the Copilot Chat Payload
    • Deserializes the request body into a CopilotChatPayload object.
    • Retrieves the username from the user context.
  5. Prepares Messages for GitHub Copilot API
    • Creates a list of messages for Copilot, including a system message that makes it a helpful assistant.
    • Adds a rule to prefix every response with the username (@userName).
    • Appends messages from the request payload.
  6. Forwards the Request to GitHub Copilot API
    • Creates an HTTP client to send a request to https://api.githubcopilot.com/chat/completions.
    • Constructs a JSON request payload containing messages and sets the authorization header with the user token.
  7. Streams the Response Back to the Client
    • Sends the request to GitHub Copilot.
    • Streams the response from GitHub Copilot directly back to the original requester.

As this API endpoint needs to be accessible from the internet, we need to validate the request if it indeed came from GitHub. I have the signature verifier implementation as follows:

    public class SignatureVerifier(string signatureKeysEndpoint = "https://api.github.com/meta/public_keys/copilot_api")
    {
        public async Task<bool> VerifyAsync(
            string keyId, byte[] payloadBytes,
            string signature, CancellationToken cancellationToken)
        {
            byte[] publicKeyDecodedKeyData = await GetPublicKeyAsync(keyId, cancellationToken);
            byte[] decodedSignature = Convert.FromBase64String(signature);

            var signer = SignerUtilities.GetSigner("SHA256withECDSA");
            signer.Init(false, PublicKeyFactory.CreateKey(publicKeyDecodedKeyData));
            signer.BlockUpdate(payloadBytes, 0, payloadBytes.Length);
            var verificationResult = signer.VerifySignature(decodedSignature);
            return verificationResult;
        }

        private async Task<byte[]> GetPublicKeyAsync(string keyId, CancellationToken cancellationToken)
        {
            var request = new HttpRequestMessage(HttpMethod.Get, signatureKeysEndpoint);
            request.Headers.Add("User-Agent", ".NET App");
            var _httpClient = new HttpClient();
            HttpResponseMessage response = await _httpClient.SendAsync(request, cancellationToken);
            response.EnsureSuccessStatusCode();
            string responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
            using JsonDocument document = JsonDocument.Parse(responseBody);
            JsonElement root = document.RootElement;
            if (!root.TryGetProperty("public_keys", out JsonElement publicKeysElement))
            {
                throw new InvalidOperationException("No public keys found");
            }
            string encodedKeyData = FindKey(publicKeysElement, keyId);
            byte[] decodedKeyData = Convert.FromBase64String(encodedKeyData);
            return decodedKeyData;
        }

        private string FindKey(JsonElement keyArray, string keyID)
        {
            foreach (JsonElement elem in keyArray.EnumerateArray())
            {
                if (elem.TryGetProperty("key_identifier", out JsonElement keyIdentifier) &&
                    keyIdentifier.GetString() == keyID &&
                    elem.TryGetProperty("key", out JsonElement key))
                {
                    // Extract just the key value
                    string keyValue = key.GetString() ?? string.Empty;
                    return Regex.Replace(
                        Regex.Replace(
                            Regex.Replace(
                                Regex.Replace(
                                    Regex.Replace(keyValue, "-*BEGIN.*KEY-*", ""),
                                    "-*END.*KEY-*", ""),
                                "\n", ""),
                            "\r", ""),
                        "\\s", "");
                }
            }

            throw new InvalidOperationException($"Key {keyID} not found in public keys");
        }
    }

At this point, we are ready with our endpoint. We need to deploy it to somewhere that is accessible from the Internet. I have used Azure Web app for that.

Create and Install a GitHub App

Follow these steps to create a GitHub App and install it on your account:

Step 1: Navigate to Developer Settings

  1. In the upper-right corner of GitHub, click on your profile photo.
  2. Go to Settings (for personal apps) or Your Organizations > Settings (for organization-owned apps).
  3. In the left sidebar, click Developer settings.

Step 2: Create a New GitHub App

  1. Under Developer settings, select GitHub Apps.
  2. Click New GitHub App.
  3. Provide the following details:
    • GitHub App Name: Enter a name for your app (e.g., MH-AzureDevOps).
    • Description (Optional): Add a short description of your app.
    • Homepage URL: Provide a relevant URL, such as:
      • Your app’s official website.
      • The organization’s website.
      • The GitHub repository where the app’s code is stored (if public).
    • Webhook Settings: Deselect Active (we won’t use webhooks for now).
  4. Click Create GitHub App to finalize the setup.

Step 3: Install the GitHub App

  1. After creating the app, navigate to the sidebar and click Install App.
  2. Choose your GitHub account or organization to install the app.

Once installed, the app will be ready to interact with GitHub Copilot and serve as a gateway to Azure DevOps. In the next section, we will configure permissions and authentication to enable API interactions.

The official tutorial explains these steps in depth.

With that, we should now see the agent in GitHub chat (on any repository).

Conclusion

In this guide, we explored how to extend GitHub Copilot’s capabilities by integrating it with an external system—Azure DevOps. We started by understanding GitHub Copilot Extensions, then moved on to building a .NET minimal API to act as a bridge between GitHub Copilot and Azure DevOps. We also implemented a POST endpoint to handle Copilot requests and registered a GitHub App to facilitate authentication and communication.

With these foundational steps in place, we now have a working integration that allows GitHub Copilot to interact with Azure DevOps—retrieving work items, creating and updating epics, and enhancing the development workflow.

Next steps? You can continue refining this extension by:

  • Enhancing authentication and authorization mechanisms.
  • Expanding the API to support more Azure DevOps operations.
  • Optimizing performance and error handling.

By leveraging GitHub Copilot Extensions, developers can create powerful integrations that streamline workflows and improve productivity. The possibilities are endless! 🚀

Leave a comment