--- sidebar_label: "Desktop Plugin SDK" title: "Desktop Plugin SDK (@hermes/plugin-sdk)" description: "Extend the native Hermes Desktop app — panes, pages, sidebar nav, status bar, palette commands, keybinds, themes, and a scoped backend namespace, with one import and no build step." --- # Desktop Plugin SDK The native [Hermes Desktop](../user-guide/desktop.md) app is contribution-driven: every surface in the window — panes, routes, sidebar nav, status-bar items, palette entries, keybinds, themes — registers into one central registry. Core registers its surfaces exactly the way a plugin does, so the plugin story is the real one, not a bolted-on afterthought. A **desktop plugin** is a single ESM file that default-exports a `HermesPlugin`. It imports one module — `@hermes/plugin-sdk` — and gets everything: the app's live state, the gateway JSON-RPC door, a scoped REST/socket backend namespace, React Query, and the app's own UI kit so plugin UI looks native by default. No repo clone, no `npm run build`, no patching app source. Drop the file in `$HERMES_HOME/desktop-plugins//plugin.js` and the app loads it within seconds and hot-reloads every save. :::warning This is not the web-dashboard plugin SDK "Plugin" means several unrelated things across Hermes. This page is the **native desktop app** (`hermes desktop`) SDK — the `@hermes/plugin-sdk` module and `$HERMES_HOME/desktop-plugins/`. The **web dashboard** (`hermes dashboard`) has its own, unrelated plugin system on `window.__HERMES_PLUGIN_SDK__` with a `manifest.json` — documented at [Extending the Dashboard](../user-guide/features/extending-the-dashboard.md). Python CLI/gateway plugins are documented at [Build a Hermes Plugin](./plugins/index.md). The three do not share code, APIs, or delivery. Only the backend `plugin_api.py` namespace (`/api/plugins/`) is shared between the desktop and dashboard SDKs. ::: ## Mental model The SDK follows the VS Code module model. A plugin author imports exactly one module and never touches app internals (they are lint-fenced out of a bundled plugin, and fail to resolve in a disk plugin). Capability comes in tiers: - **`host.state.*`** — readonly views over the app's live state (nanostore atoms): active session, per-session turn-busy, cwd, gateway socket status, model, profile, viewport. `gateway` is the WebSocket, not turn-busy. - **`host.*` actions** — curated safe verbs: toast, navigate, tail logs, restart the gateway, subscribe to the gateway event stream. - **`host.request`** — the gateway JSON-RPC door: sessions, config, skills, cron — everything the app itself calls. - **`captureGatewayFileDownload()`** — capture a gateway file-save action immediately before starting a REST read, and retain it alongside the returned data. The action `(storedPath, suggestedName) => Promise` keeps that read's connection/profile scope even if the user switches hosts before clicking. Invoke only on an explicit user download gesture, using the backend's persisted file path, never a guessed workspace path. Electron handles authenticated streaming, the native save dialog, and older-gateway fallback; plugins never receive credentials or open remote paths with `file://`. The host shows the same "Saved" / "Download failed" toasts as the Files panel and stays quiet on cancel; the promise settles when the save does and never rejects. - **`ctx.rest` / `ctx.socket`** — your plugin's own backend namespace (`/api/plugins/`) if you ship a `plugin_api.py`. - **`ui.*`** — the design language: the app's real components, theme variables, icons, and formatters, so your UI matches the app pixel-for-pixel. ## Two delivery modes | Mode | Where | Who | Build step | |------|-------|-----|------------| | **Disk** (recommended) | `$HERMES_HOME/desktop-plugins//plugin.js` | users, agents | none — plain ESM, loaded uncompiled | | **Unified package** | `$HERMES_HOME/plugins//desktop/plugin.js` | plugins that also ship agent-side code | none — same disk pipeline | | **Bundled** | `apps/desktop/src/plugins//plugin.tsx` | in-tree, shipped with the app | the app's own Vite build | All three take the same `HermesPlugin` contract, appear in **Capabilities → Plugins**, and enable/disable live. A unified package is just the disk door scanning inside your agent plugin's folder — see [One package, both SDKs](#one-package-both-sdks). Everything on this page is written against the disk door (what you and the agent write); [Bundled plugins](#bundled-plugins) notes the two differences. Radio ships as a bundled SDK-only plugin, off by default. Enable it in **Capabilities → Plugins** for free live streams, station search, and status-bar playback controls with an audio-reactive waveform. It uses the existing plugin toggle and contributes nothing while disabled. Reference demos live in the companion [`hermes-example-plugins`](https://github.com/NousResearch/hermes-example-plugins) repo. ## Quick start — your first plugin Create `$HERMES_HOME/desktop-plugins/hello/plugin.js` (that's `~/.hermes/...` by default). Desktop plugins are app-level — one root for every profile, gateway, or remote machine the window connects to. The folder name must equal the plugin `id`. ```javascript // ~/.hermes/desktop-plugins/hello/plugin.js 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) { ctx.register({ id: 'pane', area: 'panes', title: 'hello', data: { placement: 'right', width: '260px' }, render: () => jsx(HelloPane, {}) }) 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' }) }) } } ``` Save it. The app watches `desktop-plugins/`, loads the file within a few seconds, and hot-reloads every later save in place. If it doesn't appear, run ⌘K → **Reload desktop plugins**. If loading fails, a toast names the error — fix and save again. :::note No JSX, no build The disk file is loaded **uncompiled**, so JSX syntax will not parse. Write UI with `jsx()` / `jsxs()` calls from `react/jsx-runtime` (or `React.createElement`). The only importable specifiers are `@hermes/plugin-sdk`, `react`, and `react/jsx-runtime` — everything else fails to resolve, on purpose. ::: ## The plugin contract A plugin default-exports a `HermesPlugin`: ```ts interface HermesPlugin { /** Stable slug — becomes the `plugin:` source and the id namespace. */ id: string /** Human name for Settings / about UI. Defaults to `id`. */ name?: string /** Registers on load when the user hasn't chosen (default true). Set false * for opt-in plugins: they inventory in Capabilities ▸ Plugins, off until the * user flips the switch. */ defaultEnabled?: boolean /** Called once at load; wire contributions through `ctx`. */ register: (ctx: PluginContext) => void } ``` `register` receives a **scoped** `PluginContext`. It never touches the registry directly — the context auto-tags provenance (`source: 'plugin:'`) and namespaces every contribution id (`:`), so two plugins can never collide. ```ts interface PluginContext { /** Resolved source tag, e.g. `'plugin:hello'`. */ readonly source: string /** Register one contribution (id namespaced, source stamped). Returns a disposer. */ register: (c: PluginContribution) => () => void /** Register several at once; the returned disposer removes all of them. */ registerMany: (cs: PluginContribution[]) => () => void /** REST to this plugin's own backend namespace (`/api/plugins/`). */ rest: (path: string, opts?: PluginRestOptions) => Promise /** Live WebSocket to this plugin's own namespace. Returns a disposer. */ socket: (path: string, onMessage: (data: unknown) => void) => () => void /** Gateway event stream by type (`'*'` = all). Tracked: removed on unload/reload/disable. */ onEvent: (type: string, listener: (event: GatewayEvent) => void) => () => void /** Any other cleanup to run on unload/reload/disable (store subscriptions, injected DOM). */ onDispose: (fn: () => void) => void /** Scoped timers and DOM listeners — cleared with the plugin. Each returns a disposer. */ setTimeout: (fn: () => void, ms: number) => () => void setInterval: (fn: () => void, ms: number) => () => void addEventListener: (target: EventTarget, type: string, listener: EventListener, options?: AddEventListenerOptions | boolean) => () => void /** The curated OS door: native notification, open-external, reveal-in-file-manager, clipboard. */ os: PluginOs /** Plugin-scoped JSON persistence (keys live under `hermes.plugin..`). */ storage: PluginStorage } ``` A **contribution** is the one primitive every surface shares: ```ts interface Contribution { id: string // you write the local id; the host namespaces it area: string // WHERE it goes (a contribution-area constant) 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 (see the cookbook) } ``` You provide `render`, `data`, or both, depending on the area. ## Contribution areas — the cookbook Import the area constants from the SDK; each area has its own `data` payload. | Surface | `area` | You provide | |---------|--------|-------------| | Layout pane | `PANES_AREA` (`'panes'`) | `title` + `render` + `data: { placement, dock?, width?, height? }` | | Full page | `ROUTES_AREA` | `data: { path }` + `render` | | Sidebar nav | `SIDEBAR_NAV_AREA` | `data: { path, label, codicon }` | | Status bar | `STATUSBAR_AREAS.left` / `.right` | `render` (or `data` as `StatusbarItem`) | | Title bar | `TITLEBAR_AREAS.left` / `.center` / `.right` | `data` as `TitlebarTool`, or a mount-scoped `` | | Page header | `WORKSPACE_PAGE_HEADER_AREA` | `` inside your page (inline in a split tile) | | ⌘K palette | `PALETTE_AREA` | `data: PaletteContribution` | | Keybind | `KEYBINDS_AREA` | `data: KeybindContribution` | | Theme | `THEMES_AREA` | `data` as a `DesktopTheme` | | Composer | `COMPOSER_AREAS.*` | render slots, or middleware / attachment providers | | Appearance settings | `APPEARANCE_AREAS.extra` | `render` — controls appended to Settings → Appearance | ### Panes A pane is a tile in the layout tree. `placement` is the semantic role — the pane stacks (as tabs) with existing panes of that role; the user can drag it anywhere afterward. ```javascript ctx.register({ id: 'pane', area: 'panes', title: 'my pane', data: { placement: 'right', width: '260px' }, render: () => jsx(MyPane, {}) }) ``` `placement` is `'main' | 'left' | 'right' | 'top' | 'bottom'`. To land on a specific **edge** instead of stacking, add a `dock` gesture — the same thing as dragging onto a pane's drop chip: ```javascript // Below the conversation, 200px tall. data: { placement: 'bottom', dock: { pane: 'workspace', pos: 'bottom' }, height: '200px' } ``` `dock.pane` is any pane id (`workspace` is the main thread; also `sessions`, `terminal`, `files`, `review`, `logs`); `dock.pos` is `'top' | 'bottom' | 'left' | 'right' | 'center'`. Declare a `width`/`height` so the pane doesn't claim half the zone. Closing the only pane contributed by a plugin disables that plugin, which can be re-enabled from **Capabilities → Plugins**. When a plugin contributes multiple panes, closing one dismisses only that pane and leaves the plugin's other panes, commands, and middleware active. **Reset layout** restores dismissed contributed panes. ### Pages and sidebar nav A route mounts a full page in the workspace pane, like any built-in view. Pair it with a sidebar nav row (and/or a palette command) to make it reachable. ```javascript import { ROUTES_AREA, SIDEBAR_NAV_AREA } from '@hermes/plugin-sdk' ctx.registerMany([ { id: 'page', area: ROUTES_AREA, data: { path: '/my-page' }, render: () => jsx(MyPage, {}) }, { id: 'nav', area: SIDEBAR_NAV_AREA, data: { path: '/my-page', label: 'My Page', codicon: 'project' } } ]) ``` `codicon` is a [VS Code codicon](https://microsoft.github.io/vscode-codicons/dist/codicon.html) id. Navigate to a route from anywhere with `host.navigate('/my-page')`. ### Status bar and title bar Status-bar items render into the left or right cluster of the bottom bar. Simplest is a `render` function; for a plain button use `data` as a `StatusbarItem` (`{ id, label?, icon?, detail?, variant?, menuItems?, … }`). ```javascript import { STATUSBAR_AREAS, TITLEBAR_AREAS } from '@hermes/plugin-sdk' ctx.register({ id: 'count', area: STATUSBAR_AREAS.right, order: 120, render: () => jsx(MyStatus, {}) }) ``` Title-bar tools live in `TITLEBAR_AREAS.left | .center | .right` as `TitlebarTool` data (`{ id, label, icon, active?, onSelect? }`). Title-bar slots are **permanent mount points**: a component you register there stays mounted while the user moves between the chat and full pages (Capabilities, Messaging, Artifacts, contributed routes), so a `useEffect` that injects global side effects (a `