← Back to DevBytes

How to Build a Custom GitHub Copilot Extension

How to Build a Custom GitHub Copilot Extension

GitHub Copilot Extensions allow developers to integrate external tools, documentation, and services directly into the Copilot Chat experience. Instead of copying code between your editor and a browser, you can build an extension that lets Copilot query your APIs, search your internal docs, or trigger workflows from within the chat panel. This tutorial walks through everything you need to know to build, deploy, and ship a custom Copilot Extension.

What Is a Copilot Extension?

A Copilot Extension is a GitHub App that implements the Copilot Chat endpoint contract. When a user invokes your extension in Copilot Chat (by typing @your-extension followed by a prompt), GitHub sends a request to a webhook URL you control. Your service processes the request, optionally calls external APIs, and streams a response back to the user inside the chat interface.

Extensions can be public (listed in the GitHub Marketplace) or private (installed only on your organization). They are particularly powerful for teams that want to bring proprietary knowledge — internal runbooks, design docs, ticketing systems, or deployment platforms — into the developer's natural workflow.

Why It Matters

Prerequisites

How Copilot Extensions Work

The flow is straightforward but worth understanding before writing code:

Responses can be plain text, Markdown, or a streamed sequence of Copilot event chunks. Streaming is recommended for longer responses because it improves perceived latency.

Step 1: Create the GitHub App

Navigate to Settings → Developer settings → GitHub Apps → New GitHub App and configure the following:

After saving, note the App ID, Client ID, and generate a private key (.pem). You will need these to sign JWTs.

Step 2: Scaffold the Server

Create a new project and install dependencies:

mkdir copilot-extension && cd copilot-extension
npm init -y
npm install express octokit dotenv

Create a .env file with your credentials:

APP_ID=123456
CLIENT_ID=Iv1.abcdef
PRIVATE_KEY_PATH=./private-key.pem"
WEBHOOK_SECRET=your-webhook-secret
PORT=3000

Step 3: Verify the Webhook Signature

GitHub signs every request with your webhook secret using HMAC SHA-256. Always verify the signature before processing the payload.

import crypto from "crypto";
import fs from "fs";
import express from "express";
import dotenv from "dotenv";

dotenv.config();

const app = express();
app.use(express.json({ limit: "2mb" }));

function verifySignature(req, res, next) {
  const sig = req.get("x-hub-signature-256") || "";
  const body = JSON.stringify(req.body);
  const expected =
    "sha256=" +
    crypto
      .createHmac("sha256", process.env.WEBHOOK_SECRET)
      .update(body)
      .digest("hex");

  if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
    return res.status(401).send("Invalid signature");
  }
  next();
}

Note that express.json() parses the body before verification, so you must re-serialize it identically. For production, capture the raw body with express.raw({ type: "application/json" }) and parse manually to avoid subtle mismatches.

Step 4: Handle the Copilot Chat Request

The Copilot event payload contains the user's prompt, conversation history, and a token you can use to call back into GitHub APIs. Here is a minimal handler:

app.post("/api/copilot", verifySignature, async (req, res) => {
  const { copilot_interactions } = req.body;

  if (req.get("x-github-event") !== "copilot_chat") {
    return res.status(200).send("Ignored");
  }

  const lastInteraction = copilot_interactions?.at(-1);
  const userPrompt = lastInteraction?.prompt || "";

  // Build your response. This is where you'd call internal APIs,
  // query a vector database, or invoke tools.
  const answer = await buildAnswer(userPrompt);

  res.json({
    choices: [
      {
        index: 0,
        message: {
          role: "assistant",
          content: answer,
        },
      },
    ],
  });
});

async function buildAnswer(prompt) {
  return `You asked: "${prompt}". This is a placeholder response from ` +
         `the My Copilot Extension. Replace this with real logic.`;
}

app.listen(process.env.PORT, () => {
  console.log(`Copilot extension listening on ${process.env.PORT}`);
});

Step 5: Stream Responses for Better UX

For longer answers, stream chunks using the Copilot event format. Each chunk is a Server-Sent Event with a JSON payload. Streaming keeps the user engaged and lets you surface progress incrementally.

app.post("/api/copilot/stream", verifySignature, async (req, res) => {
  res.setHeader("Content-Type", "text/event-stream");
  res.setHeader("Cache-Control", "no-cache");
  res.setHeader("Connection", "keep-alive");

  const chunks = [
    "Here is the incident summary:\n\n",
    "1. **DB latency spike** at 14:02 UTC — resolved by failover.\n",
    "2. **Auth service 5xx errors** between 14:10 and 14:18 UTC.\n",
    "\nNo active incidents remain.",
  ];

  for (const chunk of chunks) {
    const data = {
      choices: [
        {
          index: 0,
          delta: { role: "assistant", content: chunk },
        },
      ],
    };
    res.write(`data: ${JSON.stringify(data)}\n\n`);
    await new Promise((r) => setTimeout(r, 150));
  }

  res.write("data: [DONE]\n\n");
  res.end();
});

Step 6: Call Back Into GitHub APIs

The request includes a token field scoped to the user and your app. Use it to act on the user's behalf — for example, fetching repository contents or creating issues.

import { Octokit } from "octokit";

async function fetchRepoReadme(userToken, owner, repo) {
  const octokit = new Octokit({ auth: userToken });
  const { data } = await octokit.rest.repos.getReadme({ owner, repo });
  return Buffer.from(data.content, "base64").toString("utf8");
}

Always scope token usage to the minimum permissions required, and never log the token.

Step 7: Test Locally with ngrok

Run your server and expose it publicly so GitHub can reach your webhook:

node index.js &
ngrok http 3000

Copy the ngrok HTTPS URL into your GitHub App's webhook field. Install the app on your account or organization, then open Copilot Chat on github.com or in your IDE and type @my-copilot-extension hello. You should see your placeholder response.

Step 8: Define the Extension Manifest

To make your extension discoverable and configurable, provide a manifest endpoint that describes its capabilities. GitHub fetches this when the extension is installed.

app.get("/.well-known/copilot-extension.json", (req, res) => {
  res.json({
    name: "My Copilot Extension",
    description: "Summarizes incidents and answers questions about internal systems.",
    hook_type: "chat",
    commands: [
      {
        name: "incidents",
        description: "Summarize recent incidents",
      },
      {
        name: "deploy",
        description: "Trigger a deployment for a service",
      },
    ],
  });
});

Best Practices

Deployment

Deploy your extension to any container-friendly host — Fly.io, Render, AWS ECS, or Cloud Run. Ensure the following:

Once deployed, update the GitHub App webhook URL, reinstall the app if permissions changed, and verify end-to-end from Copilot Chat.

Conclusion

Building a custom GitHub Copilot Extension bridges the gap between your internal tooling and the conversational AI experience developers already use every day. By implementing a signed webhook, returning well-structured responses, and following streaming and security best practices, you can ship an extension that feels native to Copilot while unlocking proprietary knowledge and workflows. Start small with a single command, iterate based on real usage, and expand to richer integrations as your team's needs grow.

— Ad —

Google AdSense will appear here after approval

← Back to all articles