Crear plugins en OpenCode 2: hooks, tools y permisos
Tabla de contenidos
Un plugin de OpenCode 2 cabe en diez líneas de TypeScript.
El primero que escribí para este artículo bloqueaba la lectura del .env. Funcionó a la primera… y a los tres minutos el modelo leyó el .env igual, con un grep desde la shell. Mi regla vigilaba la puerta principal y el agente salió por la de la cocina.
De eso va este tutorial: escribir plugins para OpenCode 2 y comprobar que hacen lo que crees que hacen. Todo lo que vas a leer lo he ejecutado en OpenCode 2.0.31, con capturas de la terminal y los fallos que me encontré por el camino.
En este artículo vas a ver:
- Qué es un plugin en OpenCode 2 y cuándo te conviene frente a una skill o un MCP
- Cómo se escribe, se carga y se depura el plugin más simple posible
- Cómo construir paso a paso un plugin real,
wr.guardia, con permisos, shell, contexto, herramientas propias, un comando y almacenamiento - Las sorpresas que no cuenta la documentación: Code Mode,
storagecompartido entre proyectos y por qué un hook de permisos no basta si se te olvida la shell - Cómo probar tus plugins y cómo publicarlos
Si lo que buscas es un listado de plugins ya hechos para instalar, tienes la guía de los mejores plugins para OpenCode. Aquí vamos a fabricar el nuestro.
¿Qué es un plugin de OpenCode 2 y qué puede hacer? ¶
Un plugin de OpenCode 2 es un módulo JavaScript o TypeScript que exporta Plugin.define con un id y una función setup. OpenCode llama a setup al cargar el plugin y le pasa un contexto (ctx) desde el que puedes registrar herramientas y comandos, interceptar lo que hace el agente y usar la API del servidor.
En la versión 1 un plugin era una función que devolvía un objeto con hooks. En la 2 todo pasa por ese ctx, y los plugins de la versión 1 no se ejecutan. Si vienes de ahí, en qué cambia en OpenCode 2 y cómo actualizar tienes la tabla de equivalencias.
El ctx tiene tres tipos de puertas:
- Transforms: añaden o cambian piezas del sistema. Herramientas, comandos, agentes, skills, servidores MCP, proveedores de modelos, buscadores web o estrategias de worktrees.
- Hooks: interceptan operaciones en vivo. El prompt que llega, el contexto que se manda al modelo, cada llamada a una herramienta, cada comando de shell y cada decisión de permisos.
- API: lo mismo que el cliente del servidor de OpenCode. Crear sesiones, mandar prompts, leer el estado de git, guardar datos o generar texto con un modelo.
¿Y cuándo compensa un plugin frente a las otras formas de extender OpenCode? Esta es la regla que uso:
| Necesitas… | Mejor opción | Por qué |
|---|---|---|
| Que el agente sepa hacer una tarea con tus convenciones | Skill (SKILL.md) |
Es texto, se carga cuando hace falta, no hay código |
| Conectar un servicio externo (GitHub, Figma, una base de datos) | Servidor MCP | Vale para cualquier agente, no solo OpenCode |
| Un prompt reutilizable detrás de una barra | Comando en markdown | Sin código, en .opencode/commands/ |
| Bloquear, modificar o auditar lo que hace el agente | Plugin | Es lo único que se sienta en medio de cada acción |
| Herramientas con lógica propia y estado que persiste | Plugin | ctx.tool + ctx.storage sin montar un servidor |
Si dudas entre skill y plugin, en cómo crear skills para Claude Code, Codex, Cursor y OpenCode tienes la otra mitad de la historia.
🔑 Si quieres que el agente sepa algo, escribe una skill. Si quieres que no pueda hacer algo, necesitas un plugin. Las instrucciones se pueden ignorar; un
denydel hook de permisos, no.
¿Qué necesitas para escribir tu primer plugin? ¶
Necesitas OpenCode 2 instalado y una carpeta de proyecto. Nada más. Si nunca has usado OpenCode, empieza por el tutorial de OpenCode en español y vuelve aquí. Para este artículo he usado el paquete oficial de npm en una carpeta aislada:
# Instalación aislada para pruebas (no toca tu OpenCode principal)
npm install @opencode/cli@2.0.31
npx opencode --version
# opencode v2.0.31
Si ya lo tienes instalado por otra vía, comprueba la versión con opencode --version. Para instalarlo o actualizar desde la versión 1, sigue la guía de OpenCode 2.
Hay tres sitios donde OpenCode busca plugins:
.opencode/plugins/dentro del proyecto. Carga solos los ficheros.tsy.jsque haya ahí y las carpetas de primer nivel.~/.config/opencode/plugins/para plugins globales, con la misma estructura.- La clave
pluginsdeopencode.jsoncpara paquetes de npm, rutas a otras carpetas o plugins con opciones.
Un detalle que pilla a todo el mundo: una carpeta plugins/ en la raíz del proyecto, al lado de opencode.jsonc, no se descubre sola. O la metes dentro de .opencode/ o la declaras en la configuración.
¿Y las dependencias? Para un plugin local no hace falta instalar nada: OpenCode resuelve el import de @opencode/plugin al cargarlo. Mi proyecto de pruebas no tenía ni package.json y el plugin cargó sin problema. Si quieres autocompletado y comprobación de tipos en el editor, instala los tipos con la misma versión que tu OpenCode:
npm install -D @opencode/plugin@2.0.31 typescript
¿Cómo se escribe un plugin «hola mundo»? ¶
Crea el fichero .opencode/plugins/hola.ts:
import { Plugin } from "@opencode/plugin"
export default Plugin.define({
id: "wr.hola",
async setup(ctx) {
// console.error sale en los logs del servidor
console.error(`[wr.hola] cargado en OpenCode ${ctx.app.version} en ${ctx.location.directory}`)
// Almacenamiento JSON persistente del plugin
await ctx.storage.set("cargado", new Date().toISOString())
},
})
Y lánzalo con un servidor privado y los logs a la vista:
opencode run --standalone --print-logs -m opencode/beam-free "Responde solo: hola"
Entre las líneas del log aparecen estas dos:
level=INFO msg="loading plugin" id=/…/proyecto/.opencode/plugins/hola.ts
[wr.hola] cargado en OpenCode 2.0.31 en /…/proyecto
Ya tienes un plugin. Tres cosas que conviene saber desde el principio:
- El
ides obligatorio y estable. OpenCode lo usa para identificar el plugin en los diagnósticos y para su almacenamiento. Usa un prefijo tuyo (wr.,miempresa.) para no chocar con nadie. setuppuede devolver una función de limpieza, que se ejecuta cuando el plugin se descarga. Ahí cierras temporizadores, conexiones o suscripciones a eventos.--standalonearranca un servidor privado. Sin esa opción, OpenCode 2 se conecta al servidor compartido en segundo plano y los logs del plugin no salen en tu terminal. Para desarrollar,--standalone --print-logses tu mejor amigo.
💡
opencode/beam-freees uno de los modelos gratuitos de OpenCode Zen. Para probar plugins viene de lujo porque no necesitas clave, pero ten paciencia: en mis pruebas varios modelos gratuitos devolvían «Rate limit exceeded» cada pocos minutos. Más abajo verás cómo un plugin puede ayudarte con eso.
Antes de programar un plugin, prueba si te basta con una skill
Mucho de lo que se le repite al agente cabe en un SKILL.md. En este minicurso por email, a tu ritmo, instalas skills y creas la tuya.
Apúntate al minicurso →¿Qué vamos a construir? El plugin wr.guardia ¶
Un «hola mundo» no te enseña dónde duele. Así que vamos a construir un plugin con problemas reales, wr.guardia, que hace ocho cosas:
| # | Qué hace | Pieza de la API |
|---|---|---|
| 1 | Impide leer o editar ficheros de secretos | ctx.permission.hook("evaluate") |
| 2 | Impide hacer git push a ramas protegidas |
ctx.permission.hook("evaluate") |
| 3 | Marca los comandos del agente y limita su duración | ctx.shell.hook("create.before") |
| 4 | Añade una instrucción fija a cada llamada al modelo | ctx.session.hook("context") |
| 5 | Espera más cuando el proveedor devuelve un 429 | ctx.session.hook("retry") |
| 6 | Cuenta cuántas veces se usa cada herramienta | ctx.tool.hook("execute.after") |
| 7 | Da al agente dos herramientas para guardar notas | ctx.tool.transform + ctx.storage |
| 8 | Añade un comando /informe con el uso de herramientas |
ctx.command.transform |
Va en una carpeta, .opencode/plugins/guardia/index.ts, porque en algún momento querrás partirlo en varios ficheros. El esqueleto con las opciones por defecto:
import { Plugin } from "@opencode/plugin"
// Opciones con valores por defecto. Se pueden cambiar desde opencode.jsonc
const DEFAULTS = {
secretos: [".env", ".env.local", ".env.production", "id_rsa"],
ramasProtegidas: ["main", "master"],
idioma: "español",
}
const esSecreto = (ruta: string, secretos: string[]) => {
const nombre = ruta.split("/").pop() ?? ruta
return secretos.includes(nombre)
}
export default Plugin.define({
id: "wr.guardia",
async setup(ctx) {
const opciones = { ...DEFAULTS, ...ctx.options }
const log = (msg: string) => console.error(`[wr.guardia] ${msg}`)
log(`activo en ${ctx.location.directory}`)
// Aquí van los hooks, las tools y el comando
return () => log("descargado")
},
})
Antes de meter hooks conviene saber en qué orden se disparan.
¿En qué momentos puede intervenir un plugin? ¶
Entre que escribes un mensaje y el agente usa una herramienta, un plugin puede intervenir en seis puntos. Los he medido con un plugin «espía» que solo escribía en el log cada hook que recibía, y este es el recorrido real en la 2.0.31:
Dos cosas del orden que no esperaba:
shellcreate.beforese ejecuta antes que la evaluación de permisos. Cuando el agente pidiógit push origin main, el log del espía mostró primero el hook de la shell y después el de permisos. Si encreate.beforehaces algo con efectos (escribir un fichero, llamar a una API), lo harás también para comandos que luego se deniegan. Ahí solo ajusta el comando.contextse repite en cada vuelta del bucle. No solo con tu mensaje: también cada vez que el resultado de una herramienta vuelve al modelo. Lo que metas ahí viaja en todas las vueltas.
Con eso claro, vamos a por la primera protección.
¿Cómo bloqueas acciones con el hook de permisos? ¶
Con ctx.permission.hook("evaluate", ...), que recibe cada decisión de permisos y te deja cambiar su efecto a allow, ask o deny. Es el sitio correcto para las protecciones: se ejecuta después de las reglas de configuración y antes de que la acción ocurra o se le pregunte al usuario.
El evento trae la acción, los recursos afectados y el efecto que OpenCode iba a aplicar. Esto es lo que registró el espía cuando el agente intentó leer el .env y ejecutar dos comandos:
[espia] perm {"action":"read","resources":[".env"],"effect":"ask","agent":"build"}
[espia] perm {"action":"shell","resources":["ls -la"],"effect":"allow","agent":"build"}
[espia] perm {"action":"shell","resources":["git push origin main"],"effect":"allow","agent":"build"}
Fíjate en el primero: OpenCode 2 ya pide confirmación para leer .env sin que configures nada. El problema es que con --auto (o pulsando «permitir siempre» un lunes a las nueve) esa pregunta se acepta sola. El plugin lo convierte en un deny:
await ctx.permission.hook("evaluate", (event) => {
const tocaSecreto = event.resources.some((r) => esSecreto(r, opciones.secretos))
if (["read", "edit"].includes(event.action) && tocaSecreto) {
event.effect = "deny"
event.message = "wr.guardia: los ficheros de secretos no se tocan. Pide al usuario el valor que necesites."
log(`denegado ${event.action} sobre ${event.resources.join(", ")}`)
}
})
El message es lo que lee el modelo. La herramienta le devuelve un error de tipo permission.rejected con ese texto y el agente decide qué hacer con él. Un buen mensaje le dice qué alternativa tiene.
Dos reglas de la documentación que conviene tener presentes:
- El hook se ejecuta para decisiones
allowyask. Undenyconfigurado enopencode.jsonces definitivo y ni siquiera llega al hook. - Si varios plugins registran el mismo hook, se ejecutan en el orden de los plugins y cada uno ve los cambios del anterior.
El agente encontró otra puerta ¶
Con la regla anterior activa, le pedí al agente algo inocente: «abre el fichero .env y dime qué variables define». Esto es lo que hizo:

