Every major AI assistant now supports custom skills — capabilities you build that extend what the AI can do. GitHub Copilot calls them Extensions. Claude calls them Tools (and MCP servers). LangChain calls them Tools. Spring AI calls them Functions. The terminology is a mess, but the concept is identical: give an AI a callable capability with a defined interface, and it will use that capability when the task requires it.
This guide builds one concrete skill — a deployment status checker — across three different platforms: GitHub Copilot Extension, Claude MCP Tool, and a reusable agent skill for any LLM. By the end you'll understand the architecture of each, where they differ, and how to choose the right platform for the job.
What "Skills" Actually Are
Before touching code, it helps to understand the mental model. An AI skill has three parts:
The LLM reads the schema at inference time, decides whether the skill is relevant to the user's request, calls it with structured arguments extracted from natural language, gets the result, and incorporates it into the response. The human never directly triggers the skill — the LLM does, when it judges the skill will help.
| Platform | Schema format | Registration | Invocation |
|---|---|---|---|
| GitHub Copilot Extension | Markdown description + REST endpoint | GitHub App manifest | @your-extension in Copilot Chat |
| Claude Tools (API) | JSON Schema | tools:[] parameter on each API call |
Model invokes via tool_use content block |
| MCP Server | JSON Schema over MCP protocol | claude_desktop_config.json or MCP client config |
Any MCP-compatible client |
Spring AI @Tool |
Auto-derived from method signature | ChatClient.tools() call |
Model invokes through Spring AI framework |
The Skill We're Building
Deployment Status Checker — given a service name and optional environment, returns the current deployment status, last deploy time, version, and any active incidents.
This is representative because:
- It requires an external API call (your deployment platform — Kubernetes, Argo CD, whatever you use)
- It takes structured parameters
- It returns structured data the AI formats for humans
- Every developer wants it in their IDE assistant
We'll implement it three ways and compare what changes.
Platform 1: GitHub Copilot Extension
GitHub Copilot Extensions let you create a custom @your-extension participant in Copilot Chat. Users invoke it with @deploy-status what's the state of the payment service in prod? and your extension receives the conversation, calls your backend, and streams a response back.
Architecture
Setting Up the GitHub App
Create a GitHub App at github.com/settings/apps/new with:
- Webhook URL: your extension server's
/api/github/copilotendpoint - Permissions: Copilot Chat → Read (under Account permissions)
- Copilot: check "Copilot Extension" in the app settings
- Callback URL: for OAuth if your skill needs to act on behalf of the user
// copilot-extension-manifest.json — defines the extension's capabilities
{
"name": "deploy-status",
"description": "Check deployment status, versions, and incidents for any service",
"instructions": "Use this extension when the user asks about deployment status, service versions, recent deployments, or production incidents. It can check any service across dev, staging, and prod environments.",
"skill_sets": [
{
"name": "deployment",
"skills": [
{
"id": "get-deploy-status",
"description": "Returns the current deployment status and version for a named service",
"parameters": {
"type": "object",
"properties": {
"service": { "type": "string", "description": "The service name, e.g. payment-service" },
"environment": { "type": "string", "enum": ["dev", "staging", "prod"], "default": "prod" }
},
"required": ["service"]
}
}
]
}
]
}
The Extension Server
// server.mjs — Express webhook handler for the Copilot extension
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
import Anthropic from "@anthropic-ai/sdk";
const app = express();
app.use(express.json());
const WEBHOOK_SECRET = process.env.GITHUB_WEBHOOK_SECRET;
const COPILOT_TOKEN = process.env.COPILOT_TOKEN; // GitHub-issued token per request
// Verify GitHub's HMAC signature on every webhook
function verifySignature(req) {
const sig = req.headers["x-github-token"] ?? "";
const payload = JSON.stringify(req.body);
const digest = createHmac("sha256", WEBHOOK_SECRET).update(payload).digest("hex");
return timingSafeEqual(Buffer.from(`sha256=${digest}`), Buffer.from(sig));
}
app.post("/api/github/copilot", async (req, res) => {
if (!verifySignature(req)) return res.status(401).send("Unauthorized");
const { messages } = req.body;
const lastMessage = messages.at(-1)?.content ?? "";
// Extract service name from the user's message (basic extraction — Claude does this more elegantly)
const serviceMatch = lastMessage.match(/\b([a-z][a-z0-9-]+(?:-service|-api|-worker|-gateway))\b/i);
const service = serviceMatch?.[1] ?? null;
const envMatch = lastMessage.match(/\b(dev|staging|prod|production)\b/i);
const environment = envMatch?.[1]?.replace("production", "prod") ?? "prod";
// Call your actual deployment platform API
const status = service
? await fetchDeployStatus(service, environment)
: null;
// Use GitHub's Copilot LLM API to format the response naturally
const copilotClient = new Anthropic({
baseURL: "https://api.githubcopilot.com",
apiKey: req.headers["x-github-token"], // use the request token, not your own key
});
res.setHeader("Content-Type", "text/event-stream");
const stream = await copilotClient.messages.stream({
model: "gpt-4o", // GitHub Copilot uses its own model routing
max_tokens: 1024,
messages: [
{
role: "system",
content: "You are a helpful deployment assistant. Format deployment status clearly and concisely. Highlight any issues or incidents prominently."
},
...messages,
...(status ? [{
role: "system",
content: `Deployment data for ${service} in ${environment}: ${JSON.stringify(status)}`
}] : [])
]
});
// Stream the response back in SSE format
for await (const chunk of stream) {
if (chunk.type === "content_block_delta") {
res.write(`data: ${JSON.stringify({ choices: [{ delta: { content: chunk.delta.text } }] })}\n\n`);
}
}
res.write("data: [DONE]\n\n");
res.end();
});
async function fetchDeployStatus(service, env) {
// Replace with your actual deployment API — Argo CD, Kubernetes, Spinnaker, etc.
const res = await fetch(`${process.env.DEPLOY_API_URL}/services/${service}/status?env=${env}`, {
headers: { Authorization: `Bearer ${process.env.DEPLOY_API_TOKEN}` }
});
if (!res.ok) return { error: `Service ${service} not found in ${env}` };
return res.json();
}
app.listen(3000, () => console.log("Copilot extension running on :3000"));
[!NOTE]
GitHub Copilot Extensions receive a short-lived x-github-token in each request. Use this token to call GitHub's Copilot LLM API — you don't need your own Anthropic or OpenAI key for the language generation part. You do need your own key if you want to call a different model for specific tasks inside your extension.
Platform 2: Claude Tools (API + Spring AI)
Claude's tool use lets you define callable functions in the API request. The model reads your schema, decides when to call your tool, and sends a structured tool_use request. Your code executes the tool and returns the result. Claude then incorporates the result into its response.
Spring AI Implementation
Spring AI makes Claude tool registration almost declarative with @Tool:
// DeploymentSkill.java — Spring AI tool definition
@Component
public class DeploymentSkill {
private final DeploymentApiClient deploymentApi;
@Tool(description = """
Returns the current deployment status, version, last deploy time, and active incidents
for a named service. Use when the user asks about service health, deployment state,
running version, or recent deployments.
""")
public DeploymentStatus getDeploymentStatus(
@ToolParam(description = "The service name, e.g. payment-service or user-api") String service,
@ToolParam(description = "Environment: dev, staging, or prod. Default is prod.") String environment) {
return deploymentApi.getStatus(service, environment.isBlank() ? "prod" : environment);
}
@Tool(description = """
Lists the last N deployments for a service, including who triggered them and whether
they succeeded. Use when the user asks about deployment history or recent changes.
""")
public List<DeploymentEvent> getDeploymentHistory(
@ToolParam(description = "The service name") String service,
@ToolParam(description = "Number of recent deployments to return, max 20") int limit) {
return deploymentApi.getHistory(service, Math.min(limit, 20));
}
}
// Record for structured return — Spring AI serializes this to JSON for Claude
public record DeploymentStatus(
String service,
String environment,
String version,
String status, // "healthy" | "degraded" | "down" | "deploying"
Instant lastDeployedAt,
String deployedBy,
int activeIncidents,
String incidentSummary // null if no active incidents
) {}
// ChatController.java — wire the tool into every chat session
@RestController
@RequestMapping("/api/chat")
public class ChatController {
private final ChatClient chatClient;
private final DeploymentSkill deploymentSkill;
@PostMapping
public Flux<String> chat(@RequestBody ChatRequest req) {
return chatClient
.prompt()
.system("""
You are an AI assistant for engineering teams. You have access to deployment
status tools. Use them proactively when the user asks about services,
deployments, incidents, or production health.
""")
.user(req.message())
.tools(deploymentSkill) // Register the skill — Spring AI handles schema generation
.stream()
.content();
}
}
Spring AI reads your @Tool methods at startup, generates the JSON Schema from the parameter types and @ToolParam descriptions, and includes them in every Claude API call automatically. When Claude decides to use a tool, Spring AI intercepts the tool_use block, invokes your method with the deserialized arguments, and feeds the result back to Claude.