Skip to main content

Building Custom Copilot Extensions: A Developer's Guide

April 04, 2026 8 min read

GitHub Copilot has become part of my daily workflow to the point where I barely notice it — like autocomplete on a phone keyboard, it's just there. But what really changed how my team works wasn't Copilot's built-in capabilities. It was the moment we built our first custom extension that could query our internal systems, pull deployment status, and suggest fixes based on our own runbooks. That's when Copilot went from a code completion tool to a genuine development platform.

Copilot Extensions let you build agents and skill providers that integrate directly into the Copilot Chat experience. Users invoke your extension with @your-agent in any Copilot Chat interface — VS Code, GitHub.com, JetBrains, or the CLI. Your extension receives the conversation context, does whatever work it needs to, and streams a response back. It's a surprisingly clean developer experience once you understand the moving parts.

In this post, I'll walk through building a practical Copilot extension from scratch. Not a toy example that echoes back "Hello World," but something useful: an agent that queries your API, processes the results, and provides actionable answers to developers in their IDE.

Understanding the Extension Architecture

Copilot Extensions come in two flavors:

  • Agents — full conversational experiences invoked with @agent-name. They receive the entire conversation history and can maintain context across turns.
  • Skill Providers — focused capabilities that Copilot can invoke automatically when relevant. Think of them as tools that Copilot decides to use based on the conversation context.

Both types work through the same mechanism: your extension is an HTTP endpoint that receives requests from GitHub's Copilot infrastructure and returns streaming responses.

Here's the request flow:

  1. User types @deploy-status what's the status of the payments service? in Copilot Chat
  2. GitHub routes the message to your registered extension endpoint
  3. Your endpoint receives the message with conversation context and user identity
  4. You process the request, call your internal APIs, and stream a response back
  5. The response appears in Copilot Chat with rich formatting

The key technical detail: responses use Server-Sent Events (SSE) streaming with the same message format as the OpenAI Chat API. If you've worked with any LLM streaming API, this will feel familiar.

Building Your First Agent in C#

Let's build a deployment status agent. This agent queries an internal API and reports the deployment state of your services. Here's the ASP.NET Core implementation:

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient<DeploymentService>();
var app = builder.Build();

app.MapPost("/agent", async (HttpContext context, DeploymentService deployService) =>
{
    // Parse the incoming Copilot request
    var request = await context.Request.ReadFromJsonAsync<CopilotRequest>();
    if (request is null) return Results.BadRequest();

    // Verify the request signature (critical for security)
    if (!VerifyGitHubSignature(context.Request, request))
        return Results.Unauthorized();

    // Extract the user's latest message
    var userMessage = request.Messages
        .LastOrDefault(m => m.Role == "user")?.Content ?? "";

    // Set up SSE streaming
    context.Response.ContentType = "text/event-stream";
    context.Response.Headers.CacheControl = "no-cache";

    // Determine what the user is asking about
    var serviceName = ExtractServiceName(userMessage);
    
    if (serviceName != null)
    {
        var status = await deployService.GetStatusAsync(serviceName);
        await StreamResponse(context.Response, FormatDeploymentStatus(status));
    }
    else
    {
        var allServices = await deployService.GetAllServicesAsync();
        await StreamResponse(context.Response, FormatServiceList(allServices));
    }

    return Results.Empty;
});

app.Run();

The streaming response follows the SSE format that Copilot expects:

static async Task StreamResponse(HttpResponse response, string content)
{
    // Stream the response in chunks, matching the OpenAI chat format
    var chunks = SplitIntoChunks(content, maxChunkSize: 100);
    
    foreach (var chunk in chunks)
    {
        var sseData = new
        {
            choices = new[]
            {
                new
                {
                    index = 0,
                    delta = new { content = chunk },
                    finish_reason = (string?)null
                }
            }
        };

        await response.WriteAsync($"data: {JsonSerializer.Serialize(sseData)}\n\n");
        await response.Body.FlushAsync();
    }

    // Signal completion
    var doneData = new
    {
        choices = new[]
        {
            new
            {
                index = 0,
                delta = new { content = "" },
                finish_reason = "stop"
            }
        }
    };
    await response.WriteAsync($"data: {JsonSerializer.Serialize(doneData)}\n\n");
    await response.WriteAsync("data: [DONE]\n\n");
    await response.Body.FlushAsync();
}

Request Verification and Security

Every request from GitHub includes a signature header that you must verify. This prevents anyone from calling your endpoint directly and impersonating Copilot. Here's the verification logic:

static bool VerifyGitHubSignature(HttpRequest request, CopilotRequest body)
{
    if (!request.Headers.TryGetValue("X-GitHub-Signature-256", out var signatureHeader))
        return false;

    var signature = signatureHeader.ToString();
    var secret = Environment.GetEnvironmentVariable("GITHUB_WEBHOOK_SECRET")!;
    var payload = JsonSerializer.Serialize(body);

    using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
    var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload));
    var expectedSignature = "sha256=" + Convert.ToHexString(hash).ToLowerInvariant();

    return CryptographicOperations.FixedTimeEquals(
        Encoding.UTF8.GetBytes(signature),
        Encoding.UTF8.GetBytes(expectedSignature));
}

Additionally, you can verify the user's identity by checking the X-GitHub-Token header. This token represents the user who invoked your agent, and you can use it to call the GitHub API on their behalf:

static async Task<GitHubUser?> GetUserFromToken(string token)
{
    using var client = new HttpClient();
    client.DefaultRequestHeaders.Authorization = 
        new AuthenticationHeaderValue("Bearer", token);
    client.DefaultRequestHeaders.UserAgent.ParseAdd("MyCopilotExtension/1.0");

    var response = await client.GetAsync("https://api.github.com/user");
    if (!response.IsSuccessStatusCode) return null;

    return await response.Content.ReadFromJsonAsync<GitHubUser>();
}

This is powerful for building extensions that respect your organization's access controls. If a developer doesn't have access to a particular service in your internal systems, your agent can enforce that based on their GitHub identity.

Adding Intelligence with Confirmation Actions

Copilot Extensions support rich interactions beyond plain text responses. You can include confirmation prompts that let users approve actions before your agent executes them:

static async Task StreamConfirmation(HttpResponse response, string serviceName, 
    string targetVersion)
{
    var confirmation = new
    {
        type = "action",
        title = "Confirm Deployment",
        message = $"Deploy **{serviceName}** to version **{targetVersion}**?",
        confirmation = new
        {
            title = "Deploy Now",
            message = $"This will deploy {serviceName} v{targetVersion} to production. " +
                      "The deployment takes approximately 5 minutes.",
            confirmation_id = $"deploy-{serviceName}-{targetVersion}"
        }
    };

    var sseData = new
    {
        choices = new[]
        {
            new
            {
                index = 0,
                delta = new
                {
                    role = "assistant",
                    content = $"I can deploy **{serviceName}** to version **{targetVersion}**.",
                },
                finish_reason = (string?)null
            }
        },
        copilot_confirmations = new[] { confirmation }
    };

    await response.WriteAsync($"data: {JsonSerializer.Serialize(sseData)}\n\n");
    await response.Body.FlushAsync();
}

When the user confirms, your agent receives a follow-up request with the confirmation_id, and you can proceed with the actual deployment. This pattern is essential for any agent that performs write operations — never auto-execute destructive actions without user confirmation.

Building a Skill Provider

Skill providers are simpler than agents. Instead of handling full conversations, they expose discrete capabilities that Copilot invokes when relevant. Here's a skill provider that looks up error codes from your internal documentation:

app.MapPost("/skill", async (HttpContext context) =>
{
    var request = await context.Request.ReadFromJsonAsync<CopilotSkillRequest>();
    if (request?.Skill?.Name != "lookup-error") return Results.BadRequest();

    var errorCode = request.Skill.Parameters?["error_code"]?.ToString();
    if (string.IsNullOrEmpty(errorCode)) 
        return Results.BadRequest("error_code parameter required");

    var errorInfo = await errorDatabase.LookupAsync(errorCode);

    context.Response.ContentType = "text/event-stream";
    
    var content = errorInfo != null
        ? $"**Error {errorCode}**: {errorInfo.Description}\n\n" +
          $"**Cause**: {errorInfo.CommonCause}\n\n" +
          $"**Resolution**: {errorInfo.Resolution}"
        : $"Error code `{errorCode}` not found in the knowledge base.";

    await StreamResponse(context.Response, content);
    return Results.Empty;
});

You register the skill's capabilities in your extension's manifest, and Copilot decides when to invoke it based on the user's conversation:

{
  "name": "error-lookup",
  "description": "Looks up internal error codes and provides resolution steps",
  "parameters": {
    "type": "object",
    "properties": {
      "error_code": {
        "type": "string",
        "description": "The error code to look up (e.g., ERR-5001, PAYMENT-TIMEOUT)"
      }
    },
    "required": ["error_code"]
  }
}

Deploying and Registering Your Extension

Your extension needs to be accessible via HTTPS. For production, I recommend deploying to Azure Container Apps — it handles SSL, scaling, and is cost-effective for bursty workloads like Copilot extensions:

# Build and push your container
az acr build --registry myregistry --image copilot-deploy-agent:latest .

# Deploy to Container Apps
az containerapp create \
  --name copilot-deploy-agent \
  --resource-group copilot-extensions-rg \
  --environment copilot-env \
  --image myregistry.azurecr.io/copilot-deploy-agent:latest \
  --target-port 8080 \
  --ingress external \
  --min-replicas 1 \
  --max-replicas 5 \
  --env-vars GITHUB_WEBHOOK_SECRET=secretref:webhook-secret

Then register your extension in your GitHub organization settings:

  1. Go to Settings → Developer settings → GitHub Apps and create a new app
  2. Set the Copilot agent URL to your Container Apps endpoint
  3. Configure the required permissions (typically just read access to user profile)
  4. Install the app in your organization

Once installed, any member of your organization can use @deploy-status in Copilot Chat across all supported surfaces.

Key Takeaways

Building Copilot extensions is genuinely accessible for any .NET developer who's built a web API. Here's what matters:

  • Start with an agent, not a skill provider. Agents are easier to reason about and test. You can always extract skills later.
  • Verify every request. The signature verification isn't optional — it's your primary security boundary.
  • Use confirmation actions for write operations. Never let an agent modify production systems without explicit user approval.
  • Deploy to a serverless platform. Copilot extensions are bursty by nature — you'll get a flurry of requests during work hours and nothing overnight. Container Apps or Azure Functions fit this pattern perfectly.
  • Invest in good error messages. When your agent can't help, say so clearly and suggest alternatives. A helpful "I don't know" is better than a hallucinated answer.

The extension ecosystem is still early, which means there's a real opportunity to build tools that become indispensable for your team. Start with something simple — a status checker, a documentation lookup, or a deployment helper — and iterate based on what your developers actually use.

Share this post

Comments

Ajit Gangurde

Software Engineer II at Microsoft | 15+ years in .NET & Azure