Cómo crear un plugin para Claude Cowork (y Claude Code) a partir de tus skills

Si ya tienes skills que usas a diario en Claude Code, empaquetarlas en un plugin es el paso que las vuelve compartibles, versionables y reutilizables entre proyectos. Y el mismo plugin funciona en Claude Cowork y en Claude Code, sin cambios.
Esta guía es práctica y verificable: cada afirmación técnica está trazada contra su fuente primaria, la documentación oficial o el endpoint real de un servidor MCP, nunca contra un blog que la repite de segunda mano. Las URLs están al final. El punto más interesante, conectar un MCP remoto con OAuth sin pedir API key, está cerca del cierre.
Claude Cowork y Claude Code usan el mismo formato
Esto no es una suposición. El repositorio oficial de plugins para Claude Cowork lo dice de forma literal:
"Built for Claude Cowork, also compatible with Claude Code."
"Both platforms use the same plugin structure, enabling shared compatibility between the two products."
Es decir: construyes el plugin una vez y corre en los dos productos. La estructura de directorios, el manifest y la forma de declarar componentes son idénticos. La única diferencia es la distribución (en Cowork los plugins se instalan desde el portal en claude.com/plugins/), no cómo armas el paquete.
Por eso el resto del artículo se apoya en la referencia de Claude Code: es la misma especificación.
La estructura de un plugin
Un plugin es un directorio con un manifest y, opcionalmente, carpetas para cada tipo de componente:
mi-plugin/
├── .claude-plugin/
│ └── plugin.json # El manifest (lo único que va aquí dentro)
├── skills/ # Skills: <nombre>/SKILL.md, Claude las usa automáticamente
├── commands/ # Skills como archivos .md planos (formato heredado)
├── agents/ # Subagentes
├── hooks/ # hooks.json con manejadores de eventos
└── .mcp.json # Servidores MCP, en la RAÍZ del pluginHay un error que la propia doc marca como el más común:
"Common mistake: Don't put
commands/,agents/,skills/, orhooks/inside the.claude-plugin/directory. Onlyplugin.jsongoes inside.claude-plugin/. All other directories must be at the plugin root level."
La regla: dentro de .claude-plugin/ va solo el plugin.json. Todo lo demás, incluido .mcp.json, vive en la raíz del plugin, un nivel más arriba. Si pones skills/ dentro de .claude-plugin/, el plugin carga pero tus skills no aparecen.
El manifest: plugin.json
El manifest describe la identidad del plugin. Lo bueno es que casi todo es opcional:
"If you include a manifest,
nameis the only required field."
Un manifest mínimo válido es literalmente esto:
{
"name": "mi-plugin"
}El name cumple doble función: identifica el plugin y sirve de namespace para sus componentes. Una skill review dentro de un plugin llamado mi-plugin se invoca como /mi-plugin:review. Ese namespacing es lo que evita choques cuando tienes varios plugins con skills del mismo nombre.
En la práctica vas a querer agregar metadatos. El schema completo soporta, entre otros campos:
{
"name": "mi-plugin",
"displayName": "Mi Plugin",
"version": "1.0.0",
"description": "Qué hace el plugin, en una línea",
"author": {
"name": "Tu Nombre",
"email": "tu@correo.com",
"url": "https://github.com/tu-usuario"
},
"homepage": "https://tu-sitio.com/mi-plugin",
"repository": "https://github.com/tu-usuario/mi-plugin",
"license": "MIT",
"keywords": ["skills", "automatizacion"]
}Un detalle sobre version que conviene entender, porque cambia cómo reciben las actualizaciones tus usuarios:
"If set, users only receive updates when you bump this field. If omitted and your plugin is distributed via git, the commit SHA is used and every commit counts as a new version."
En claro: si fijas version, controlas tú el ritmo de releases. Si la omites y distribuyes por git, cada commit cuenta como versión nueva. Para algo que cambia seguido, fijar la versión te ahorra ruido.
El truco que importa: apuntar a skills que ya tienes, sin moverlas
Este es el punto que conecta el plugin con lo que ya tienes en tu repo. El manifest acepta rutas custom para cada tipo de componente. El campo skills está documentado así:
skills(tipostring | array): "Custom skill directories containing<name>/SKILL.md(in addition to defaultskills/)"
Traducido: no tienes que mover tus skills a la carpeta skills/ del plugin. Puedes dejarlas donde viven y apuntar el manifest hacia ellas:
{
"name": "mi-plugin",
"skills": "./ruta/a/mis/skills/"
}Lo mismo aplica para commands, agents, hooks y mcpServers: todos aceptan rutas o arrays de rutas. Así un repo que ya tiene una carpeta de skills se convierte en plugin con un plugin.json que apenas las referencia.
De dónde sale el nombre con el que invocas una skill
Hay una sutileza que conviene precisar para no llevarte una sorpresa: el nombre que tecleas para invocar la skill viene de dónde vive el archivo, no del frontmatter, salvo un caso. La doc lo dice así:
"The command you type to invoke a skill comes from where the skill file lives. The frontmatter
namefield sets the display label shown in skill listings and, except for a plugin-rootSKILL.md, does not change what you type after/."
En concreto:
Ubicación del SKILL.md |
Qué determina el comando |
|---|---|
Subdirectorio skills/ del plugin |
El nombre del directorio, con el namespace del plugin: mi-plugin/skills/review/SKILL.md da /mi-plugin:review |
SKILL.md en la raíz del plugin |
El campo name del frontmatter, con el nombre del directorio del plugin como fallback |
O sea: en el caso normal (skills dentro de skills/), manda el nombre de la carpeta. El name del frontmatter solo fija el comando cuando el SKILL.md está en la raíz del plugin, porque ahí no hay carpeta de donde tomarlo. El description del frontmatter, eso sí, siempre importa: es lo que Claude lee para decidir cuándo cargar la skill automáticamente.
Probar el plugin en local antes de publicar
No necesitas publicar nada para probar. La doc da dos herramientas:
# Cargar el plugin directamente, sin instalarlo
claude --plugin-dir ./mi-plugin
# Validar la estructura y el manifest
claude plugin validate ./mi-pluginMientras desarrollas, /reload-plugins recarga skills, agentes, hooks y servidores MCP del plugin sin reiniciar la sesión. Y antes de publicar, vale correr la validación con --strict, que convierte los warnings (por ejemplo, un campo mal escrito en el manifest) en errores:
claude plugin validate ./mi-plugin --strictEl punto clave: conectar un MCP remoto con OAuth, sin API key
Un plugin puede traer servidores MCP que se conectan solos cuando el plugin está activo. Se declaran en .mcp.json, en la raíz del plugin. Para un servidor remoto el formato es mínimo:
{
"mcpServers": {
"thatseoagent": {
"type": "http",
"url": "https://thatseoagent.com/api/mcp"
}
}
}Fíjate en lo que no está: no hay header Authorization, no hay API key, no hay secreto. Y es a propósito.
Por qué omitir el header dispara OAuth
Cuando Claude Code intenta usar un servidor remoto, lo marca como "necesita autenticación" si el servidor responde 401 Unauthorized o 403 Forbidden:
"Claude Code marks a remote server as needing authentication when the server responds with
401 Unauthorizedor403 Forbidden. ... A custom server that returns aWWW-Authenticateheader pointing to its authorization server gets the same automatic discovery as any other remote server."
A partir de ese 401, Claude Code descubre los endpoints de OAuth por el camino estándar:
"By default, Claude Code first checks RFC 9728 Protected Resource Metadata at
/.well-known/oauth-protected-resource, then falls back to RFC 8414 authorization server metadata at/.well-known/oauth-authorization-server."
Si el authorization server soporta Dynamic Client Registration (DCR), Claude Code se registra como cliente OAuth solo, sin que tú ni el usuario tengan que crear una app a mano. Lo sabemos por el contrario: la doc dice que cuando el server no soporta DCR aparece el error "Incompatible auth server: does not support dynamic client registration" y hay que cargar credenciales manualmente. Si DCR está disponible, ese paso desaparece.
El resultado: el usuario instala el plugin, la primera vez que se usa la herramienta se abre el navegador, hace login, y listo. Cero API key, cero copiar tokens.
La verificación contra el endpoint real
Esto no es teoría. Lo confirmé inspeccionando los .well-known del propio servidor MCP de That SEO Agent. El primero, el protected-resource metadata:
curl https://thatseoagent.com/.well-known/oauth-protected-resource{
"resource": "https://thatseoagent.com/api/mcp",
"authorization_servers": ["https://thatseoagent.com"],
"scopes_supported": ["mcp"],
"bearer_methods_supported": ["header"]
}Ese authorization_servers apunta a dónde buscar el segundo documento, el del authorization server:
curl https://thatseoagent.com/.well-known/oauth-authorization-server{
"issuer": "https://thatseoagent.com",
"authorization_endpoint": "https://thatseoagent.com/oauth/authorize",
"token_endpoint": "https://thatseoagent.com/oauth/token",
"registration_endpoint": "https://thatseoagent.com/oauth/register",
"response_types_supported": ["code"],
"code_challenge_methods_supported": ["S256"],
"scopes_supported": ["mcp"]
}Las dos claves que hacen funcionar todo el flujo sin configuración:
registration_endpointpresente: el server soporta DCR, así que Claude Code se registra solo.code_challenge_methods_supported: ["S256"]: hay PKCE, el flujo de autorización es seguro para un cliente público.
El server-card (/.well-known/mcp/server-card.json) lo cierra: transporte streamable-http en https://thatseoagent.com/api/mcp y authentication.type igual a oauth2. Todo encaja con lo que describe la doc.
La trampa: un header inválido NO cae a OAuth
Este es el detalle que rompe a mucha gente, y la razón para omitir el header en lugar de poner uno "por las dudas". Si configuras un Authorization inválido, el flujo OAuth no se activa como fallback:
"If you configured
headers.Authorizationfor the server and the server rejects that header, Claude Code reports the connection as failed instead of falling back to OAuth. Check that the token is valid for the MCP endpoint, or remove the header to use the OAuth flow."
La lectura es directa: para que OAuth se dispare solo, no pongas el header. Un header presente pero rechazado se interpreta como "el usuario quiso autenticarse con token y falló", no como "probemos OAuth".
Distinto es si tu MCP usa una API key estática y no OAuth. Ahí el patrón correcto es otro, con el header y expansión de variables de entorno para no hardcodear el secreto:
{
"mcpServers": {
"mi-api": {
"type": "http",
"url": "https://api.example.com/mcp",
"headers": {
"Authorization": "Bearer ${MI_API_KEY}"
}
}
}
}Pero si el server soporta OAuth con DCR, como el del ejemplo, el camino sin header es más limpio para quien instala el plugin.
Distribuir: el marketplace.json
Para que otros instalen tu plugin con un par de comandos, necesitas un marketplace: un catálogo marketplace.json que vive en .claude-plugin/marketplace.json dentro de tu repo. Los campos requeridos son name, owner y plugins:
{
"name": "mis-plugins",
"owner": {
"name": "Tu Nombre"
},
"plugins": [
{
"name": "mi-plugin",
"source": "./plugins/mi-plugin",
"description": "Qué hace el plugin"
}
]
}Cada entrada en plugins[] necesita como mínimo name y source. El source dice de dónde sacar ese plugin. Para uno que vive en el mismo repo, se usa una ruta relativa, y la doc es precisa sobre la regla:
Relative path: "Local directory within the marketplace repo. Must start with
./. Resolved relative to the marketplace root, not the.claude-plugin/directory."
Auto-hospedar cuando el plugin ES el repo
Esto es recomendación de diseño, derivada de esa regla. Si tu repositorio es un único plugin (su plugin.json está en .claude-plugin/plugin.json, en la raíz), puedes poner el marketplace.json al lado y apuntar el source a la raíz misma:
{
"name": "mis-plugins",
"owner": { "name": "Tu Nombre" },
"plugins": [
{
"name": "mi-plugin",
"source": "./",
"description": "El plugin es el repo entero"
}
]
}Como el source se resuelve desde la raíz del marketplace (el directorio que contiene .claude-plugin/), "./" apunta al repo completo, que es justo donde está el plugin. Es la forma más simple de auto-hospedar: un repo, un plugin, un marketplace, sin subdirectorios. Los ejemplos explícitos de la doc usan rutas como ./plugins/mi-plugin; el "./" es la aplicación natural de esa regla a un repo de un solo plugin.
Instalación para quien usa tu plugin
Con el marketplace en un repo de GitHub, cualquiera lo instala en dos pasos:
# 1. Registrar tu marketplace (atajo owner/repo de GitHub)
/plugin marketplace add tu-usuario/tu-repo
# 2. Instalar el plugin desde ese marketplace
/plugin install mi-plugin@mis-pluginsEl formato <plugin>@<marketplace> no es decorativo: el marketplace es el name que pusiste en tu marketplace.json, no el nombre del repo. A partir de ahí, las skills quedan disponibles como /mi-plugin:nombre-skill y, si incluiste un .mcp.json con un server OAuth, la primera vez que se use se abrirá el navegador para autenticar.
En resumen
- Junta tus skills (no hace falta moverlas: el manifest apunta a ellas con el campo
skills). - Crea
.claude-plugin/plugin.jsoncon al menosname. - Si vas a traer un MCP remoto con OAuth, agrega
.mcp.jsonen la raíz sin header de autorización. - Prueba en local con
claude --plugin-dir ./mi-pluginy valida conclaude plugin validate --strict. - Publica con un
marketplace.jsony comparte los dos comandos de instalación.
Lo construyes una vez y corre en Claude Cowork y en Claude Code. Y si tu MCP expone OAuth con DCR, quien lo instala no tiene que ver una sola API key.
Preguntas Frecuentes
¿Un plugin de Claude Code funciona igual en Claude Cowork?
Sí. El repositorio oficial de plugins para Cowork lo confirma: ambas plataformas usan la misma estructura de plugin. Construyes el paquete una vez y corre en los dos productos; lo único que cambia es la vía de distribución.
¿Tengo que mover mis skills a la carpeta del plugin?
No. El campo skills del manifest acepta rutas custom, así que puedes dejar tus skills donde ya viven y apuntar el plugin.json hacia esa carpeta. No hace falta reorganizar tu repo.
¿Necesito publicar un marketplace para probar el plugin?
No. Cárgalo directo con claude --plugin-dir ./mi-plugin, sin instalar nada. Usa /reload-plugins para recargar cambios sin reiniciar la sesión, y claude plugin validate para revisar el manifest.
¿Cómo conecto un MCP con OAuth sin pedirle una API key al usuario?
Declara el server en .mcp.json sin header Authorization. Si el servidor responde 401 y expone /.well-known/oauth-protected-resource con un authorization server que soporta Dynamic Client Registration, Claude Code se registra solo y abre el navegador para el login. Cero API key.
¿Por qué mi MCP falla en vez de abrir el navegador para OAuth?
Casi siempre porque dejaste un header Authorization inválido. La doc es clara: si configuras ese header y el servidor lo rechaza, Claude Code reporta la conexión como fallida en lugar de caer a OAuth. Quita el header para que se dispare el flujo del navegador.
¿Qué pasa si no defino el campo version en el manifest?
Si lo omites y distribuyes por git, Claude Code usa el commit SHA y cada commit cuenta como una versión nueva. Si fijas version, tus usuarios solo reciben actualizaciones cuando subes ese número.
Instalé el plugin pero mi skill no aparece, ¿qué reviso?
Lo primero: que la carpeta skills/ esté en la raíz del plugin y no dentro de .claude-plugin/, que es el error más común. Dentro de .claude-plugin/ va solo el plugin.json. Si la estructura está bien, corre /reload-plugins.
¿El nombre con el que invoco la skill sale del frontmatter o del archivo?
Del nombre del directorio de la skill, con el namespace del plugin (mi-plugin/skills/review/SKILL.md da /mi-plugin:review). El name del frontmatter solo fija el comando cuando el SKILL.md está en la raíz del plugin.
Última actualización: 31 de mayo de 2026.
Fuentes
- Create plugins (code.claude.com)
- Plugins reference (code.claude.com)
- Create and distribute a plugin marketplace (code.claude.com)
- Connect Claude Code to tools via MCP (code.claude.com)
- Extend Claude with skills (code.claude.com)
- anthropics/knowledge-work-plugins (GitHub)
- oauth-protected-resource de That SEO Agent (.well-known)
- oauth-authorization-server de That SEO Agent (.well-known)
- server-card del MCP de That SEO Agent (.well-known)

Angel Cruz
Soy desarrollador PHP senior. Casi todo lo que construyo pasa por Laravel, me obsesiona el código que se mantiene y escribo sobre lo que aprendo, con sus trade-offs incluidos.