Building Custom Copilot Extensions: A Developer's Guide
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.
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.
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();
The streaming response follows SSE format:
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();
}
Security: Request Verification
Every request includes a signature header — verification is mandatory:
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.
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.
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.
Comments
Ajit Gangurde
Software Engineer II at Microsoft | 15+ years in .NET & Azure