---
title: "Cómo crear un plugin para Claude Cowork (y Claude Code) a partir de tus skills"
excerpt: "Guía práctica para empaquetar tus skills en un plugin que funciona igual en Claude Cowork y Claude Code, con el truco de OAuth para conectar un MCP remoto sin API key. Todo verificado contra la documentación oficial."
date: "2026-05-31T12:00:00.000Z"
category: "Inteligencia Artificial"
tech_article: true
author:
  name: "angel cruz"
  picture: "https://angelcruzdevcdn.nyc3.cdn.digitaloceanspaces.com/images/me/angel-cruz.png"
ogImage:
  url: "/images/open-graph/og-claude-cowork.jpg"
seo_title: "Crear un plugin de Claude Cowork y Claude Code desde tus skills"
seo_description: "Empaqueta tus skills en un plugin para Claude Cowork y Claude Code: manifest, .mcp.json y marketplace. Incluye el truco de OAuth para conectar un MCP remoto sin API key. Verificado contra la doc oficial."
---

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:

```text
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 plugin
```

Hay un error que la propia doc marca como el más común:

> "Common mistake: Don't put `commands/`, `agents/`, `skills/`, or `hooks/` inside the `.claude-plugin/` directory. Only `plugin.json` goes 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, `name` is the only required field."

Un manifest mínimo válido es literalmente esto:

```json
{
  "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:

```json
{
  "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` (tipo `string | array`): "Custom skill directories containing `<name>/SKILL.md` (in addition to default `skills/`)"

Traducido: **no tienes que mover tus skills a la carpeta `skills/` del plugin**. Puedes dejarlas donde viven y apuntar el manifest hacia ellas:

```json
{
  "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 `name` field sets the display label shown in skill listings and, except for a plugin-root `SKILL.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:

```bash
# Cargar el plugin directamente, sin instalarlo
claude --plugin-dir ./mi-plugin

# Validar la estructura y el manifest
claude plugin validate ./mi-plugin
```

Mientras 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:

```bash
claude plugin validate ./mi-plugin --strict
```

## El 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:

```json
{
  "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 Unauthorized` or `403 Forbidden`. ... A custom server that returns a `WWW-Authenticate` header 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:

```bash
curl https://thatseoagent.com/.well-known/oauth-protected-resource
```

```json
{
  "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:

```bash
curl https://thatseoagent.com/.well-known/oauth-authorization-server
```

```json
{
  "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_endpoint` presente**: 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.Authorization` for 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:

```json
{
  "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`:

```json
{
  "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:

```json
{
  "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:

```bash
# 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-plugins
```

El 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

1. Junta tus skills (no hace falta moverlas: el manifest apunta a ellas con el campo `skills`).
2. Crea `.claude-plugin/plugin.json` con al menos `name`.
3. Si vas a traer un MCP remoto con OAuth, agrega `.mcp.json` en la raíz **sin** header de autorización.
4. Prueba en local con `claude --plugin-dir ./mi-plugin` y valida con `claude plugin validate --strict`.
5. Publica con un `marketplace.json` y 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)](https://code.claude.com/docs/en/plugins)
- [Plugins reference (code.claude.com)](https://code.claude.com/docs/en/plugins-reference)
- [Create and distribute a plugin marketplace (code.claude.com)](https://code.claude.com/docs/en/plugin-marketplaces)
- [Connect Claude Code to tools via MCP (code.claude.com)](https://code.claude.com/docs/en/mcp)
- [Extend Claude with skills (code.claude.com)](https://code.claude.com/docs/en/skills)
- [anthropics/knowledge-work-plugins (GitHub)](https://github.com/anthropics/knowledge-work-plugins)
- [oauth-protected-resource de That SEO Agent (.well-known)](https://thatseoagent.com/.well-known/oauth-protected-resource)
- [oauth-authorization-server de That SEO Agent (.well-known)](https://thatseoagent.com/.well-known/oauth-authorization-server)
- [server-card del MCP de That SEO Agent (.well-known)](https://thatseoagent.com/.well-known/mcp/server-card.json)

---

## Sitemap

Índice completo del sitio: [/sitemap.md](https://angelcruz.dev/sitemap.md)

Canónico HTML: [https://angelcruz.dev/post/crear-plugin-claude-cowork-claude-code-desde-skills](https://angelcruz.dev/post/crear-plugin-claude-cowork-claude-code-desde-skills)
