Escribir una extensión instalable
Cualquier plugin de Claude Code es una extensión, tal cual: sus skills, comandos y subagentes se cargan como en Claude Code, y sus servidores MCP arrancan desde su propio .mcp.json. Lo que añade agent-kit (etiquetas para el chat, un logo para el catálogo, capacidades, ayuda) va en metadata["agent-kit"] dentro de su manifiesto, que Claude Code no lee. Así, una misma carpeta sirve igual a los agentes basados en el kit y a Claude Code.
Esta página construye una desde cero: weather, el tiempo en la ciudad de la persona, para un agente que planifica con ella. Tiene su propio servidor MCP, un ajuste que la persona da una sola vez (su ciudad), una herramienta que solo lee, una skill, un comando y un logo. Después lista las reglas que sigue toda extensión instalada. La extensión terminada está en el marketplace de Falken's Lab, agent-extensions/extensions/weather, y se instala con my-agent extension add weather@falkenslab una vez añadido el marketplace.
Lo que vas a construir
weather/
├── .claude-plugin/plugin.json its manifest: name, version, settings, agent-kit's data
├── .mcp.json its MCP server, as Claude Code declares it
├── dist/server.mjs the server, bundled into one file (built, committed)
├── src/server.mjs the server's source
├── package.json for building it (not installed with it)
├── skills/plan-the-day/SKILL.md a skill
├── commands/today.md a command: /weather:today
├── assets/logo.svg its logo in a catalog
└── README.md for people
Necesitas Node.js 20 o posterior, un agente basado en agent-kit para probarla (Captain Whiskers sirve) y Claude Code 2.1.271 o posterior para validarla.
1. El servidor
Las herramientas de una extensión instalada vienen de su servidor MCP: un programa de Node que el kit arranca en un proceso aparte y que habla MCP por su entrada y su salida estándar. Escríbelo con el SDK oficial:
mkdir weather && cd weather
npm init -y
npm install --save-dev @modelcontextprotocol/sdk zod esbuild
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
// Its settings, as variables: the extension's options fill them in (step 3).
const city = process.env.WEATHER_CITY ?? "";
const imperial = process.env.WEATHER_UNITS === "imperial";
const server = new McpServer(
{ name: "weather", version: "0.1.0" },
// What it's for and its rules: the model has them from the start.
{ instructions: "The person's weather: forecast for their city (today and the next days). Use it before suggesting plans that depend on the weather; never guess the weather." },
);
async function locate(name) {
const response = await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(name)}&count=1`);
const place = (await response.json()).results?.[0];
if (!place) throw new Error(`No place called ${name}.`);
return place;
}
server.registerTool(
"forecast",
{
title: "Weather forecast",
description: "The weather now and the forecast for the next days in the person's city (or another, if they name one): temperature, rain chance and wind.",
inputSchema: { city: z.string().optional().describe("Another city than the person's"), days: z.number().int().min(1).max(7).default(1) },
// It only reads: plan mode lets it through.
annotations: { readOnlyHint: true, openWorldHint: true },
},
async ({ city: other, days }) => {
const name = other || city;
if (!name) return { isError: true, content: [{ type: "text", text: "No city set: the person sets it in the extension's settings (city)." }] };
const place = await locate(name);
const units = imperial ? "&temperature_unit=fahrenheit&wind_speed_unit=mph" : "";
const url = `https://api.open-meteo.com/v1/forecast?latitude=${place.latitude}&longitude=${place.longitude}¤t=temperature_2m,wind_speed_10m&daily=temperature_2m_max,temperature_2m_min,precipitation_probability_max&timezone=auto&forecast_days=${days}${units}`;
const data = await (await fetch(url)).json();
const t = imperial ? "°F" : "°C";
const lines = [
`${place.name} (${place.country}): now ${data.current.temperature_2m}${t}, wind ${data.current.wind_speed_10m} ${imperial ? "mph" : "km/h"}.`,
...data.daily.time.map((day, i) => `${day}: ${data.daily.temperature_2m_min[i]}–${data.daily.temperature_2m_max[i]}${t}, rain ${data.daily.precipitation_probability_max[i]}%.`),
];
return { content: [{ type: "text", text: lines.join("\n") }] };
},
);
await server.connect(new StdioServerTransport());
Empaquétalo en un solo fichero. El kit instala los ficheros de una extensión y los ejecuta tal cual: nunca ejecuta npm install. Así que el servidor se distribuye con todo lo que necesita dentro:
{
"private": true,
"type": "module",
"scripts": { "build": "esbuild src/server.mjs --bundle --minify --platform=node --format=esm --target=node20 --outfile=dist/server.mjs" }
}
npm run build # dist/server.mjs, about 700 kB, with the SDK and zod inside
Haz commit de dist/server.mjs y deja fuera node_modules/ (.gitignore): lo que se instala es la carpeta tal como está en el repositorio. Un servidor ya publicado en npm no tiene por qué estar en la carpeta: consulta Un servidor publicado en npm.
2. El manifiesto
.claude-plugin/plugin.json es el manifiesto del plugin, en el formato de Claude Code:
{
"name": "weather",
"version": "0.1.0",
"description": "The weather in your city, for an agent that plans with you: today and the next days, from Open-Meteo.",
"author": { "name": "Ada Lovelace" },
"license": "MIT",
"keywords": ["weather", "planning"]
}
nameva en kebab-case y da nombre a todo lo demás: sus herramientas (mcp__weather__forecast), sus skills y comandos (weather:plan-the-day,/weather:today).authores un objeto ({ name, email?, url? }): una cadena sin más no pasa la validación.keywordsson sus topics en un catálogo.
Su servidor va en .mcp.json, tal como lo lee Claude Code:
{
"mcpServers": {
"weather": {
"command": "node",
"args": ["${CLAUDE_PLUGIN_ROOT}/dist/server.mjs"],
"env": {
"WEATHER_CITY": "${user_config.city}",
"WEATHER_UNITS": "${user_config.units}"
}
}
}
}
${CLAUDE_PLUGIN_ROOT} es la carpeta de la extensión, esté donde esté instalada. ${user_config.…} viene a continuación.
3. Sus ajustes
La ciudad de la persona es un ajuste: se declara en el manifiesto como el userConfig de Claude Code, se pregunta al instalar la extensión y se pasa al servidor donde lo nombra .mcp.json. Añade a plugin.json:
"userConfig": {
"city": { "type": "string", "title": "Your city", "description": "Where you are, e.g. Santa Cruz de Tenerife.", "required": true },
"units": { "type": "string", "title": "Units", "description": "metric (°C, km/h) or imperial (°F, mph).", "options": ["metric", "imperial"], "default": "metric" }
}
- Una opción tiene un
type(string,number,boolean,directory,file), untitley unadescriptionescritos para la persona, y puede tenerrequired,default,sensitive(un secreto),options(valores fijos),multipleymin/max. Todos los campos están en la referencia. - Sin su
cityobligatoria, la extensión se queda apagada, diciendo cómo establecerla; en Claude Code, se pregunta al activar el plugin. - Un secreto (
sensitive: true) se guarda aparte y nunca se vuelve a mostrar. Lo que hace la persona con las opciones está en Configuración.
4. Los datos de agent-kit
Lo que usan el chat, las puertas y el catálogo del kit va en metadata["agent-kit"] dentro de plugin.json. Todo es opcional:
"metadata": {
"agent-kit": {
"catalog": { "logo": "assets/logo.svg" },
"description": { "es": "El tiempo en tu ciudad, para un agente que planifica contigo: hoy y los próximos días." },
"capabilities": [
{
"id": "weather-forecast",
"skills": ["plan-the-day"],
"tools": ["forecast"],
"texts": {
"en": { "label": "Planning around the weather", "description": "Today's and the next days' forecast in your city, and a plan B when rain is likely." },
"es": { "label": "Planificar según el tiempo", "description": "El tiempo de hoy y de los próximos días en tu ciudad, y un plan B si va a llover." }
}
}
],
"help": "Weather: `forecast` gives today and the next days in your city; /weather:today sums up the day.",
"labels": {
"forecast": {
"en": { "label": "Checking the weather in {city}", "phrase": ["checked the weather", "checked the weather {n} times"] },
"es": { "label": "Mirando el tiempo en {city}", "phrase": ["miró el tiempo", "miró el tiempo {n} veces"] }
}
}
}
}
labels: cómo muestra el chat cada herramienta, por idioma: la línea mientras se ejecuta ({city}toma la entrada de la llamada) y cómo cuenta en un resumen plegado. Sin ellas, el chat muestra el nombre de la herramienta.description: su descripción en otros idiomas (es,fr,de), mostrada en el de la persona: el catálogo en los chats y el panel lateral de Captain Whiskers,extension infoy elcatalog.jsondel marketplace. El inglés sigue siendo la propiadescriptiondeplugin.json, que lee Claude Code; el prompt también la conserva, ya que el modelo lee inglés. UnREADME.es.mdjunto aREADME.mdes el README en español:extension infolo señala en una sesión en español, ycatalog.jsonlo lista.capabilities: lo que el agente puede hacer con ella, para la persona: lasskills(sus carpetas enskills/) y lastools(nombres cortos) de cada una juntas, con unalabely unadescriptionpor idioma (inglés donde falte el de la persona)./extensions,about_me,extension infoy el catálogo las muestran; sus ids cuentan como proporcionados. Al instalar se comprueba que existe cada skill nombrada; las herramientas solo las lista un servidor en marcha, así que/extensionsdice cuándo una no se ofrece. Agrupa las herramientas según lo que la persona consigue hacer, no una capacidad por herramienta: una extensión con 140 herramientas se lee como un puñado de capacidades.help: lo que dice de sí misma, para que el agente se lo cuente a la persona (elabout_mede awareness).catalog: su aspecto en un catálogo (más abajo).- Herramientas de solo lectura: aquí no hay nada que declarar, ya que
forecastdicereadOnlyHint: truepor sí misma y el modo plan pregunta al servidor (Herramientas de solo lectura).
Nunca lo pongas en el nivel superior de plugin.json: Claude Code avisaría ("Unknown field") y la validación con --strict fallaría. Todos los campos están en la referencia, y agent-kit-metadata.schema.json (en schemas/ del kit) permite que un editor lo compruebe.
5. Una skill y un comando
La herramienta dice qué hace; una skill dice cómo hacer bien un trabajo con ella, y solo se carga cuando el modelo la necesita:
---
name: plan-the-day
description: Plan the person's day or week around the weather - check the forecast first, then suggest when to do what. Use when they ask what to do today, when to go out, or to plan outdoor activities.
---
# Planning around the weather
1. Call `forecast` (with `days` for a week). Never guess the weather.
2. Put what has to be outdoors on the dry, mild hours or days; say why ("rain 70% on Thursday").
3. Name a plan B when the rain chance is over 40%.
Un comando es un flujo que escribe la persona (/weather:today):
---
description: Today's weather in your city, in two lines
---
Call `forecast` for today and tell the person, in two short lines, what the day looks like and whether they need a jacket or an umbrella.
Los subagentes van en agents/*.md, como en Claude Code. Una skill puede requerir una capacidad (requires: knowledge-base) para ofrecerse solo cuando otra extensión la proporciona.
6. Su aspecto en un catálogo
Un logo cuadrado (y un banner ancho, de unos 3:1) la convierten en una tarjeta en un catálogo: SVG, PNG, JPEG o WebP, dentro de su carpeta, nombrados en metadata["agent-kit"].catalog. Una imagen fuera de su carpeta, o de otro tipo, no se muestra.
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="weather">
<rect width="64" height="64" rx="14" fill="#1e66d0"/>
<circle cx="26" cy="26" r="10" fill="#ffd23f"/>
<path d="M22 46h22a8 8 0 0 0 0-16 11 11 0 0 0-21 3 7 7 0 0 0-1 13z" fill="#fff"/>
</svg>
7. Valídala
claude plugin validate --strict .
Debe pasar sin avisos: es la misma comprobación que ejecuta la CI de un marketplace. Después pruébala en un agente (my-agent extension add ./weather --link --set city=Madrid) y publícala.
Un README.md en su raíz es para las personas: qué hace, qué ofrece (sus herramientas, skills y comandos), sus ajustes, cómo instalarla. El modelo nunca lo lee; extension info dice dónde está.
Las reglas que sigue toda extensión instalada
Sus servidores
- Node, o npx.
commandesnodey el primero deargses su script, dentro de la extensión (${CLAUDE_PLUGIN_ROOT}/…); el resto deargsllegan al script. Ocommandesnpx, con-yy un paquete en una versión exacta (Un servidor publicado en npm). Cualquier otro comando (python,docker,npxsin una versión exacta) deja la extensión apagada, diciendo por qué: el kit solo ejecuta código instalado a través de su lanzador. - Los nombres de sus herramientas vienen de los de su servidor:
mcp__<server>__<tool>. - Su entorno son las variables del sistema (dónde están las cosas, la sesión de escritorio:
PATH,TEMP,HOME,APPDATA,PROGRAMFILES,DISPLAY,XDG_*, el idioma y la zona horaria…) y las de suenv, nada más del agente: tampoco sus credenciales. Enargsyenv,${CLAUDE_PLUGIN_ROOT}es su carpeta,${CLAUDE_PLUGIN_DATA}su carpeta de datos,${user_config.KEY}una de sus opciones, y${VAR}una variable propia del agente ("API_KEY": "${API_KEY}", del.envdel agente). - Para qué sirve y sus reglas van en las
instructionsde su servidor: el modelo las tiene desde el principio. Mantenlas cortas; los procedimientos van en skills. - Se ejecuta desde lo instalado: sin dependencias, o empaquetado con ellas en un solo fichero. Una carpeta instalada con su
node_modulesde desarrollo se hashea entera en cada sesión (segundos para decenas de miles de ficheros): instala la extensión construida, no la carpeta de desarrollo, y enlázala mientras la desarrollas.
Herramientas de solo lectura
El modo plan solo deja pasar las herramientas de las que se sabe que solo leen. Una extensión instalada dice cuáles de una de dos formas:
- Las pistas de sus servidores, cuando su manifiesto no tiene
readOnlyTools: una herramienta cuyo servidor declaraannotations: { readOnlyHint: true }pasa, consultado a la sesión en marcha cuando el modo plan se encuentra con la llamada, así que un servidor que gana una herramienta no necesita ningún cambio en el manifiesto. Un servidor que aún se está conectando, uno apagado o uno que no responde en 3 segundos cuenta como que no. - La lista de su manifiesto,
readOnlyToolsenmetadata["agent-kit"]: la respuesta completa, digan lo que digan sus servidores. Una lista vacía ([]) significa que no pasa ninguna de sus herramientas. Un servidor es el código en el que menos confía el kit, así que sus pistas solo cubren la ausencia de la lista: escribe la lista cuando no te fíes de lo que dice un servidor, o no puedas cambiarlo.
extension info <name> dice cuál se aplica (Only read roll (its manifest), o what its servers mark readOnlyHint).
Programas que necesita
El kit instala los ficheros de una extensión y nunca un programa. Un servidor que ejecuta uno que instala la persona (Docker para un laboratorio, ffmpeg o yt-dlp para vídeos) lo declara en requirements, y el kit lo comprueba, dice qué falta, para qué y cómo instalarlo en este sistema, y solo deja apagado lo que lo necesita:
"requirements": [
{
"name": "Docker",
"program": "docker",
"version": { "args": ["--version"], "min": "24.0.0" },
"install": {
"windows": "winget install Docker.DockerDesktop",
"macos": "brew install --cask docker",
"url": "https://docs.docker.com/engine/install/"
},
"for": ["practice-lab"]
}
]
- La comprobación:
programes un nombre sin más que se encuentra en elPATH(en Windows, con las extensiones dePATHEXT), nunca una ruta. Conversion, el kit lo ejecuta solo con esos argumentos (opciones como--version, oversion; nuncadocker run …), sin shell y con un límite de cinco segundos, y compara el primerx.y.zque imprime conmin. Un programa que no imprime versión, o un script de Windows (.cmd,.bat, que no pueden ejecutarse sin shell), cuenta como presente. La instalación rechaza un requisito que no se pueda comprobar así. - Lo que se deja apagado: con
for, solo esas capacidades (ids que proporciona): no se proporcionan, sus skills se quedan fuera, y el prompt,/extensionsyabout_medicen qué instalar. Sinfor, toda la extensión lo necesita, y se queda apagada con el motivo. En ambos casos se instala, yextension add,/extensions instally el controlador del chat dicen qué falta. - Cómo instalarlo: un comando por sistema (
windows,macos,linux) y un enlace (url) para el resto; la persona lee el de su sistema. - Cuándo se comprueba: cada vez que se abre una sesión (
/extensions reloaddespués de instalarlo), nunca en mitad de una. - En Claude Code, que no lee
metadata, lo dice el propio servidor: dale una herramienta de solo lecturacheck_requirements, y haz que una herramienta cuyo programa falte falle diciendo qué falta, para qué es y cómo instalarlo, en lugar de con unENOENTsin más.
Sus datos
Una extensión instalada tiene una carpeta de datos propia, nombrada en los args y el env de sus servidores como ${CLAUDE_PLUGIN_DATA}, como la de Claude Code para un plugin: <scope>/.data/<name>/ (p. ej. ~/.my-agent/extensions/.data/weather/), o la carpeta privada de un workspace dentro de la del agente (Workspaces). Guarda ahí lo que tenga que durar: un perfil de navegador con la sesión iniciada, una caché, un navegador descargado.
{ "mcpServers": { "moodle": { "command": "node", "args": ["${CLAUDE_PLUGIN_ROOT}/dist/server.mjs", "--profile", "${CLAUDE_PLUGIN_DATA}/profile"] } } }
- Se crea cuando una sesión arranca la extensión, fuera de su plugin y de su hash: lo que el servidor escribe ahí nunca apaga la extensión.
- Se conserva cuando la extensión se vuelve a instalar o se actualiza, y cuando se elimina, salvo que la persona diga que se borre.
- Nunca escribas en la propia carpeta de la extensión: una actualización la sustituye, y un fichero modificado deja la extensión apagada hasta que se vuelva a instalar.
Un servidor publicado en npm
Un servidor MCP publicado en npm por su cuenta (utilizable con npx en Claude Code o Codex) no tiene por qué copiarse en la extensión: su .mcp.json lo ejecuta con npx, y la extensión es su manifiesto, ese fichero, y sus skills y comandos. La extensión moodle-teacher de Falken's Lab es una (y moodle-student, el mismo servidor para un estudiante):
{
"mcpServers": {
"moodle": {
"command": "npx",
"args": ["-y", "@falkenslab/moodle-mcp@0.3.0"],
"env": { "MOODLE_MCP_SITES": "${user_config.sites}" }
}
}
}
Claude Code ejecuta ese npx tal cual. El kit no ejecuta npx (necesitaría npm, un node en el PATH y red mientras el servidor se conecta, y una app de escritorio no tiene nada de eso). En su lugar:
- Cuando se instala la extensión, el kit descarga el paquete del registro de npm, lo comprueba contra la
integritydel registro (sha512) y lo guarda en la caché de paquetes del agente,<agent scope>/.npm/<package>/<version>/, compartida por todas las extensiones y ámbitos que ejecutan esa versión. El lock lo registra (packages: su integridad, un SHA-256 de sus ficheros, subin). - Cuando arranca una sesión, el servidor es el
bindel paquete ejecutado con el propio Node del agente (el de Electron, en una app de escritorio) a través del lanzador, como cualquier servidor instalado. Sin npm y sin red: un paquete que falta en la caché, o cuyos ficheros han cambiado ahí, deja la extensión apagada, diciendo que se vuelva a instalar. - Solo una versión exacta:
<package>@x.y.z. Una versión publicada en npm nunca cambia, así que la extensión ejecuta siempre el mismo código; sin versión, con un rango o con una etiqueta (@latest), no se puede instalar. La única opción es-y(--yes). - El paquete trae lo que necesita: el kit no instala dependencias ni ejecuta scripts. Un paquete cuyas
dependenciesno estén todas enbundleDependencies, con scripts de instalación (preinstall,install,postinstall) o con código nativo que compilar (binding.gyp) no se puede instalar, y se dice por qué. Empaqueta el servidor, y lista enbundleDependencieslo que no se pueda empaquetar (playwright-core). - Su
bines el único del paquete, o el que se llama como el paquete (moodle-mcppara@falkenslab/moodle-mcp), igual que lo elige npx. - Actualizar el servidor es una versión nueva en
.mcp.jsony una versión nueva de la extensión; las skills escritas para esa versión van en el mismo cambio.
Un servidor remoto
Un servidor puede ejecutarse en otro sitio, como un servicio, y alcanzarse por HTTP: no se instala ni se arranca nada en el ordenador. En .mcp.json, type (http, o sse para un servidor más antiguo) y url, con headers cuando recibe un token:
{
"mcpServers": {
"api": {
"type": "http",
"url": "https://mcp.example.com/mcp?site=${user_config.site}",
"headers": { "Authorization": "Bearer ${user_config.token}" }
}
}
}
- Su dirección tiene que ser
https(httpsolo enlocalhost, para un servidor en desarrollo); la instalación rechaza cualquier otra. - Sus opciones van en
urlyheaders(${user_config.KEY},${VAR}) cuando se abre la sesión: el SDK las fija entonces, así que un valor cambiado se aplica cuando la sesión vuelve a abrirse (/extensions reload). Una cabecera que nombra una opción sin valor no se envía, así que un token puede ser opcional: conAuthorizationpuesta, el CLI no inicia sesión con OAuth. - Su nombre en la sesión es el de la extensión y el suyo,
<extensión>-<servidor>(mcp__campus-api__get_time): el CLI guarda los tokens de inicio de sesión por nombre de servidor, y un nombre propio nunca recoge los tokens del propio Claude Code de quien lo ejecuta. Las etiquetas y las herramientas de solo lectura del manifiesto usan los nombres cortos de sus herramientas, como siempre. - Iniciar sesión: un servidor detrás de OAuth empieza necesitándolo.
/extensionslo dice, y/extensions sign-in <nombre>(signInExtension()del controlador del chat) da a la persona el enlace que abrir; su navegador vuelve al CLI en este ordenador y el servidor se conecta. Desde un navegador en otro ordenador, pega la dirección en la que acabó:/extensions sign-in <nombre> <dirección>./extensions sign-out <nombre>cierra la sesión. El token se guarda en la carpeta de configuración de Claude Code que usa el CLI del agente, y una sesión nueva no vuelve a pedirlo. Usa los métodos de inicio de sesión del SDK, que aún no están en sus tipos (ADR-028): sin ellos, el agente dice que no puede iniciar sesión. - Adónde van los datos: lo que el agente le envía sale del ordenador. La instalación dice con qué host habla, y también
extension infoy el catálogo. - Las herramientas de solo lectura y las etiquetas funcionan como en un servidor local: el modo plan se fía del
readOnlyHintde sus herramientas, o de la lista del manifiesto.
Lo que no puede hacer
A diferencia de una extensión en el código de tu agente (Las propias del kit): hooks (no se ejecuta ninguno de los de su plugin), los paneles del kit, un almacén pasado desde código, sus propias carpetas para las herramientas de ficheros. Sus subagentes no reciben Bash: uno que la lista la pierde, y uno sin tools no recibe ninguna. Sus herramientas pasan por las mismas puertas que cualquier otra: la aprobación en modo interactivo, el modo plan, la transcripción.
Cuando se desactiva
Desactivar una extensión en la sesión, o cerrar la sesión, termina sus servidores y todos los procesos que arrancaron (un navegador que abrió, con todos sus propios procesos), así que un perfil que tuviera en uso no queda bloqueado para el siguiente arranque. Un servidor no tiene por qué limpiar por su cuenta, pero puede hacerlo: cerrar un navegador cuando termina su entrada guarda bien el estado del navegador.