Eine Datei, ein Panel: Ein praktischer Leitfaden zum Hermes Desktop Plugin SDK


Du verbringst deinen Tag im Fenster von Hermes Desktop, und früher oder später taucht ein kleiner Wunsch auf: ein kleines Pane, das zeigt, ob die heutigen Cron-Jobs tatsächlich gelaufen sind, oder ein Button in der Statusleiste, der beim Klick die Logs mitlaufen lässt. Bis vor Kurzem gab es auf solche Wünsche zwei Antworten — damit leben oder den Quellcode der App lesen, was eine große und für Dritte unfreundliche Codebasis ist.

Inzwischen gibt es eine dritte Option: eine Datei schreiben, sie in ein Verzeichnis legen, und wenige Sekunden später ist sie live. Jede Oberfläche im Fenster von Hermes Desktop — Layout-Panes, Sidebar-Navigation, die Statusleiste, Werkzeuge in der Titelleiste, ⌘K-Paletteneinträge, Keybinds, Themes — registriert sich in einer einzigen Registry, und die Kern-App registriert ihre eigene Oberfläche genau so, wie es ein Plugin tut. Plugins sind kein Anbau: Sie sind die Art und Weise, wie die App gebaut ist.

Drei Minuten bis zum ersten Plugin

Hier das kürzeste vollständige Beispiel. Erstelle eine Datei:

// $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'
        })
    })
  }
}

Speichere sie. Die App überwacht desktop-plugins/, lädt die Datei innerhalb weniger Sekunden und lädt bei jedem weiteren Speichern an Ort und Stelle neu. Erscheint nichts, drücke ⌘K und wähle Reload desktop plugins. Schlägt das Laden fehl, bekommst du einen Toast, der den Fehler benennt — behebe ihn und speichere erneut. Ein kaputtes Plugin kann eine Exception werfen, aber es kann die App nicht mitreißen.

Der Vertrag: zwei Interfaces, mehr nicht

Ein Plugin exportiert per Default ein Objekt:

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 erhält einen abgegrenzten Kontext. Er rührt die Registry nie direkt an — der Kontext stempelt die Herkunft (source: 'plugin:<id>') und versieht jede Beitrags-ID mit einem Namensraum (<id>:<localId>), sodass zwei Plugins niemals kollidieren können.

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>.*)
}

Eine Contribution ist das eine Primitiv, das alle Oberflächen teilen:

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
}

Du lieferst render, data oder beides, je nach Area.

Wohin die Dinge kommen können

Oberfläche area Was du bereitstellst
Layout-Pane (andockbar, verschiebbar) PANES_AREA ('panes') title + render + data (placement, dock, width, height)
Ganzseitige Route ROUTES_AREA data (path) + render
Sidebar-Navigationseintrag SIDEBAR_NAV_AREA data (path, label, codicon)
Statusleiste STATUSBAR_AREAS.left / .right render, oder data als StatusbarItem
Titelleiste TITLEBAR_AREAS.left / .center / .right data als TitlebarTool, oder ein abgegrenztes <Contribute>
⌘K-Palette PALETTE_AREA data als PaletteContribution
Keybind KEYBINDS_AREA data als KeybindContribution
Theme THEMES_AREA data als DesktopTheme
Composer COMPOSER_AREAS.* Render-Slots oder Middleware- / Attachment-Provider

Die placement eines Panes ist seine semantische Rolle (main, left, right, top, bottom): Es stapelt sich als Tab mit vorhandenen Panes dieser Rolle, und der Nutzer kann es danach überallhin ziehen. Um stattdessen an einer bestimmten Kante zu landen, füge eine dock-Geste hinzu — dasselbe, als würde man ein Pane von Hand auf einen Drop-Chip ziehen.

Drei Dinge, die du wissen solltest, bevor du mehr schreibst

Erstens: Schreib keine JSX-Syntax. Die Datei auf der Festplatte wird unkompiliert geladen, ein <div> erreicht also nie einen Parser. Schreib die UI mit jsx()- / jsxs()-Aufrufen aus react/jsx-runtime (oder React.createElement), wie im Beispiel oben. Das ist ausführlicher, und genau das erkauft dir „kein Build-Schritt“.

Zweitens: genau drei importierbare Spezifizierer. @hermes/plugin-sdk, react und react/jsx-runtime — alles andere löst nicht auf, und zwar mit Absicht. Ein Lint-Zaun erzwingt das: Der Import von App-Interna (@/…) aus einem Plugin ist ein Fehler, und die richtige Lösung ist, die Fähigkeit dem SDK hinzuzufügen.