No llamó a read. Ejecutó un grep sobre .env desde la shell, que para el sistema de permisos es la acción shell, no read. Mi regla ni se enteró. (Hay que reconocerle el detalle de quitar los valores con sed. Esta vez.)
El agente no esquiva tus reglas por malicia: busca la forma más corta de cumplir lo que le pides. Si la puerta de read está cerrada y la de la shell abierta, pasa por la shell. La solución es vigilar también los comandos:
if (event.action === "shell") { const comando = event.resources.join(" ") // El agente puede intentar leer el secreto con cat, grep o sed const palabras = comando.split(/[\s'"=<>|;&()]+/) if (palabras.some((p) => esSecreto(p, opciones.secretos))) { event.effect = "deny" event.message = "wr.guardia: tampoco desde la shell. Los ficheros de secretos no se leen." log(`denegado: ${comando}`) return }}Con esta versión, la misma petición acabó con el agente explicando que la configuración de seguridad del proyecto protege el .env y ofreciendo alternativas. En otra prueba forcé los dos caminos seguidos, read y cat .env, y el log del plugin registró los dos bloqueos.
⚠️ Esto no es un sandbox. Un
cat .e*, un script de Python o unbase64bien colocado se saltan esta comprobación. Un plugin de permisos sube mucho el listón y evita el accidente típico, pero si necesitas aislamiento de verdad, ejecuta el agente en un contenedor.
Push a main, denegado con instrucciones ¶
La segunda regla vive en el mismo hook. Si el comando es un git push y menciona una rama protegida, se deniega:
const pushProtegido = /\bgit\s+push\b/.test(comando) &&
opciones.ramasProtegidas.some((rama: string) => new RegExp(`\\b${rama}\\b`).test(comando))
if (pushProtegido) {
event.effect = "deny"
event.message = `wr.guardia: no se hace push a ${opciones.ramasProtegidas.join(" ni ")}. Crea una rama y abre una PR.`
log(`denegado: ${comando}`)
}
Este es el resultado con un modelo real:

En una prueba anterior, con un remoto que no aceptaba el push, el agente fue más allá: leyó el mensaje, creó una rama nueva por su cuenta e intentó subirla. El agente actúa según lo que pongas en message, así que escríbelo como si fuera un prompt.
¿Y si lanzo un error en execute.before? ¶
Es lo que hacían muchos plugins de la versión 1, y en la 2 también funciona. Lo probé con este hook:
await ctx.tool.hook("execute.before", (event) => {
const input = event.input as { command?: string }
if (event.tool === "shell" && input.command?.includes("rm -rf")) {
throw new Error("Bloqueado: nada de rm -rf")
}
})
El comando no se ejecutó, el modelo recibió el error y la sesión siguió con la siguiente herramienta. La diferencia está en lo que ve el agente y en lo que puedes hacer:
| Aspecto | permission.hook("evaluate") |
throw en execute.before |
|---|---|---|
| Tipo de error que ve el modelo | permission.rejected |
Error genérico (unknown) |
Puede pedir confirmación (ask) |
Sí | No, solo bloquear |
| Datos que recibe | Acción y recursos ya normalizados | Nombre de la tool e input en bruto |
| Puede cambiar el input | No | Sí, reasignando event.input |
| Uso recomendado | Bloquear o pedir permiso | Reescribir argumentos o validar formatos |
Para bloquear, el hook de permisos. Para retocar argumentos, execute.before.
¿Cómo se modifican la shell y el contexto del modelo? ¶
ctx.shell.hook("create.before") cambia cada comando antes de ejecutarlo, y ctx.session.hook("context") cambia cada petición antes de mandarla al modelo. Son los dos hooks para ajustar el entorno sin bloquear nada.
En la shell puedes tocar el comando, el directorio, el timeout, el intérprete y las variables de entorno. wr.guardia marca los comandos que lanza el agente y limita su duración a un minuto:
await ctx.shell.hook("create.before", (event) => {
event.env.OPENCODE_AGENTE = "1"
event.timeout = Math.min(event.timeout, 60_000)
})
Cuando el agente ejecutó echo agente=$OPENCODE_AGENTE, la salida fue agente=1. Esa variable te permite, por ejemplo, que un hook de git o un script de tu proyecto se comporten distinto cuando quien los lanza es el agente. El timeout por defecto que vi en el evento era de 120.000 ms.
El hook context recibe el system prompt, los mensajes, las herramientas visibles y las opciones de generación de cada llamada:
await ctx.session.hook("context", (event) => {
event.system.push({
type: "text",
text: `Responde siempre en ${opciones.idioma}. Si una acción se deniega por wr.guardia, no intentes rodearla.`,
})
})
Lo comprobé desde el lado del proveedor: en una tarea con seis llamadas a herramientas, la instrucción llegó en todas las peticiones al modelo, una por vuelta. Los cambios de este hook solo afectan a la llamada que sale: no se guardan en el historial ni en la configuración. También puedes borrar herramientas para esa llamada (delete event.tools.write) o fijar la temperatura (event.options.temperature = 0.2).
💡 Hay hooks hermanos para las peticiones auxiliares:
compactionpara los resúmenes,titlepara el título de la sesión ygenerateparactx.session.generate. Si quieres que algo se aplique a todas, regístralo en cada uno.
Si estás montando tu propio sistema de trabajo alrededor de un agente, cada domingo compartimos lo que vamos aprendiendo sobre programar con IA y 12 recursos seleccionados. Gratis, desde 2018.
Apúntate gratis →¿Cómo añades herramientas propias al agente? ¶
Con ctx.tool.transform, que te da un editor para añadir, cambiar o quitar herramientas. Cada herramienta lleva un nombre, una descripción para el modelo, un esquema de entrada y una función execute.
wr.guardia añade dos: una para guardar notas del proyecto («aquí usamos pnpm», «los cambios a main van por PR») y otra para listarlas. Como las notas tienen que sobrevivir a la sesión, se guardan con ctx.storage:
await ctx.tool.transform((editor) => { editor.namespace({ name: "guardia", description: "Notas persistentes del proyecto" }) editor.add({ name: "nota_guardar", description: "Guarda una nota del proyecto (decisiones, convenciones, pendientes) para futuras sesiones.", input: { type: "object", properties: { texto: { type: "string", description: "La nota en una frase" } }, required: ["texto"], additionalProperties: false, }, options: { namespace: "guardia", codemode: false }, execute: async (input) => { const { texto } = input as { texto: string } await ctx.storage.set(clave(`notas/${Date.now()}`), { texto, fecha: new Date().toISOString() }) return { content: "Nota guardada" } }, }) editor.add({ name: "notas_listar", description: "Lista las notas guardadas del proyecto.", input: { type: "object", properties: {}, additionalProperties: false }, options: { namespace: "guardia", codemode: false }, execute: async () => { const { entries } = await ctx.storage.scan({ prefix: clave("notas/"), limit: 50 }) if (entries.length === 0) return { content: "No hay notas guardadas." } return { content: entries.map((e) => `- ${(e.value as { texto: string }).texto}`).join("\n") } }, })})El esquema de entrada acepta JSON Schema, como aquí, o un esquema compatible con Standard Schema (Zod, Valibot, Effect Schema). El nombre efectivo de la herramienta junta el namespace y el nombre: guardia_nota_guardar.

Hay dos detalles en ese código que me costaron una prueba fallida cada uno.
Sorpresa 1: las herramientas con namespace van a Code Mode ¶
Mi primera versión no tenía codemode: false. El modelo pedía guardia_nota_guardar y OpenCode respondía «No tool named “guardia_nota_guardar” is currently available». Mirando lo que llegaba al proveedor entendí por qué: las herramientas con namespace entran por defecto en el catálogo de Code Mode. El modelo no las ve como herramientas sueltas, sino descritas en el system prompt, y solo puede usarlas escribiendo JavaScript dentro de la herramienta execute:
// Lo que tiene que escribir el modelo dentro de la tool execute
return await tools.guardia.nota_guardar({ texto: "Usamos pnpm, no npm" })
Funciona y ahorra contexto cuando tienes muchas herramientas, porque el modelo puede encadenar varias en un solo paso. Pero si quieres que el modelo las vea como herramientas normales, pon codemode: false en las opciones.
Sorpresa 2: el storage es del plugin, no del proyecto ¶
La documentación dice que ctx.storage guarda valores JSON duraderos «del plugin». En la práctica, las claves se comparten entre todos los proyectos donde cargas el plugin. Creé un repositorio nuevo, pedí la lista de notas y me salieron las del otro proyecto.
Para notas de proyecto, prefija las claves con el id del proyecto, que tienes en ctx.location.project.id:
// ctx.storage es del plugin, compartido entre proyectos: separa por proyecto
const clave = (k: string) => `${ctx.location.project.id}/${k}`
Con ese cambio, cada repositorio ve solo sus notas. Lo comprobé guardando una nota en cada proyecto y listándolas por separado.
🛡️ Ojo con lo que guardas en
ctx.storage: es persistente y vive en los datos de OpenCode del usuario, no en el repositorio. No guardes ahí secretos ni nada que no quieras que sobreviva al proyecto.
El contador de uso ¶
La sexta pieza usa el hook execute.after, que se ejecuta cuando termina cada herramienta, tanto si sale bien (status: "completed") como si falla (status: "error"):
await ctx.tool.hook("execute.after", async (event) => {
const uso = ((await ctx.storage.get(clave("uso"))) ?? {}) as Record<string, number>
uso[event.tool] = (uso[event.tool] ?? 0) + 1
await ctx.storage.set(clave("uso"), uso)
})
Leer, sumar y escribir no es atómico. Si el modelo lanza varias herramientas en paralelo, algún incremento se puede perder. Para un informe orientativo me vale; para facturar, no.
¿Cómo creas un comando /slash desde un plugin? ¶
Con ctx.command.transform, que registra comandos con una función execute. El comando aparece en el autocompletado de la terminal junto a los de serie:

El /informe de wr.guardia lee los contadores y le pide al modelo que los presente:
await ctx.command.transform((editor) => {
editor.add({
name: "informe",
description: "Muestra cuántas veces se ha usado cada herramienta en este proyecto",
execute: async ({ sessionID, delivery }) => {
const uso = ((await ctx.storage.get(clave("uso"))) ?? {}) as Record<string, number>
const filas = Object.entries(uso).sort((a, b) => b[1] - a[1]).map(([t, n]) => `- ${t}: ${n}`)
await ctx.session.prompt({
sessionID,
delivery,
text: filas.length
? `Presenta este informe de uso de herramientas como una tabla y añade una línea de conclusión:\n${filas.join("\n")}`
: "Dime que todavía no se ha usado ninguna herramienta en este proyecto.",
})
},
})
})
Mi primera versión usaba ctx.session.synthetic, que inyecta un mensaje en la sesión. Al modelo le llegaba, pero la terminal no lo mostraba: escribías /informe y solo veías la respuesta del agente diciendo «Entendido». Con ctx.session.prompt, el patrón que usa la documentación, el informe aparece en la conversación y el modelo lo presenta como una tabla:
await ctx.session.synthetic({ sessionID, text: informe })await ctx.session.prompt({ sessionID, delivery, text: informe })Otra cosa que comprobé: opencode run "/informe" no ejecuta el comando, manda el texto tal cual como prompt. Los comandos de plugin se lanzan desde la interfaz interactiva.
¿Cómo reaccionas a los fallos del proveedor? ¶
Con ctx.session.hook("retry"), que se ejecuta cuando OpenCode ha clasificado un error del proveedor y propone reintentar o no. Puedes cambiar esa decisión y el tiempo de espera.
Con los modelos gratuitos me comí tantos «Rate limit exceeded» que acabé escribiendo esta pieza:
await ctx.session.hook("retry", (event) => {
if (event.error.status === 429) {
event.decision = { retry: true, delay: 10_000 * (event.attempt - 1) }
log(`429 del proveedor, reintento ${event.attempt} en ${10 * (event.attempt - 1)} s`)
}
})
Para verlo funcionar sin depender de la suerte, hice que el proveedor devolviera un 429. Esto es lo que recibió el hook:
attempt=2 status=429 type=provider.rate-limit decision={"retry":true,"delay":2102}
Tres detalles útiles: attempt empieza en 2 en el primer reintento (el intento inicial es el 1), la espera por defecto eran unos 2 segundos, y el máximo de intentos de OpenCode sigue siendo un límite duro que el hook no puede saltarse. Un retraso inválido (negativo, NaN) vuelve al valor calculado por OpenCode.
El flujo completo, con prompts
El plugin pone las vallas; el flujo decide por dónde va el agente
Te llevas un flujo de 7 fases, de la petición a la PR, con una fase entera dedicada a intentar romper lo que ha hecho el agente antes de firmarlo.
Abrir la guía →Incluye la chuleta completa de prompts + repo con la PR terminada
¿Cómo se pasa configuración a un plugin? ¶
Declarando el plugin en opencode.jsonc con la forma de objeto, package más options, y leyendo ctx.options en el setup. Así el mismo plugin sirve para proyectos con reglas distintas:
{
"$schema": "https://opencode.ai/config.json",
"plugins": [
{
"package": "./.opencode/plugins/guardia",
"options": {
"ramasProtegidas": ["main", "produccion"],
"idioma": "español de España"
}
}
]
}
Con esta configuración, el mensaje de denegación pasó a decir «no se hace push a main ni produccion» y la instrucción del contexto llegó al modelo como «Responde siempre en español de España».
Un detalle de mis pruebas: el plugin estaba a la vez dentro de .opencode/plugins/ (que se carga solo) y declarado en opencode.jsonc. El log mostró dos líneas de «loading plugin», pero setup se ejecutó una sola vez y ganaron las opciones de la configuración. Para no depender de ese comportamiento, si un plugin lleva opciones, sácalo de .opencode/plugins/ y decláralo solo en la configuración.
Las rutas relativas se resuelven desde el fichero de configuración que las contiene, y los plugins de los distintos ficheros (~/.config/opencode/, la raíz del proyecto y .opencode/) se suman en lugar de pisarse. También puedes desactivar plugins: "-acme.reviewer" quita uno y "-opencode.provider.*" todos los que empiecen así.
El código completo de wr.guardia ¶
Aquí tienes el plugin entero, tal y como lo dejé después de todas las pruebas. Pasa tsc --strict con los tipos de @opencode/plugin@2.0.31:
import { Plugin } from "@opencode/plugin"// Opciones con valores por defecto. Se pueden cambiar desde opencode.jsoncconst DEFAULTS = { secretos: [".env", ".env.local", ".env.production", "id_rsa"], ramasProtegidas: ["main", "master"], idioma: "español",}const esSecreto = (ruta: string, secretos: string[]) => { const nombre = ruta.split("/").pop() ?? ruta return secretos.includes(nombre)}export default Plugin.define({ id: "wr.guardia", async setup(ctx) { const opciones = { ...DEFAULTS, ...ctx.options } const log = (msg: string) => console.error(`[wr.guardia] ${msg}`) // ctx.storage es del plugin, compartido entre proyectos: separa por proyecto const clave = (k: string) => `${ctx.location.project.id}/${k}` log(`activo en ${ctx.location.directory}`) await ctx.permission.hook("evaluate", (event) => { const tocaSecreto = event.resources.some((r) => esSecreto(r, opciones.secretos)) if (["read", "edit"].includes(event.action) && tocaSecreto) { event.effect = "deny" event.message = "wr.guardia: los ficheros de secretos no se tocan. Pide al usuario el valor que necesites." log(`denegado ${event.action} sobre ${event.resources.join(", ")}`) return } if (event.action === "shell") { const comando = event.resources.join(" ") // El agente puede intentar leer el secreto con cat, grep o sed const palabras = comando.split(/[\s'"=<>|;&()]+/) if (palabras.some((p) => esSecreto(p, opciones.secretos))) { event.effect = "deny" event.message = "wr.guardia: tampoco desde la shell. Los ficheros de secretos no se leen." log(`denegado: ${comando}`) return } const pushProtegido = /\bgit\s+push\b/.test(comando) && opciones.ramasProtegidas.some((rama: string) => new RegExp(`\\b${rama}\\b`).test(comando)) if (pushProtegido) { event.effect = "deny" event.message = `wr.guardia: no se hace push a ${opciones.ramasProtegidas.join(" ni ")}. Crea una rama y abre una PR.` log(`denegado: ${comando}`) } } }) await ctx.shell.hook("create.before", (event) => { event.env.OPENCODE_AGENTE = "1" event.timeout = Math.min(event.timeout, 60_000) }) await ctx.session.hook("context", (event) => { event.system.push({ type: "text", text: `Responde siempre en ${opciones.idioma}. Si una acción se deniega por wr.guardia, no intentes rodearla.`, }) }) await ctx.session.hook("retry", (event) => { if (event.error.status === 429) { event.decision = { retry: true, delay: 10_000 * (event.attempt - 1) } log(`429 del proveedor, reintento ${event.attempt} en ${10 * (event.attempt - 1)} s`) } }) await ctx.tool.hook("execute.after", async (event) => { const uso = ((await ctx.storage.get(clave("uso"))) ?? {}) as Record<string, number> uso[event.tool] = (uso[event.tool] ?? 0) + 1 await ctx.storage.set(clave("uso"), uso) }) await ctx.tool.transform((editor) => { editor.namespace({ name: "guardia", description: "Notas persistentes del proyecto" }) editor.add({ name: "nota_guardar", description: "Guarda una nota del proyecto (decisiones, convenciones, pendientes) para futuras sesiones.", input: { type: "object", properties: { texto: { type: "string", description: "La nota en una frase" } }, required: ["texto"], additionalProperties: false, }, options: { namespace: "guardia", codemode: false }, execute: async (input) => { const { texto } = input as { texto: string } await ctx.storage.set(clave(`notas/${Date.now()}`), { texto, fecha: new Date().toISOString() }) return { content: "Nota guardada" } }, }) editor.add({ name: "notas_listar", description: "Lista las notas guardadas del proyecto.", input: { type: "object", properties: {}, additionalProperties: false }, options: { namespace: "guardia", codemode: false }, execute: async () => { const { entries } = await ctx.storage.scan({ prefix: clave("notas/"), limit: 50 }) if (entries.length === 0) return { content: "No hay notas guardadas." } const lista = entries.map((e) => `- ${(e.value as { texto: string }).texto}`).join("\n") return { content: lista } }, }) }) await ctx.command.transform((editor) => { editor.add({ name: "informe", description: "Muestra cuántas veces se ha usado cada herramienta en este proyecto", execute: async ({ sessionID, delivery }) => { const uso = ((await ctx.storage.get(clave("uso"))) ?? {}) as Record<string, number> const filas = Object.entries(uso).sort((a, b) => b[1] - a[1]).map(([t, n]) => `- ${t}: ${n}`) await ctx.session.prompt({ sessionID, delivery, text: filas.length ? `Presenta este informe de uso de herramientas como una tabla y añade una línea de conclusión:\n${filas.join("\n")}` : "Dime que todavía no se ha usado ninguna herramienta en este proyecto.", }) }, }) }) return () => log("descargado") },})Cópialo en .opencode/plugins/guardia/index.ts (o en otra carpeta declarada en opencode.jsonc si vas a pasarle opciones) y arranca OpenCode en ese proyecto.
¿Cómo pruebas y depuras un plugin? ¶
Arranca OpenCode con --standalone --print-logs, provoca a propósito lo que el plugin debería impedir y mira el log. Que OpenCode arranque sin errores no te dice nada de si tus reglas funcionan. Lo que me sirvió:
- Logs con prefijo. Todo
console.errordel plugin sale entre los logs del servidor. Con un prefijo ([wr.guardia]) lo filtras congrep. - Un plugin espía. Un plugin de diez líneas que registra cada hook con
JSON.stringify(event)te enseña la forma real de los eventos. Así descubrí quereadrecibe{ path }y la shell{ command, workdir }. - Pruebas de ataque. Pide al agente justo lo que el plugin debería impedir, con distintas formulaciones. La versión con
grepdel.envla encontré así. - Recarga en caliente. OpenCode vigila las carpetas de configuración. Cambié el código de
/informecon la terminal abierta y la siguiente vez que lo usé ya ejecutaba la versión nueva, sin reiniciar. Si cambias dependencias que no están en carpetas vigiladas, reinicia conopencode service restart. - Comprobación de tipos.
tsc --noEmit --strictcon@opencode/pluginen la misma versión que tu OpenCode detecta nombres de hooks mal escritos y campos que no existen.
Para las pruebas que no dependían del criterio del modelo (forzar un 429, encadenar varias herramientas en un orden exacto) usé un truco: un servidor local compatible con la API de OpenAI que devuelve llamadas a herramientas programadas, registrado como proveedor desde otro plugin global:
import { Model, Plugin, Provider } from "@opencode/plugin"
// Plugin de pruebas: añade un proveedor que apunta a un servidor local
export default Plugin.define({
id: "test.mock-provider",
async setup(ctx) {
const providerID = Provider.ID.make("mock")
await ctx.provider.transform((editor) => {
editor.add({
info: {
...Provider.Info.empty(providerID),
name: "Mock",
activation: "enabled",
package: "@opencode/ai/providers/openai-compatible",
settings: { baseURL: "http://127.0.0.1:8787/v1" },
},
models: [{ ...Model.Info.default(providerID, Model.ID.make("mock")), name: "Mock" }],
})
})
},
})
Con opencode run -m mock/mock tienes pruebas repetibles y gratis. De paso, es un ejemplo de lo que permite ctx.provider.transform: conectar OpenCode a un modelo propio sin tocar la configuración global.
⚠️ En mis pruebas, lanzar un segundo
opencode --standalonemientras otro seguía abierto, o justo después de cerrarlo, a veces dejaba el servidor colgado al arrancar, sin un solo error. Sirunno responde, cierra las demás instancias, espera unos segundos y vuelve a probar.
Probar tus protecciones como si fueras el agente es de lo que más estamos aprendiendo en la comunidad. Cada domingo lo compartimos con más de 7.200 developers en la newsletter.
Suscríbete gratis →¿Cómo publicas tu plugin en npm? ¶
Un plugin publicado es un paquete con "type": "module", un exports que apunta al fichero con el Plugin.define y @opencode/plugin como dependencia. El manifiesto mínimo según la documentación:
{
"name": "opencode-guardia",
"version": "1.0.0",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"dependencies": {
"@opencode/plugin": "2.0.31"
}
}
Quien lo use lo instala con opencode plugin add opencode-guardia@1.0.0 o lo declara en la clave plugins de su configuración. opencode plugin add acepta también repositorios de git (github:usuario/repo, ramas, tags y subcarpetas con ::path:), así que puedes repartirlo en tu equipo sin pasar por npm.
Tres consejos de la documentación que te ahorran disgustos:
- Fija la versión de
@opencode/plugina la de OpenCode que pruebas y publica una versión nueva de tu plugin cuando adoptes la API de una versión posterior. - Prueba el paquete instalado, no solo la copia enlazada de tu espacio de trabajo.
- Si tienes usuarios en la versión 1, puedes servir las dos APIs desde el mismo paquete: exportas un objeto con
...Plugin.define({ id, setup })para la 2 y una funciónserver()para la 1. Cada versión lee su parte e ignora la otra, pero no traduce hooks: tienes que escribir las dos implementaciones.
Una vez publicado, si quieres ver qué hay ya hecho y qué conviene instalar, tienes el mapa en los mejores plugins para OpenCode.
¿Qué límites tiene la API de plugins de OpenCode 2? ¶
La API da mucho juego, pero no sustituye ni a un sandbox ni a un fork. Estos son los límites que conviene conocer antes de apostar por ella:
- No es aislamiento. Un hook de permisos filtra lo que el agente pide, no lo que un proceso puede hacer. Hemos visto cómo un
grepesquiva una regla mal planteada. - El hook
promptno puede rechazar mensajes. Transforma la entrada, pero no tiene una API para decir «este mensaje no pasa». - Los cambios de
contextson efímeros. Afectan a la llamada que sale y no quedan en el historial. - Algunos hooks son experimentales, como los de WebSocket (
experimental.ws.*), y sus nombres o formas pueden cambiar. - No todo cabe en un plugin. Hay quien ha intentado convertir los cambios de su fork de la versión 1 en un único plugin de la 2 y no ha podido hacer todo lo que necesitaba.
- Los plugins de la versión 1 no se ejecutan y OpenCode no lo avisa en la interfaz. Lo explicamos en detalle en la guía de migración a OpenCode 2.
Aun con esos límites, con 142 líneas tienes un agente que no lee tus secretos, no sube a main, recuerda decisiones entre sesiones y te cuenta qué ha hecho. Es más o menos lo que le explicas a alguien nuevo en el equipo su primer día.
¿Qué es lo primero que le prohibirías tú?
TL;DR ¶
- 🔌 Un plugin de OpenCode 2 es un
Plugin.define({ id, setup(ctx) })en.opencode/plugins/: sin compilar, sin publicar y sin instalar dependencias. - 🛡️ Para bloquear acciones usa
ctx.permission.hook("evaluate")y vigila también la acciónshell: el agente leyó un.envprotegido congrep. - 🧰 Las herramientas con namespace van a Code Mode por defecto; usa
codemode: falsesi quieres que el modelo las vea como herramientas normales. - 💾
ctx.storagees del plugin y se comparte entre proyectos: prefija las claves conctx.location.project.id. - 🧪 Prueba con
--standalone --print-logs, ataca tus propias reglas y comprueba los tipos con@opencode/pluginen tu misma versión.
Preguntas frecuentes ¶
¿Cómo se crea un plugin en OpenCode 2? ¶
Crea un fichero .ts o .js en .opencode/plugins/ que exporte por defecto Plugin.define({ id, setup }), importando Plugin desde @opencode/plugin. OpenCode lo carga solo al arrancar en ese proyecto y llama a setup con un contexto desde el que registras hooks, herramientas y comandos.
¿Dónde se guardan los plugins de OpenCode? ¶
En .opencode/plugins/ para un proyecto y en ~/.config/opencode/plugins/ para todos tus proyectos. Los paquetes de npm, las rutas a otras carpetas y los plugins con opciones se declaran en la clave plugins de opencode.jsonc. Una carpeta plugins/ en la raíz del proyecto no se carga sola.
¿Necesito instalar @opencode/plugin para que funcione un plugin local? ¶
No para que funcione: OpenCode resuelve el import al cargar el plugin. Sí te conviene instalarlo como dependencia de desarrollo, con la misma versión que tu OpenCode, para tener tipos, autocompletado y comprobación con tsc.
¿Funcionan los plugins de OpenCode 1 en OpenCode 2? ¶
No. La versión 2 tiene una API nueva basada en Plugin.define y un contexto, y los plugins de la versión 1 se descartan con un aviso en el log. Hay que reescribirlos, aunque un mismo paquete puede exportar las dos versiones a la vez.
¿Cómo bloqueo un comando peligroso desde un plugin de OpenCode? ¶
Registra ctx.permission.hook("evaluate"), comprueba si event.action es shell y si el comando de event.resources coincide con lo que quieres bloquear, y pon event.effect = "deny" con un event.message que explique la alternativa. El modelo recibe ese mensaje como error de la herramienta.
¿Qué diferencia hay entre el hook de permisos y execute.before? ¶
El hook de permisos recibe la acción y los recursos ya normalizados, puede devolver allow, ask o deny, y el modelo ve un error de tipo permission.rejected. execute.before recibe el input en bruto de la herramienta y sirve para modificarlo; si lanzas un error también bloqueas, pero solo como error genérico.
¿Cómo añado una herramienta propia al agente de OpenCode? ¶
Con ctx.tool.transform((editor) => editor.add({ name, description, input, execute })). El input admite JSON Schema o un esquema compatible con Standard Schema, como Zod. Si usas un namespace, la herramienta va a Code Mode salvo que pongas codemode: false en sus opciones.
¿Dónde guarda datos un plugin de OpenCode? ¶
En ctx.storage, un almacén clave-valor de JSON persistente con get, set, remove y scan por prefijo. Está asociado al plugin y se comparte entre proyectos, así que para datos de un proyecto concreto conviene prefijar las claves con ctx.location.project.id.
¿Cómo depuro un plugin de OpenCode 2? ¶
Lanza opencode run --standalone --print-logs o la interfaz con --standalone y escribe en el log con console.error y un prefijo propio. Un plugin espía que registre cada evento te enseña su forma real, y los cambios en .opencode/plugins/ se recargan en caliente.
¿Un plugin de OpenCode sirve como sandbox? ¶
No. Un plugin filtra las acciones que pide el agente, pero no aísla procesos: hay muchas formas de llegar a un fichero desde la shell. Úsalo para evitar accidentes y dejar rastro, y ejecuta el agente en un contenedor o una máquina virtual si necesitas aislamiento real.
Fuentes ¶
- Documentación de plugins de OpenCode 2: ciclo de vida, transforms, API y hooks
- Configuración y gestión de plugins: rutas, opciones, activar y desactivar,
opencode plugin add - Plugins de la interfaz de terminal:
@opencode/plugin/tui - Migración de plugins desde la versión 1
- Paquete @opencode/plugin en npm: tipos de la versión 2.0.31 usados en este artículo
- Paquete @opencode/cli en npm: OpenCode 2.0.31, la versión probada
- Repositorio de OpenCode
🧨 Última oportunidad para recibir la dinamita que mereces sobre programación con IA el próximo domingo: Suscríbete gratis a Web Reactiva en https://webreactiva.com/newsletter
12 recursos para developers cada domingo en tu bandeja de entrada
Además de una skill práctica bien explicada, trucos para mejorar tu futuro profesional y una pizquita de humor útil para el resto de la semana. Gratis.