Skip to main content

Building Custom Copilot Extensions: A Developer's Guide

April 04, 2026 5 min read

Copilot Extensions let you build agents and skill providers that integrate directly into Copilot Chat. Users invoke your extension with @your-agent in VS Code, GitHub.com, or the CLI. Your extension receives conversation context, does its work, and streams a response back. Let's build one step by step.

Extension Architecture

Two flavors: Agents handle full conversations invoked with @agent-name. Skill Providers expose discrete capabilities Copilot invokes automatically when relevant. Both are HTTP endpoints using SSE streaming in the OpenAI chat format.

The request flow looks like this: the user types @deploy-status check order-api → GitHub sends the conversation history to your registered endpoint → your agent processes the request → streams a response back using Server-Sent Events. The entire interaction happens within Copilot Chat, giving your internal tools a natural language interface.

Building an Agent in C#

Here's a deployment status agent that queries internal APIs:

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

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

    if (!VerifyGitHubSignature(context.Request, request))
        return Results.Unauthorized();

    var userMessage = request.Messages
        .LastOrDefault(m => m.Role == "user")?.Content ?? "";

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

    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();

SSE Streaming Response

The streaming response follows SSE format. Each chunk is sent as a JSON object matching the OpenAI chat completion format — this is what makes the response appear incrementally in Copilot Chat:

static async Task StreamResponse(HttpResponse response, string content)
{
    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();
    }
    await response.WriteAsync("data: [DONE]\n\n");
    await response.Body.FlushAsync();
}

You can also include confirmation actions for operations that modify state. This lets the user review and approve before the agent executes a destructive action:

static async Task StreamConfirmation(HttpResponse response, string action, string details)
{
    var confirmation = new
    {
        choices = new[] { new {
            index = 0,
            delta = new { content = $"⚠️ **Confirm:** {action}\n\n{details}\n\nReply 'yes' to proceed." },
            finish_reason = (string?)null
        }}
    };
    await response.WriteAsync($"data: {JsonSerializer.Serialize(confirmation)}\n\n");
    await response.WriteAsync("data: [DONE]\n\n");
    await response.Body.FlushAsync();
}

Security: Request Verification

Every request includes a signature header — verification is mandatory. Without it, anyone could send requests to your endpoint pretending to be GitHub:

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

    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 expected = "sha256=" + Convert.ToHexString(hash).ToLowerInvariant();

    return CryptographicOperations.FixedTimeEquals(
        Encoding.UTF8.GetBytes(signatureHeader.ToString()),
        Encoding.UTF8.GetBytes(expected));
}

The X-GitHub-Token header represents the invoking user — use it to enforce access controls via the GitHub API. You can verify the user's identity and check their permissions before processing the request.

Request and Response Models

Define clean models for the Copilot protocol. These types map to the OpenAI chat completion format that Copilot uses:

public record CopilotRequest(
    List<CopilotMessage> Messages,
    string? CopilotThreadId = null);

public record CopilotMessage(
    string Role,
    string Content,
    string? Name = null);

public record DeploymentStatus(
    string ServiceName,
    string Environment,
    string Version,
    DateTime DeployedAt,
    string Health);

Deployment and Registration

Deploy to Azure Container Apps for SSL, scaling, and cost-effective bursty workloads:

az acr build --registry myregistry --image copilot-deploy-agent:latest .
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

Register in Settings → Developer settings → GitHub Apps, set the Copilot agent URL, and install in your organization. Members can then use @deploy-status in Copilot Chat.

Testing Your Extension

Test locally before deploying using a tool like curl to simulate Copilot requests:

curl -X POST http://localhost:5000/agent \
  -H "Content-Type: application/json" \
  -d '{"messages": [{"role": "user", "content": "check order-api status"}]}'

For end-to-end testing, use a tunnel service to expose your local endpoint and register it as a development GitHub App. GitHub provides a development mode for Copilot extensions that lets you test with real Copilot Chat interactions without publishing to the marketplace.

Key Takeaways

  • Start with an agent, not a skill provider — agents are easier to reason about and test.
  • Verify every request — signature verification is your primary security boundary.
  • Use confirmation actions for write operations — never auto-execute destructive actions.
  • Deploy to a serverless platform — extensions are bursty by nature (active during work hours, idle overnight).
  • Invest in clear error messages — a helpful "not enough information" is better than a hallucinated answer.

References

  1. GitHub Copilot Extensions documentation — Official guide to building Copilot extensions
  2. GitHub Copilot Extensions samples — Official sample agents and skill providers from GitHub
  3. Creating a GitHub App — How to register and configure a GitHub App for Copilot extensions
  4. Azure Container Apps documentation — Deploying containerized applications for extension hosting
  5. Server-Sent Events specification — The SSE protocol used by Copilot extensions for streaming responses

Comments