Un archivo, un panel: guía práctica del SDK de plugins de Hermes Desktop

Te pasas el día en la ventana de Hermes Desktop y, tarde o temprano, aparece un pequeño deseo: un panelito que muestre si los trabajos de cron de hoy se ejecutaron de verdad, o un botón en la barra de estado que siga los registros cuando lo pulsas. Hasta hace poco ese deseo tenía dos respuestas: vivir sin él, o irte a leer el código fuente de la aplicación, que es una base de código grande y poco amable con terceros.
Ahora hay una tercera opción: escribir un archivo, dejarlo en un directorio y tenerlo funcionando unos segundos después. Cada superficie de la ventana de Hermes Desktop —paneles de diseño, navegación lateral, la barra de estado, herramientas de la barra de título, entradas de la paleta ⌘K, atajos de teclado, temas— se registra en un único registro, y la propia aplicación registra su interfaz exactamente igual que lo hace un plugin. Los plugins no son un añadido: son la forma en que está construida la aplicación.
Tres minutos para tu primer plugin
Aquí tienes el ejemplo completo más corto. Crea un archivo:
// $HERMES_HOME/desktop-plugins/hello/plugin.js (that's ~/.hermes/desktop-plugins/hello/plugin.js by default)
import { host, haptic, useValue } from '@hermes/plugin-sdk'
import { jsx, jsxs } from 'react/jsx-runtime'
function HelloPane() {
const gateway = useValue(host.state.gateway)
return jsxs('div', {
className: 'flex h-full flex-col gap-2 p-3 text-sm',
children: [
jsx('div', { className: 'font-medium', children: 'Hello, Hermes' }),
jsx('div', {
className: 'text-(--ui-text-tertiary)',
children: `gateway: ${gateway}`
})
]
})
}
export default {
id: 'hello', // must match the folder name
name: 'Hello',
register(ctx) {
// a 260px pane docked to the right
ctx.register({
id: 'pane',
area: 'panes',
title: 'hello',
data: { placement: 'right', width: '260px' },
render: () => jsx(HelloPane, {})
})
// a small button in the right side of the status bar
ctx.register({
id: 'chip',
area: 'statusBar.right',
order: 130,
render: () =>
jsx('button', {
type: 'button',
className: 'px-1.5 text-[0.6875rem] text-(--ui-text-tertiary)',
onClick: () => {
haptic('tap')
host.notify({ kind: 'info', message: 'Hello from my plugin!' })
},
children: 'hello'
})
})
}
}
Guárdalo. La aplicación vigila desktop-plugins/, carga el archivo en unos segundos y recarga en caliente cada guardado posterior sin perder el estado. Si no aparece nada, pulsa ⌘K y elige Recargar plugins de escritorio. Si la carga falla, recibes un aviso que nombra el error: corrígelo y vuelve a guardar. Un plugin roto puede lanzar una excepción, pero no puede tumbar la aplicación.
El contrato: dos interfaces y ya está
Un plugin exporta por defecto un único objeto:
interface HermesPlugin {
id: string // stable slug — the `plugin:<id>` source tag and the id namespace
name?: string // shown in Settings; defaults to id
defaultEnabled?: boolean // defaults to true; false ships it opt-in (inventoried, switched off)
register: (ctx: PluginContext) => void
}
register recibe un contexto con alcance. Nunca toca el registro directamente: el contexto marca la procedencia (source: 'plugin:<id>') y da un espacio de nombres a cada id de contribución (<id>:<localId>), así que dos plugins nunca pueden colisionar.
interface PluginContext {
readonly source: string // e.g. 'plugin:hello'
register: (c: PluginContribution) => () => void // register one, returns a disposer
registerMany: (cs: PluginContribution[]) => () => void // register many at once
rest: <T>(path, opts?) => Promise<T> // REST to your own /api/plugins/<id>
socket: (path, onMessage) => () => void // live WebSocket in the same namespace
os: PluginOs // native notification, open-external, reveal, clipboard
storage: PluginStorage // plugin-scoped persistence (hermes.plugin.<id>.*)
}
Una contribución es la primitiva que comparten todas las superficies:
interface Contribution {
id: string // your local id; the host namespaces it
area: string // WHERE it goes (see the area table below)
title?: string
order?: number // sort within the area, lower = earlier
when?: () => boolean // dynamic visibility, re-evaluated by the area
enabled?: boolean
render?: () => ReactNode // the component to mount
data?: unknown // area-specific payload
}
Tú aportas render, data o ambos, según la superficie.
Dónde puede ir cada cosa
| Superficie | area |
Qué aportas |
|---|---|---|
| Panel de diseño (acoplable, arrastrable) | PANES_AREA ('panes') |
title + render + data (placement, dock, width, height) |
| Ruta de página completa | ROUTES_AREA |
data (path) + render |
| Elemento de navegación lateral | SIDEBAR_NAV_AREA |
data (path, label, codicon) |
| Barra de estado | STATUSBAR_AREAS.left / .right |
render, o data como un StatusbarItem |
| Barra de título | TITLEBAR_AREAS.left / .center / .right |
data como un TitlebarTool, o un <Contribute> con alcance |
| Paleta ⌘K | PALETTE_AREA |
data como un PaletteContribution |
| Atajo de teclado | KEYBINDS_AREA |
data como un KeybindContribution |
| Tema | THEMES_AREA |
data como un DesktopTheme |
| Composer | COMPOSER_AREAS.* |
slots de render, o middleware / proveedores de adjuntos |
El placement de un panel es su rol semántico (main, left, right, top, bottom): se apila como pestañas con los paneles existentes de ese rol, y después el usuario puede arrastrarlo donde quiera. Para colocarlo en un borde concreto en lugar de apilarlo, añade un gesto dock, lo mismo que arrastrar un panel a mano sobre un chip de acoplamiento.
Tres cosas que conviene saber antes de escribir más
Uno: no escribas sintaxis JSX. El archivo en disco se carga sin compilar, así que <div> nunca llega a un parser. Escribe la interfaz con llamadas jsx() / jsxs() de react/jsx-runtime (o React.createElement), como en el ejemplo anterior. Es más verboso, y es lo que te da el «sin paso de compilación».
Dos: exactamente tres especificadores importables. @hermes/plugin-sdk, react y react/jsx-runtime: todo lo demás falla al resolverse, a propósito. Un lint lo impone: importar internals de la aplicación (@/…) desde un plugin es un error, y la corrección correcta pasa por añadir la capacidad al SDK.
Tres: los plugins de escritorio son de la aplicación, no tienen alcance por perfil. Hay un único directorio raíz y se aplica a todos los perfiles, gateways y máquinas remotas a las que se conecta la ventana. Si un plugin necesita comportarse de forma distinta según el perfil, que lea él mismo el perfil activo.
Usa el propio lenguaje de diseño de la aplicación
El ui.* del SDK te da los componentes reales de la aplicación, sus variables de tema, sus iconos y sus formateadores. Eso importa más de lo que parece: si te montas tu propio estilo a mano, el plugin parecerá ajeno en una ventana donde todo lo demás coincide al píxel. Usa ui y los colores y el espaciado seguirán el tema activo: text-(--ui-text-tertiary) en el ejemplo es una variable de tema que cambia cuando el usuario cambia de tema.
¿Quieres un backend? Tienes un espacio de nombres
Las llamadas rest y socket de un plugin van a su propio espacio de nombres, /api/plugins/<id>. Deja un plugin_api.py en el paquete del plugin y ese espacio de nombres cobra vida. (Solo esta capa de backend se comparte entre los SDK de escritorio y del panel web; sus API de front-end son totalmente independientes, así que no las mezcles.) Eso le da al plugin su propio estado en proceso, sus archivos y conexiones de gateway de larga duración sin meter lógica dentro de render. React Query también viene incluido, así que no hay que rehacer la obtención, el caché ni la invalidación de datos.
Tres modos de entrega
| Modo | Dónde | Quién | Paso de compilación |
|---|---|---|---|
| Disco (recomendado) | $HERMES_HOME/desktop-plugins/<id>/plugin.js |
usuarios, agentes | ninguno: ESM plano, cargado sin compilar |
| Paquete unificado | $HERMES_HOME/plugins/<id>/desktop/plugin.js |
plugins que también distribuyen código del lado del agente | ninguno: el mismo pipeline de disco |
| Integrado | apps/desktop/src/plugins/<id>/plugin.tsx |
en el árbol del repositorio, se distribuye con la aplicación | sí: el propio build de Vite de la aplicación |
Los tres usan el mismo contrato HermesPlugin, los tres aparecen en Capacidades → Plugins y los tres se activan y desactivan en caliente. Para ver implementaciones de referencia, el repositorio compañero oficial es hermes-example-plugins. La propia Radio de la aplicación (streams en directo, búsqueda de emisoras, controles de reproducción en la barra de estado y una forma de onda reactiva al audio) se distribuye como plugin integrado que solo usa el SDK, desactivado por defecto: actívalo en Capacidades → Plugins para ver hasta dónde puede llegar un plugin.
La frontera de seguridad: esto no es un sandbox
La documentación es inusualmente directa al respecto, y vale la pena reproducirlo tal cual: un plugin cargado se evalúa como ESM en el realm del renderer con toda la autoridad de la aplicación: el singleton de React, todo el SDK (host.request con el JSON-RPC del gateway, ctx.rest, el almacenamiento, navigate). El aislamiento que hay aquí es aislamiento de errores (fronteras de contribución, listeners aislados), lo que significa que un plugin no puede tumbar la aplicación, pero puede hacer cualquier cosa que haga la aplicación.
Para fuentes locales eso es aceptable, porque un archivo de tu disco ya puede ejecutar código. Pero es justo la razón por la que una fuente remota (https + lista de permitidos) no debe reutilizar este pipeline tal cual: necesita una frontera real (iframe o worker, más CSP y control de capacidades) antes de poder publicarse. La opción { integrity } es la costura del transporte, no la de la confianza.
Así que instala solo plugins que estés dispuesto a ejecutar en tu máquina. Si quieres saber qué está haciendo el proyecto con la procedencia y la revisión de plugins, lee la entrada sobre el catálogo de plugins junto a este artículo: el catálogo responde a «de dónde salió esto, si se revisó y si estos son los bytes originales».
Por dónde seguir
- Para plugins del lado del agente (CLI de Python / gateway), ese es otro SDK: consulta los plugins de backend de terminal y cómo publicar un proveedor de modelos como plugin de pip.
- Para ver cómo es la experiencia de un plugin de escritorio terminado, la entrada del Desktop Skills Hub cubre cómo explorar e instalar skills dentro de la aplicación.
- ¿Todavía no lo tienes configurado? Empieza por la guía de instalación: la aplicación de escritorio es la superficie más fácil para escribir plugins.
Una nota honesta sobre los tiempos: este SDK es joven. Que Radio se distribuya como plugin integrado del SDK y la remodelación de la página de Plugins aterrizaron a pocos días de este artículo, lo que significa que la superficie todavía puede moverse, y también que un plugin pequeño que funcione hoy probablemente sea uno que a nadie más se le ha ocurrido escribir.