Cómo crear un plugin propio para dsh de DeepSeek Harness
Crea un plugin dsh desde una carpeta vacía: configura package.json, añade el archivo de parche, monta una herramienta real e implementa los dos hooks necesarios.
Qué es realmente un plugin de dsh
Un plugin de dsh es un paquete npm que exporta una función apply e incluye un archivo YAML pequeño que indica a DeepSeek Harness que debe cargarlo. No hay que aprender primero un SDK de plugins independiente. dsh es una aplicación de Cordis, y «todo es un plugin» es literal: el registro de herramientas, el bucle del agente, el almacén de sesiones y el servidor web son filas del mismo árbol de plugins al que se incorpora el paquete.
Cordis es un framework general de composición. Se desarrolló de forma independiente y se ha utilizado durante años como base del framework de chatbot Koishi. Gestiona la carga y descarga, y resuelve las dependencias entre plugins. No sabe nada sobre agentes. Todo lo relacionado con agentes procede de los paquetes del harness que se ejecutan sobre él. Por eso la estructura del plugin que se muestra a continuación es tan pequeña. La mayor parte de lo que obtiene procede de la herencia.
Un plugin tiene dos partes. La parte del host se ejecuta en Node, registra herramientas y listeners de eventos, y puede proporcionar sus propios servicios. La parte del navegador se ejecuta dentro de la interfaz web y registra espacios de interfaz. El primer plugin casi siempre sólo se ejecuta en el host, así que trate la parte del navegador como opcional hasta que la necesite.
Esta guía se redactó para @deepseek-ai/dsh versión 0.1.0-rc.7, la etiqueta npm latest del 19 de agosto de 2026. dsh es una versión preliminar para desarrolladores y su propio README indica que habrá cambios incompatibles. Cada nombre de clave que aparece a continuación se obtuvo de la documentación upstream y del repositorio en esa fecha. Cuando se volvió a comprobar esta guía el 12 de octubre de 2026, la etiqueta npm latest era 0.2.0-rc.2, y las secciones siguientes señalan dónde se comporta de forma diferente esa versión. Vuelva a comprobar los nombres de las claves antes de depender de ellos, porque una API preliminar cambia los nombres de los campos entre versiones candidatas. Si el harness todavía no se está ejecutando, configúrelo primero con DeepSeek Harness en un VPS y la clave de API y la configuración del modelo de dsh; después, vuelva aquí.
Cargue un solo archivo de prueba antes de empaquetar nada
Empezar por el empaquetado es la forma más lenta de aprender esto. Cargue un solo archivo, confirme que el runtime llama a su código y, después, empaquételo.
Cree una carpeta fuera del checkout del harness y coloque un archivo en ella.
import type { Context } from '@deepseek-ai/cordis'
export const name = 'hello-plugin'
export function apply(ctx: Context) {
console.log('[hello-plugin] plugin loaded')
}export const name son metadatos que se usan para identificar el plugin en los diagnósticos. apply es el contrato completo: Cordis lo llama una vez y le pasa un contexto limitado a su plugin. Todo lo que registre en ese contexto se deshace automáticamente cuando se elimina el plugin.
Junto a él, escriba cordis.yml.
- insert:
- id: hello
name: '/absolute/path/to/scratch-plugin/hello.ts'Ahora inicie un perfil con ese archivo superpuesto.
dsh web --patch ./scratch-plugin/cordis.ymlSi dsh no está en su PATH, npx @deepseek-ai/dsh web --patch ./scratch-plugin/cordis.yml realiza la misma función. Esa vía con npx puede proporcionarle un release candidate antiguo en caché en lugar de la versión descrita en esta guía. Por tanto, si el harness rechaza directamente un flag documentado, consulte las soluciones para los errores de instalación y versión de dsh antes de empezar a dudar de su propio archivo. Debería ver [hello-plugin] plugin loaded en la terminal desde la que inició dsh. Si no aparece nada, la fila no se resolvió.
El campo name acepta un nombre de paquete npm o una ruta del sistema de archivos, y la documentación upstream indica que la ruta debe ser absoluta. Un ./hello.ts relativo es lo primero que debe comprobar cuando un plugin de prueba no produce ninguna salida. Lo segundo es la extensión del archivo. El flujo documentado se ejecuta como pnpm dsh web --patch ... desde un clon del repositorio del harness, donde las entradas de TypeScript se cargan mediante tsx. Si obtuvo dsh desde npm, indique en la fila un archivo JavaScript simple o compile primero el archivo.
--patch es un flag del launcher y su overlay se aplica al final, después de todos los bundles y de su propio parche de perfil. Por tanto, un overlay de prueba siempre tiene prioridad, que es exactamente lo que necesita mientras itera.
Escriba la herramienta más pequeña que haga algo útil
Una línea de registro demuestra que el complemento se carga. Una herramienta demuestra que el complemento forma parte del agente.
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'greet-tool'
export const inject = ['tools']
export function apply(ctx: Context) {
ctx.tools.register(defineTool({
name: 'greet',
description: 'Greet someone by name.',
parameters: {
name: { type: 'string', required: true, description: 'The name to greet' },
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute(args) {
return `Hello, ${args.name}!`
},
}))
}export const inject = ['tools'] es la línea que suele omitirse. Las entradas de una configuración de Cordis se inician de forma simultánea, por lo que la posición de una fila en el archivo no garantiza ningún orden de carga. El orden procede de las dependencias declaradas. inject indica a Cordis que espere hasta que exista ctx.tools antes de llamar a su apply. Sin esto, el código puede ejecutarse cuando el registro aún no esté disponible para registrar la herramienta.
El resto del objeto define el contrato que ve el modelo. parameters es el esquema de argumentos y execute recibe los argumentos que ya se han validado y analizado según ese esquema. output.schema describe el valor que devuelve execute, mientras que render convierte ese valor en los bloques de contenido que lee el modelo. Mantener ambos aspectos separados permite que la interfaz muestre una cosa mientras el modelo lee otra.
Inicie el perfil y pida al asistente que salude a alguien por su nombre. La respuesta vuelve a través de su execute. El registro mediante ctx se puede revertir, por lo que al liberar el complemento la herramienta se anula automáticamente. Para cualquier recurso que Cordis no pueda conocer, como un socket o un descriptor de archivo, llame a ctx.effect() y proporciónele una función de liberación.
Los dos puntos de extensión que realmente usa el primer plugin
La lista completa de puntos de extensión es larga. Dos de ellos cubren casi todos los primeros plugins.
Los eventos de conversación forman el flujo persistente registrado. En 0.1.0-rc.7, sus nombres eran session/event, turn/start, turn/end, step/start, step/end, user/message, assistant/message, assistant/chunk, tool/call y tool/result. La versión 0.2.0-rc.2 no tiene assistant/chunk. El streaming en directo pasó a agent/assistant-stream. Sus tramas permanecen en el proceso y nunca se registran. Un nuevo evento persistente, assistant/attempt, conserva los intentos fallidos, reintentados y cancelados. Se conecta un listener normal.
ctx.on('tool/call', (payload) => {
console.log('[my-plugin] tool/call', JSON.stringify(payload))
})Imprima el payload una vez y léalo. No copie los nombres de los campos del payload de ninguna guía, incluida esta, porque la forma del payload es la parte de una API preliminar que más cambia.
El segundo punto de extensión es el waterfall. agent/pre-step, agent/request, agent/request-error, llm/stream y los eventos tools/* son waterfalls, y un listener de waterfall tiene una firma diferente. Recibe un callback next, y la cadena sólo continúa si lo invoca.
ctx.on('agent/request', async (payload, next) => {
const startedAt = Date.now()
const downstream = await next()
console.log('[my-plugin] model request took', Date.now() - startedAt, 'ms')
return downstream
})Si olvida await next(), no ha añadido un hook. Ha sustituido la llamada al modelo por nada y el agente se detiene ahí, porque el cortocircuito es el comportamiento previsto para un plugin de gateway que rechaza una petición de forma intencionada. Esta diferencia causa la mayoría de las confusiones al crear el primer plugin. Escriba la llamada next() antes de escribir cualquier otra cosa a su alrededor.
agent/request envuelve la llamada al modelo. Su payload contiene el agente que realiza la llamada, el número del turno abierto, el paso al que pertenece la petición y la señal de cancelación de ese turno. Por eso es el punto adecuado para un registrador de peticiones o un limitador de tasa. Los waterfalls tools/* tienen la misma forma un nivel más abajo. tools/pre-execute permite, rechaza o solicita aprobación antes del envío. tools/execute envuelve el envío. tools/post-execute puede sustituir o bloquear el resultado normalizado. tools/result sólo observa el resultado congelado.
Empaquetarlo como un paquete que otras personas puedan instalar
Un bundle es un paquete de npm cuyo package.json declara un campo dsh.bundle que apunta a su archivo de parche. Esa declaración es la única diferencia entre un archivo provisional y algo que se puede instalar.
{
"name": "dsh-plugin-hello",
"version": "0.1.0",
"type": "module",
"main": "lib/index.js",
"files": ["lib", "cordis.patch.yml", "README.md", "LICENSE"],
"engines": { "node": "^22.19 || >=24", "dsh": ">=0.1.0-rc.6" },
"dsh": { "bundle": { "patch": "./cordis.patch.yml" } },
"keywords": ["dsh-plugin", "deepseek-harness"],
"scripts": { "build": "tsdown", "prepare": "pnpm run build" },
"exports": {
".": { "types": "./lib/index.d.ts", "default": "./lib/index.js" },
"./cordis.patch.yml": "./cordis.patch.yml",
"./package.json": "./package.json"
}
}El cordis.patch.yml que se encuentra junto a él es breve.
- insert:
- id: dsh-plugin-hello
name: dsh-plugin-helloLa fila name es el nombre del paquete, por lo que esas dos cadenas deben coincidir. La fila id es el identificador que una capa posterior utiliza cuando un usuario reemplaza tu configuración. Elige un valor estable y no lo reutilices para otro plugin.
files debe incluir cordis.patch.yml. Si se omite, el tarball publicado contiene un dsh.bundle.patch que apunta a un archivo que nunca se empaquetó. Por tanto, el paquete se instala, pero no aporta nada al árbol.
Instálalo en un perfil desde el directorio que contiene la carpeta de tu plugin.
dsh plugin --profile demo add ./dsh-plugin-hello
dsh --profile demo --dump-config
dsh --profile demodsh plugin --profile <name> reenvía el resto de sus argumentos a pnpm dentro del directorio de ese perfil, por lo que add y remove se comportan como en pnpm. Desinstala con dsh plugin --profile demo remove dsh-plugin-hello. Los perfiles web, headless, sdk, sdk-minimal y acp incluidos se crean a partir de plantillas la primera vez que se usan. En 0.2.0-rc.2, cualquier otro nombre se crea de una de estas dos formas. dsh plugin lo inicia sólo con el bundle base. dsh --profile <name> --from-default-profile <template> copia la lista de bundles de una plantilla incluida. Si intentas iniciar un perfil personalizado que todavía no existe, se produce un error con una indicación para ejecutar dsh plugin.
Por qué falta su fila en el árbol compuesto
La composición comienza con una lista de entradas vacía y apila las capas en un orden fijo. Primero, cada bundle incluido en dsh.profile.bundles del perfil, en el orden en que aparece. Después, el cordis.patch.yml propio del perfil. Luego, $DSH_HOME/cordis.patch.yml. Por último, cualquier overlay --patch indicado en la línea de comandos. Las capas posteriores sustituyen las filas anteriores por id.
Los perfiles se encuentran en $DSH_HOME/profiles/<name>. Un directorio de perfil contiene un package.json con el manifiesto dsh.profile y su lista ordenada de bundles, además del archivo de parches propio del usuario. Los nombres de los bundles se resuelven primero desde la instalación de dsh y después desde node_modules del perfil. Ahí es donde pnpm coloca un plugin externo al árbol.
dsh --profile demo --dump-config muestra el árbol completamente compuesto sin iniciar nada. Esa salida separa las dos vías de diagnóstico. Si falta el id de su fila, el problema está en la composición: un nombre no se resuelve o nunca se incluyó un archivo de parches. Si la fila está presente y no ocurre nada, el problema está en su código. Responda primero a esa pregunta y evitará la mayor parte de las conjeturas.
Dónde aparecen realmente los errores de carga
Un error lanzado dentro de apply es evidente. El proceso termina con esa excepción y se muestra un seguimiento de pila que señala la línea del código propio.
Los errores de resolución no son visibles de forma directa. El cargador informa mediante el registrador de Cordis sobre los módulos que no puede resolver, en lugar de provocar un fallo del proceso. El tutorial original advierte que estos mensajes pueden perderse durante el arranque porque se emiten antes de asociar los exportadores de consola. Por eso, un error tipográfico en una ruta puede parecer exactamente un plugin que se cargó pero no hizo nada. Esta es la razón por la que conviene ejecutar la comprobación --dump-config anterior antes de revisar el código.
Mantenga un console.log como primera instrucción de apply durante el desarrollo. Si no aparece, sabrá qué parte del problema tiene, y podrá eliminarlo después sin consecuencias. En un servidor, ejecute el arnés en primer plano mientras realiza cambios, en lugar de ejecutarlo mediante un gestor de servicios. Así, la salida del cargador llegará al terminal, en vez de quedar en un journal que después tendría que consultar.
Iterar sin reiniciar todo
En 0.1.0-rc.7, la respuesta para la parte del host era sencilla: reiniciar. El paquete de la aplicación web de esa versión se distribuía con su fila compartida de recarga en caliente de módulos deshabilitada, y el archivo incluía una nota que indicaba que se volvería a habilitar cuando se hubiera probado el ciclo de vida de la recarga. En 0.2.0-rc.2, esa sobreescritura ya no existe. El paquete base monta @deepseek-ai/dsh-hmr en todos los perfiles con root: [], bajo el comentario "La configuración del perfil se recarga de forma predeterminada; las raíces de módulos son opcionales". Los cambios válidos en el perfil y en los archivos de parches del directorio personal ahora se vuelven a aplicar sin reiniciar. El código fuente del propio plugin todavía no se supervisa, salvo que añada su directorio a root de esa fila. Añadir, eliminar o actualizar un paquete sigue requiriendo un reinicio. La cadena de recarga del cliente siempre está montada, pero permanece inactiva hasta que un observador de compilación vuelve a escribir los paquetes del cliente, por lo que tampoco hace nada para la parte de Node.
Haga que el reinicio sea rápido en lugar de depender de una recarga que debe habilitar de forma explícita. Mantenga el plugin en un solo archivo. Cárguelo con --patch en lugar de instalarlo en un perfil, para que no haya ningún paso de compilación ni de pnpm entre una edición y una ejecución. Registre todo mediante ctx para que un reinicio no deje una herramienta duplicada ni un listener obsoleto. Encapsule todo lo que reserve por su cuenta en ctx.effect() con un disposer real, porque el síntoma habitual de que falta un disposer es que la segunda ejecución falla en un puerto que la primera todavía mantiene ocupado.
Si desarrolla contra un harness que se ejecuta en un servidor en lugar de en su portátil, nada de lo anterior cambia, pero la vinculación de la interfaz web sí es importante. Un inicio local sirve la interfaz web en http://127.0.0.1:3080 y la abre en el navegador predeterminado, o no abre el navegador si pasa --no-open. Un inicio mediante SSH sólo muestra la URL del host, por lo que no se abre nada en su equipo. La vinculación de loopback en el puerto 3080 explica por qué la página no se abre en ese caso y qué debe hacer al respecto.
La parte del navegador y hasta dónde confiar en ella
Añádelo sólo cuando tu plugin necesite su propia interfaz. Se declara en el mismo campo dsh que el bundle.
{
"dsh": {
"client": {
"platform": "web",
"inject": [],
"external": [],
"immediately": false
}
},
"exports": {
".": "./src/index.ts",
"./client": "./src/client/apply.ts",
"./package.json": "./package.json"
}
}"platform": "web" es obligatorio, y el analizador genera un error si el paquete no tiene una exportación ./client. Por tanto, el mapa de exportaciones forma parte del manifiesto y no es un elemento opcional. La entrada del cliente recibe el Context de Cordis ampliado con el tipo del runtime del cliente, y todos los registros se realizan dentro de apply mediante ctx.slots.register. Allí no se permiten efectos secundarios a nivel de módulo.
import type { Context } from 'cordis'
import type { DshClientContext } from '@deepseek-ai/dsh-client-runtime'
export async function apply(ctx: Context & DshClientContext) {
ctx.slots.register({ name: 'domain.entry.slot' }, MyComponent)
}Conviene conocer dos detalles antes de empezar. inject en el manifiesto del cliente sirve como documentación, no para programar la activación: registra las dependencias del paquete y no controla su orden de activación. external declara solicitudes de módulos fuera de la base establecida, pero las notas actuales del cliente indican que no es un mecanismo de dependencias para un plugin de funcionalidad y lo reservan para infraestructura, transporte y código de ensamblado generado. Déjalo vacío en un plugin de funcionalidad. Esta es la parte que cambia con mayor rapidez en la vista previa, así que lee packages/client/AGENTS.md en el repositorio del arnés el día que escribas el código, no el día que leas una guía al respecto.
Publica el plugin y explica qué toca
Añadir el tema dsh-plugin a un repositorio de GitHub lo incluye en la lista que consultan las personas cuando buscan plugins. Eso implica confiar en un proyecto desconocido y conlleva obligaciones. Esas obligaciones reflejan lo que nuestra guía para evaluar un plugin de dsh antes de instalarlo indica que se debe comprobar, así que redactar el plugin conforme a esa lista es la forma más sencilla de cumplirla.
- Fija las dependencias. Un rango con acento circunflejo en una dependencia transitiva puede hacer que un paquete que era seguro la semana pasada ejecute código diferente esta semana. Ese es el mecanismo exacto de los ataques a la cadena de suministro de npm contra un servidor.
- Haz que el manifiesto indique qué tocas. La lista
injectes un resumen honesto y legible por máquina de los servicios del harness que utilizas. Un revisor la lee en segundos y se forma una opinión a partir de ella. - No hagas llamadas de red silenciosas. Si una herramienta llama a una API, indica el host en el README y permite configurar el endpoint. Las personas que auditan estos proyectos retirarán de la lista un plugin que se conecte a un servidor que no mencionó.
- Mantén
filesajustado. Publicar una carpeta de trabajo completa es la forma de que un archivo de credenciales olvidado llegue al registro. - Proporciona a los instaladores de git un script
prepareque compile sin asumir dependencias exclusivas del desarrollo, e indica en el README que deben incluir explícitamente esa compilación enpnpm-workspace.yamldel perfil. - Fecha el README con respecto al candidato de lanzamiento contra el que compilaste y realizaste las pruebas. Los lectores de una API preliminar necesitan saber qué versión utilizaste.
Para ver desde fuera cómo es un plugin terminado, lee los plugins de dsh que merece la pena instalar y observa qué explica cada README antes de instalarlo. Si has escrito extensiones para otro agente, cómo se estructuran los plugins de Claude Code ofrece una comparación útil. El harness te proporciona un grafo de objetos activo y un registro reversible. Eso ofrece más capacidad que un manifiesto de archivos y también implica más responsabilidad.
FAQ
¿Necesito publicar en npm para escribir un plugin de dsh?
No. Basta con una ruta del sistema de archivos en una superposición cordis.yml, cargada con dsh web --patch ./scratch-plugin/cordis.yml, para ejecutar su propio código dentro del harness. La ruta debe ser absoluta. El empaquetado sólo es necesario cuando otra persona instala el plugin. Incluso en ese caso, puede instalar una carpeta local con dsh plugin --profile demo add ./my-plugin para probar la forma empaquetada sin acceder a un registro.
¿Por qué se carga mi plugin, pero la herramienta nunca aparece?
Ejecute primero dsh --profile demo --dump-config. Si el id de su fila no aparece en esa salida, el plugin nunca se montó y la causa está en la composición, no en el código. Si la fila está presente, compruebe export const inject = ['tools']. Las entradas de una configuración de Cordis se inician de forma concurrente, por lo que el orden de los archivos no determina el orden de carga. Sin esa declaración, Cordis no espera al registro de herramientas, y su apply puede ejecutarse cuando ctx.tools todavía no está disponible para registrarse.
¿Cuál es la diferencia entre cordis.yml y cordis.patch.yml?
cordis.yml es una lista completa de entradas. cordis.patch.yml es una capa que se aplica sobre una de ellas y que selecciona las filas por id para insertar otras nuevas o reemplazar una configuración existente. Un bundle apunta a su propio archivo de parches mediante dsh.bundle.patch en package.json. Las capas se aplican en un orden fijo: primero, cada bundle en el orden indicado por el perfil; después, el archivo de parches del perfil; luego, $DSH_HOME/cordis.patch.yml; y, por último, cualquier superposición --patch. Las capas posteriores tienen prioridad.
¿Puedo recargar en caliente un plugin de dsh mientras el agente está en ejecución?
No en 0.1.0-rc.7, cuyo bundle web incluía deshabilitada la fila compartida de recarga de módulos en caliente. En 0.2.0-rc.2, esa fila se monta en todos los perfiles, pero de forma predeterminada sólo recarga la configuración del perfil. Las raíces de los módulos se habilitan de forma explícita mediante su lista root, por lo que el código del plugin todavía requiere un reinicio, a menos que añada una. En cualquier caso, diseñe el plugin para reiniciarse rápidamente: un solo archivo, cargado mediante --patch y sin paso de compilación, con todos los registros realizados mediante ctx para que no queden recursos entre una ejecución y la siguiente. Use ctx.effect() con un disposer para los recursos que Cordis no pueda limpiar por sí solo.