Drittens: Desktop-Plugins gelten app-weit, nicht pro Profil. Es gibt ein einziges Wurzelverzeichnis, und es gilt für jedes Profil, jedes Gateway und jede Remote-Maschine, mit der sich das Fenster verbindet. Muss sich ein Plugin pro Profil anders verhalten, liest es das aktive Profil selbst aus.

Nutze die Designsprache der App selbst

Das ui.* des SDK gibt dir die echten Komponenten, Theme-Variablen, Icons und Formatierer der App. Das ist wichtiger, als es klingt: Baust du dein Styling selbst zusammen, wirkt das Plugin in einem Fenster fremd, in dem sonst alles pixelgenau zusammenpasst. Nutze ui, und Farben und Abstände folgen dem aktiven Theme — text-(--ui-text-tertiary) im Beispiel ist eine Theme-Variable, die sich ändert, wenn der Nutzer das Theme wechselt.

Willst du ein Backend? Du bekommst einen Namensraum

Die rest- und socket-Aufrufe eines Plugins gehen an seinen eigenen Namensraum, /api/plugins/<id>. Leg eine plugin_api.py in das Plugin-Paket, und dieser Namensraum lebt auf. (Nur diese Backend-Schicht ist zwischen dem Desktop- und dem Web-Dashboard-SDK geteilt — ihre Frontend-APIs sind völlig getrennt, also nicht verwechseln.) Das gibt einem Plugin eigenen In-Process-Zustand, Dateien und langlebige Gateway-Verbindungen, ohne Logik in render stopfen zu müssen. React Query kommt obendrein mit, sodass Fetching, Caching und Invalidierung nicht neu gebaut werden müssen.

Drei Auslieferungsmodi

Modus Wo Wer Build-Schritt
Disk (empfohlen) $HERMES_HOME/desktop-plugins/<id>/plugin.js Nutzer, Agenten keiner — reines ESM, unkompiliert geladen
Unified Package $HERMES_HOME/plugins/<id>/desktop/plugin.js Plugins, die auch agentenseitigen Code ausliefern keiner — dieselbe Disk-Pipeline
Bundled apps/desktop/src/plugins/<id>/plugin.tsx im Repo, mit der App ausgeliefert ja — der eigene Vite-Build der App

Alle drei folgen demselben HermesPlugin-Vertrag, alle erscheinen unter Capabilities → Plugins, und alle lassen sich live aktivieren und deaktivieren. Referenzimplementierungen findest du im offiziellen Begleit-Repo hermes-example-plugins. Das eigene Radio der App (Live-Streams, Sendersuche, Wiedergabesteuerung in der Statusleiste, eine audio-reaktive Wellenform) wird als gebündeltes SDK-Plugin ausgeliefert, standardmäßig aus — schalte es unter Capabilities → Plugins ein, um zu sehen, wie weit ein Plugin gehen kann.

Die Sicherheitsgrenze: das ist kein Sandkasten

Die Doku ist hier ungewöhnlich deutlich, und es lohnt sich, das wörtlich weiterzugeben: Ein geladenes Plugin wird als ESM im Renderer-Realm mit voller App-Autorität ausgewertet — das React-Singleton, das gesamte SDK (host.request für Gateway-JSON-RPC, ctx.rest, Storage, navigate). Die Isolation hier ist eine Fehler-Isolation (Contribution-Grenzen, isolierte Listener), was heißt: Ein Plugin kann die App nicht zum Absturz bringen — aber es kann alles tun, was die App kann.

Bei lokalen Quellen ist das akzeptabel, denn eine Datei auf deiner Festplatte kann ohnehin schon Code ausführen. Genau deshalb darf eine Remote-Quelle (https + Allowlist) diese Pipeline nicht einfach wiederverwenden: Sie braucht eine echte Grenze (iframe/Worker plus CSP und Capability-Gating), bevor sie ausgeliefert werden kann. Die Option { integrity } ist die Transport-Naht, nicht die Vertrauens-Naht.

Also: Installiere nur Plugins, die du bereit bist, auf deiner Maschine laufen zu lassen. Wenn du wissen willst, was das Projekt in Sachen Plugin-Herkunft und Review unternimmt, lies den Beitrag zum Plugin-Katalog parallel zu diesem — der Katalog beantwortet die Frage „woher kommt das, wurde es geprüft, sind das die Original-Bytes“.

Wie es weitergeht

Zum Schluss eine ehrliche Anmerkung zum Timing: Dieses SDK ist jung. Dass Radio als gebündeltes SDK-Plugin ausgeliefert wird und die Überarbeitung der Plugins-Seite landeten beide nur wenige Tage vor diesem Beitrag, was heißt, dass sich die Oberfläche noch bewegen kann — und auch, dass ein kleines Plugin, das heute funktioniert, wahrscheinlich noch niemandem eingefallen ist.