Un fichier, un panneau : guide pratique du SDK de plugins de Hermes Desktop

Vous passez vos journées dans la fenêtre Hermes Desktop et, tôt ou tard, un petit souhait surgit : un volet qui montre si les tâches cron du jour se sont réellement exécutées, ou un bouton dans la barre d’état qui affiche les journaux en flux quand vous cliquez dessus. Jusqu’à récemment, ce souhait avait deux réponses — s’en passer, ou aller lire le code source de l’application, une base de code volumineuse et peu accueillante pour des tiers.
Il existe désormais une troisième option : écrire un fichier, le déposer dans un répertoire, et il est actif quelques secondes plus tard. Chaque surface de la fenêtre Hermes Desktop — panneaux de mise en page, navigation latérale, barre d’état, outils de la barre de titre, entrées de la palette ⌘K, raccourcis clavier, thèmes — s’enregistre dans un registre unique, et l’application principale enregistre sa propre interface exactement comme le ferait un plugin. Les plugins ne sont pas un ajout greffé après coup : c’est ainsi que l’application est construite.
Trois minutes pour votre premier plugin
Voici l’exemple complet le plus court. Créez un seul fichier :
// $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'
})
})
}
}
Enregistrez-le. L’application surveille desktop-plugins/, charge le fichier en quelques secondes et recharge à chaud chaque sauvegarde ultérieure sur place. Si rien n’apparaît, faites ⌘K et choisissez Recharger les plugins de bureau. En cas d’échec du chargement, un toast vous nomme l’erreur — corrigez et enregistrez de nouveau. Un plugin cassé peut lever une exception, mais il ne peut pas faire tomber l’application.
Le contrat : deux interfaces, c’est tout
Un plugin exporte par défaut un seul objet :
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 reçoit un contexte à portée limitée. Il ne touche jamais le registre directement — le contexte estampille la provenance (source: 'plugin:<id>') et préfixe chaque identifiant de contribution (<id>:<localId>), si bien que deux plugins ne peuvent jamais entrer en collision.
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>.*)
}
Une contribution est la primitive unique que partagent toutes les surfaces :
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
}
Vous fournissez render, data, ou les deux, selon la surface.
Où les choses peuvent aller
| Surface | area |
Ce que vous fournissez |
|---|---|---|
| Panneau de mise en page (ancrable, déplaçable) | PANES_AREA ('panes') |
title + render + data (placement, dock, width, height) |
| Route de page complète | ROUTES_AREA |
data (path) + render |
| Élément de navigation latérale | SIDEBAR_NAV_AREA |
data (path, label, codicon) |
| Barre d’état | STATUSBAR_AREAS.left / .right |
render, ou data sous forme de StatusbarItem |
| Barre de titre | TITLEBAR_AREAS.left / .center / .right |
data sous forme de TitlebarTool, ou un <Contribute> à portée limitée |
| Palette ⌘K | PALETTE_AREA |
data sous forme de PaletteContribution |
| Raccourci clavier | KEYBINDS_AREA |
data sous forme de KeybindContribution |
| Thème | THEMES_AREA |
data sous forme de DesktopTheme |
| Composeur | COMPOSER_AREAS.* |
slots de rendu, ou fournisseurs de middleware / pièces jointes |
Le placement d’un panneau est son rôle sémantique (main, left, right, top, bottom) : il s’empile en onglets avec les panneaux existants de ce rôle, et l’utilisateur peut ensuite le faire glisser où il veut. Pour atterrir sur un bord précis plutôt que de s’empiler, ajoutez un geste dock — l’équivalent de faire glisser un panneau à la main sur une pastille de dépôt.
Trois choses à savoir avant d’aller plus loin
Un : n’écrivez pas de syntaxe JSX. Le fichier sur disque est chargé non compilé, donc <div> n’atteint jamais un parseur. Écrivez l’interface avec les appels jsx() / jsxs() de react/jsx-runtime (ou React.createElement), comme dans l’exemple ci-dessus. C’est plus verbeux, et c’est précisément ce qui vous offre l’absence d’étape de build.
Deux : exactement trois spécificateurs importables. @hermes/plugin-sdk, react et react/jsx-runtime — tout le reste échoue à se résoudre, exprès. Une barrière de lint l’impose : importer les internes de l’application (@/…) depuis un plugin est une erreur, et le correctif approprié consiste à ajouter la capacité au SDK.
Trois : les plugins de bureau sont au niveau de l’application, pas du profil. Il n’existe qu’un seul répertoire racine, et il s’applique à chaque profil, gateway et machine distante auxquels la fenêtre se connecte. Si un plugin doit se comporter différemment selon le profil, c’est à lui de lire le profil actif.
Adoptez le langage visuel de l’application
Le ui.* du SDK vous donne les vrais composants de l’application, ses variables de thème, ses icônes et ses formateurs. Cela compte plus qu’il n’y paraît : fabriquez votre propre style à la main et le plugin paraîtra étranger dans une fenêtre où tout le reste s’aligne au pixel près. Utilisez ui et les couleurs comme les espacements suivent le thème actif — text-(--ui-text-tertiary) dans l’exemple est une variable de thème qui change quand l’utilisateur bascule de thème.
Besoin d’un backend ? Vous obtenez un namespace
Les appels rest et socket d’un plugin vont vers son propre namespace, /api/plugins/<id>. Déposez un plugin_api.py dans le paquet du plugin et ce namespace prend vie. (Seule cette couche backend est partagée entre les SDK desktop et web-dashboard — leurs API front-end sont entièrement séparées, ne les confondez pas.) Un plugin dispose ainsi de son propre état en mémoire, de ses fichiers et de connexions gateway durables sans fourrer de logique dans render. React Query vient avec, donc la récupération, la mise en cache et l’invalidation n’ont pas à être réécrites.
Trois modes de distribution
| Mode | Où | Qui | Étape de build |
|---|---|---|---|
| Disque (recommandé) | $HERMES_HOME/desktop-plugins/<id>/plugin.js |
utilisateurs, agents | aucune — ESM simple, chargé non compilé |
| Paquet unifié | $HERMES_HOME/plugins/<id>/desktop/plugin.js |
plugins qui embarquent aussi du code côté agent | aucune — même pipeline disque |
| Intégré | apps/desktop/src/plugins/<id>/plugin.tsx |
dans le dépôt, livré avec l’application | oui — le build Vite de l’application |
Les trois suivent le même contrat HermesPlugin, tous apparaissent dans Capacités → Plugins et tous s’activent ou se désactivent à chaud. Pour des implémentations de référence, le dépôt compagnon officiel est hermes-example-plugins. La Radio de l’application elle-même (flux en direct, recherche de stations, commandes de lecture dans la barre d’état, forme d’onde réactive à l’audio) est livrée comme plugin intégré reposant uniquement sur le SDK, désactivé par défaut — activez-le dans Capacités → Plugins pour voir jusqu’où un plugin peut aller.
La frontière de sécurité : ce n’est pas un bac à sable
La documentation est inhabituellement directe sur ce point, et cela vaut la peine de la citer telle quelle : un plugin chargé est évalué comme de l’ESM dans le realm du renderer avec l’autorité complète de l’application — le singleton React, tout le SDK (host.request en JSON-RPC vers le gateway, ctx.rest, le stockage, navigate). L’isolation ici est une isolation d’erreur (frontières de contributions, écouteurs isolés), ce qui signifie qu’un plugin ne peut pas faire planter l’application — mais qu’il peut faire tout ce que l’application peut faire.
Pour des sources locales, c’est acceptable, puisqu’un fichier sur votre disque peut déjà exécuter du code. Mais c’est précisément pourquoi une source distante (https + liste blanche) ne doit pas réutiliser ce pipeline tel quel : il lui faut une vraie frontière (iframe/worker plus CSP et restriction des capacités) avant de pouvoir être livrée. L’option { integrity } est la couture du transport, pas celle de la confiance.
Donc : n’installez que des plugins que vous êtes prêt à exécuter sur votre machine. Si vous voulez savoir ce que le projet fait en matière de provenance et de revue des plugins, lisez le billet sur le catalog de plugins en parallèle de celui-ci — le catalog répond à « d’où vient ce plugin, a-t-il été relu, s’agit-il bien des octets d’origine ».
Pour aller plus loin
- Pour les plugins côté agent (CLI Python / gateway), c’est un autre SDK : voir les plugins de backend terminaux et distribuer un fournisseur de modèle sous forme de plugin pip.
- Pour voir à quoi ressemble une expérience de plugin desktop aboutie, le billet sur le Desktop Skills Hub couvre la navigation et l’installation de compétences dans l’application.
- Pas encore installé ? Commencez par le guide d’installation — l’application de bureau est la surface la plus simple pour écrire des plugins.
Une note honnête sur le calendrier : ce SDK est jeune. La Radio livrée comme plugin intégré du SDK et la refonte de la page Plugins ont toutes deux atterri quelques jours avant ce billet, ce qui signifie que la surface peut encore bouger — et aussi qu’un petit plugin qui fonctionne aujourd’hui est probablement un que personne n’a encore pensé à écrire.