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 iden
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. Before touching code, it helps to understand the mental model. An AI skill has three parts: flowchart LR subgraph "Skill Anatomy" A["<b>Schema</b>\nWhat the skill does,\nwhat inputs it accepts,\nwhat it returns"] B["<b>Implementation</b>\nThe actual code that\nexecutes when called"] C["<b>Registration</b>\nHow the AI model\ndiscovers the skill\nexists"] end D[LLM] -->|reads| A D -->|"decides to invoke\n(with structured args)"| B B -->|result| D C -->|exposes A + B to| D style A fill:#1e3a5f,color:#7dd3fc style B fill:#065f46,color:#6ee7b7 style C fill:#374151,color:#d1d5db 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 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. 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. flowchart TD A[Developer types\n@deploy-status in\nCopilot Chat] --> B[GitHub routes message\nto your extension's\nwebhook URL] B --> C[Your Extension Server\nNode.js / Java / Python] C -->|Verify GitHub\nHMAC signature| D{Valid?} D -->|No| E[401 Unauthorized] D -->|Yes| F[Parse conversation\nfrom payload] F --> G[Call your\ndeployment API] G --> H[Optionally call\nCopilot LLM API\nto format response] H -->|SSE stream| I[Response appears\nin Copilot Chat] style C fill:#1e3a5f,color:#7dd3fc style I fill:#065f46,color:#6ee7b7 Create a GitHub App at github.com/settings/apps/new with: Webhook URL: your extension server's /api/github/copilot endpoint 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"] } } ] } ] } // 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] 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. 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 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. Understanding the wire format helps debug issues: // What Spring AI sends to the Claude API { "model": "claude-opus-5", "messages": [{ "role": "user", "content": "What version is payment-service running in prod?" }], "tools": [ { "name": "getDeploymentStatus", "description": "Returns the current deployment status, version, last deploy time...", "input_schema": { "type": "object", "properties": { "service": { "type": "string", "description": "The service name, e.g. payment-service" }, "environment": { "type": "string", "description": "Environment: dev, staging, or prod..." } }, "required": ["service"] } } ] } // Claude's response when it decides to invoke the tool { "role": "assistant", "content": [ { "type": "text", "text": "Let me check the deployment status for payment-service in prod." }, { "type": "tool_use", "id": "toolu_01XyzAbc", "name": "getDeploymentStatus", "input": { "service": "payment-service", "environment": "prod" } } ] } // Your code returns this, Spring AI sends it back to Claude { "role": "user", "content": [ { "type": "tool_result", "tool_use_id": "toolu_01XyzAbc", "content": "{"service":"payment-service","environment":"prod","version":"2.14.1",...}" } ] } Claude then reads the tool result and writes a natural language response. The Model Context Protocol (MCP) is an open standard that separates tool implementation from tool consumption. You build an MCP server once, and any MCP-compatible client — Claude Desktop, Claude Code, Cursor, Zed, your own agent — can connect to it without any changes. flowchart TD subgraph "MCP Clients" A[Claude Desktop] B[Claude Code CLI] C[Cursor IDE] D[Your Custom Agent] end subgraph "Your MCP Server" E[Tool: get_deploy_status] F[Tool: list_deployments] G[Tool: trigger_rollback] H[Resource: /incidents/active] end subgraph "Your Backend" I[Argo CD API] J[PagerDuty API] K[GitHub Actions API] end A <-->|MCP protocol\nover stdio / SSE| E B <-->|MCP protocol| E C <-->|MCP protocol| F D <-->|MCP protocol| G E --> I F --> I G --> K H --> J style E fill:#1e3a5f,color:#7dd3fc style F fill:#1e3a5f,color:#7dd3fc style G fill:#1e3a5f,color:#7dd3fc // DeploymentMcpServer.java — Spring AI MCP server implementation @SpringBootApplication @EnableMcpServer // Activate Spring AI's MCP server support public class DeploymentMcpServer { public static void main(String[] args) { SpringApplication.run(DeploymentMcpServer.class, args); } } @McpServer @Component public class DeploymentTools { private final DeploymentApiClient api; @McpTool( name = "get_deploy_status", description = "Returns current deployment status, version, and incident summary for a service" ) public DeploymentStatus getStatus( @McpParam("service") String service, @McpParam("environment") @Nullable String environment) { return api.getStatus(service, environment != null ? environment : "prod"); } @McpTool( name = "list_recent_deployments", description = "Lists the last N deployments with timestamps, deployers, and outcomes" ) public List<DeploymentEvent> listDeployments( @McpParam("service") String service, @McpParam("limit") @Nullable Integer limit) { return api.getHistory(service, limit != null ? Math.min(limit, 20) : 5); } @McpTool( name = "trigger_rollback", description = "Triggers a rollback to the previous stable version for a service in a given environment. REQUIRES explicit user confirmation in the conversation before calling." ) public RollbackResult triggerRollback( @McpParam("service") String service, @McpParam("environment") String environment) { return api.rollback(service, environment); } // MCP Resources expose data without requiring the model to "ask" for it @McpResource(uri = "deploy://incidents/active", description = "Active production incidents") public String activeIncidents() { return api.getActiveIncidents().stream() .map(i -> "- [%s] %s (since %s)".formatted(i.severity(), i.title(), i.startedAt())) .collect(Collectors.joining("\n")); } } # application.yml — MCP server transport config spring: ai: mcp: server: transport: stdio # stdio for local tools; sse for remote/multi-user name: deploy-status-mcp version: 1.0.0 // ~/.claude/claude_desktop_config.json — or your MCP client's config { "mcpServers": { "deploy-status": { "command": "java", "args": ["-jar", "/opt/tools/deploy-status-mcp.jar"], "env": { "DEPLOY_API_URL": "https://deploy.yourcompany.internal", "DEPLOY_API_TOKEN": "your-token-here" } } } } After adding this, Claude Code picks up your tools automatically. Users can run /mcp to list available tools and see get_deploy_status, list_recent_deployments, and trigger_rollback from your server. Claude Code has its own skill system for defining reusable slash commands — procedures Claude follows when you type /deploy-check in a session. <!-- .claude/skills/deploy-check.md --> name: deploy-check description: Check deployment status for a service. Usage: /deploy-check [service] [env] Check the deployment status for the given service and environment. ## Steps 1. If service name is not provided, ask the user: "Which service? (e.g. payment-service, user-api)" 2. If environment is not provided, default to prod 3. Run: curl -s -H "Authorization: Bearer $DEPLOY_API_TOKEN" \ "$DEPLOY_API_URL/services/{service}/status?env={env}" | jq . 4. Format the output: - Show service name, environment, version, and status on the first line - If status is not "healthy", highlight it prominently with what's wrong - Show the last deploy time and who triggered it - If there are active incidents, list them with severity and duration 5. If the API call fails (non-200 response), say "Could not reach the deployment API — check that DEPLOY_API_TOKEN and DEPLOY_API_URL are set in your environment" ## Output format [service] @ [env] — v[version] — [status] Skills defined in .claude/skills/ are automatically available as /skill-name in every Claude Code session in that project. The CLAUDE.md in your project can also reference them: <!-- CLAUDE.md --> ## Available slash commands - /deploy-check [service] [env] — Check deployment status and active incidents - /review — Run AI code review on current diff - /cost-check — Review token usage and estimated costs for this sprint The single highest-leverage thing you can do to make a skill perform well is write a great description. The model reads your description to decide when to invoke the skill. Bad descriptions lead to missed invocations or wrong invocations. Bad description: "Gets deployment info" Good description: "Returns the current deployment status, running version, last deploy timestamp, and active PagerDuty incidents for a named service in a specified environment (dev/staging/prod). Use when the user asks about: is service X running? what version is deployed? when was the last deployment? are there any active incidents? what's wrong with service Y?" The difference: Specificity — lists exactly what the tool returns Trigger phrases — "Use when the user asks about..." gives the model explicit routing signals Examples — concrete sample questions help the model recognize when to invoke Apply the same principle to parameter descriptions: // Bad @ToolParam("service name") String service // Good @ToolParam("The exact service name as it appears in the deployment system, e.g. 'payment-service', 'user-api', 'notification-worker'. If the user gives a partial name, use your best judgment.") String service flowchart TD A{Where will\nusers invoke\nthe skill?} A -->|In GitHub Copilot Chat\nin their IDE| B[GitHub Copilot Extension] A -->|In Claude.ai or\nClaude Code| C{Persistent connection\nor per-call?} A -->|In your own app\nor API| D{Language / framework?} A -->|Any MCP client\nCursor, Zed, etc.| E[MCP Server] C -->|One-off, embedded\nin app logic| F[Claude Tool Use\nvia API directly] C -->|Persistent, IDE-like\nmulti-session| E D -->|Java / Spring Boot| G[Spring AI @Tool\nor MCP Server] D -->|Any other| H[Claude API tool_use\nin your language] style B fill:#1e3a5f,color:#7dd3fc style E fill:#065f46,color:#6ee7b7 style G fill:#374151,color:#d1d5db Use case Best platform IDE assistant for developers (@your-team) GitHub Copilot Extension App feature powered by Claude (summarize, analyze, assist) Claude Tools via API (tool_use) Developer tool available in Claude Code / Cursor / Zed MCP Server Team automation inside Claude Code sessions Claude Code Skill (SKILL.md) Enterprise integration across multiple AI clients MCP Server (platform-agnostic) Prototype / internal hackathon Claude Tools via API (fastest to build) Regardless of which platform you choose, four implementation rules apply everywhere: 1. Return structured data, let the AI format it for humans. "Payment service is running version 2.14.1 and was last deployed at 3:42 PM." Return { version: "2.14.1", lastDeployedAt: "2026-09-02T15:42:00Z", status: "healthy" }. The AI formats prose better than you do. Structured data also survives prompt changes without touching your implementation. 2. Fail loudly with context. // Bad — Claude has nothing to work with throw new RuntimeException("Not found"); // Good — Claude can explain this to the user return new SkillError("SERVICE_NOT_FOUND", "No service named '" + service + "' found in " + environment + ". " + "Available services: payment-service, user-api, notification-worker"); 3. Add safety rails on destructive operations. trigger_rollback, add a requirement in the description: "ONLY invoke after the user has explicitly confirmed they want to roll back, e.g. 'yes, roll it back' or 'proceed with rollback'." The model respects this instruction. 4. Keep tool count low. GitHub Copilot Extensions are best for developer tools that live inside the IDE alongside code — your team can invoke them with @your-extension without leaving the editor. Claude Tools (API) are best for embedding AI capabilities inside your own applications — an AI assistant inside your product, a chatbot, a document analyzer. You control the full stack. MCP Servers are the most portable option — build once, run in any MCP-compatible client. This is the right choice for tools you want available everywhere your team uses AI. Claude Code Skills are configuration, not code — great for standardizing team workflows and creating shared slash commands without a deployment. The description is the routing logic. A well-written description is worth more than any amount of prompt engineering around the tool invocation. Invest here first. Return structured data. Let the AI format results for humans. Your job is to return correct, complete data in a machine-readable format. The AI's job is to turn that into a clear, contextualized response. The skills you build today will run in assistants your team doesn't know about yet. Invest 30 extra minutes on the schema and description — it compounds across every client that ever connects to your skill.
Key Takeaways #
- •Every major AI assistant now supports custom skills — capabilities you build that extend what the AI can do
- •This story was reported by Dev.to , covering developments in thedev space.
- •AI advancements continue to reshape industries — read the full article on Dev.to for complete coverage.
📖 Continue reading the full article: