Instructions to use Wellz1000/hermes-2 with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- HERMES
How to use Wellz1000/hermes-2 with HERMES:
# No code snippets available yet for this library. # To use this model, check the repository files and the library's documentation. # Want to help? PRs adding snippets are welcome at: # https://github.com/huggingface/huggingface.js
- Notebooks
- Google Colab
- Kaggle
File size: 27,047 Bytes
645efc2 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 | ---
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) 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/<id>/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). Python
CLI/gateway plugins are documented at [Build a Hermes Plugin](/developer-guide/plugins).
The three do not share code, APIs, or delivery. Only the backend `plugin_api.py`
namespace (`/api/plugins/<id>`) 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, cwd, gateway status, model, profile, viewport.
- **`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.
- **`ctx.rest` / `ctx.socket`** β your plugin's own backend namespace
(`/api/plugins/<id>`) 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/<id>/plugin.js` | users, agents | none β plain ESM, loaded uncompiled |
| **Bundled** | `apps/desktop/src/plugins/<id>/plugin.tsx` | in-tree, shipped with the app | the app's own Vite build |
Both take the same `HermesPlugin` contract, appear in **Settings β Plugins**, and
enable/disable live. Everything on this page is written against the disk door
(what you and the agent write); [Bundled plugins](#bundled-plugins) notes the two
differences. No desktop plugins ship in the core tree today β 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, or `~/.hermes/profiles/<name>/...` under a named profile). 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:<id>` 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 Settings βΈ 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:<id>'`) and
namespaces every contribution id (`<id>:<localId>`), 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/<id>`). */
rest: <T>(path: string, opts?: PluginRestOptions) => Promise<T>
/** Live WebSocket to this plugin's own namespace. Returns a disposer. */
socket: (path: string, onMessage: (data: unknown) => void) => () => void
/** Plugin-scoped JSON persistence (keys live under `hermes.plugin.<id>.`). */
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 `<Contribute>` |
| β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 |
### 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.
### 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? }`).
### Palette commands and keybinds
```javascript
import { PALETTE_AREA, KEYBINDS_AREA } from '@hermes/plugin-sdk'
ctx.registerMany([
{
id: 'open',
area: PALETTE_AREA,
data: {
id: 'my-page.open',
label: 'Open My Page',
keywords: ['my', 'page'],
run: () => host.navigate('/my-page')
}
},
{
id: 'refresh',
area: KEYBINDS_AREA,
data: {
id: 'my-page.refresh',
label: 'Refresh My Page',
category: 'My Plugin',
defaults: ['mod+shift+r'],
run: () => void doRefresh()
}
}
])
```
Keybinds are user-rebindable in settings; `defaults` is just the initial binding.
### Themes
A theme contribution ships a full `DesktopTheme` as its `data` (name, label,
colors, β¦). It appears in the theme picker like a built-in.
```javascript
import { THEMES_AREA } from '@hermes/plugin-sdk'
ctx.register({ id: 'noir', area: THEMES_AREA, data: myDesktopTheme })
```
### Composer extensions
`COMPOSER_AREAS` (`top`, `bottom`, `leading`, `actions`, `attachments`,
`middleware`) let a plugin add controls around the message composer, provide an
attachment source, or transform a draft before it is sent (`ComposerMiddleware`
with a `handler(draft) => draft | null`).
### Mount-scoped chrome (`Contribute`)
`ctx.register` is for **permanent** contributions. When chrome should live and
die with a component that's already on screen (a page's own title-bar control
leaves when the page unmounts), render `<Contribute>` inside it instead:
```javascript
import { Contribute, TITLEBAR_AREAS } from '@hermes/plugin-sdk'
jsx(Contribute, {
area: TITLEBAR_AREAS.center,
id: 'my-page:switcher', // namespace with your slug
children: jsx(MySwitcher, {})
})
```
It registers on mount and disposes on unmount automatically.
## Host API
Everything on `host` is reachable from anywhere in a plugin. State atoms are
readonly β read with `.get()` in handlers, subscribe with `useValue(atom)` in
components.
```ts
host.state.activeSessionId // ReadableAtom<string | null>
host.state.cwd // ReadableAtom<string>
host.state.gateway // ReadableAtom<string> ('idle' | 'connecting' | 'open' | β¦)
host.state.model // ReadableAtom<string>
host.state.profile // ReadableAtom<string>
host.state.viewport // ReadableAtom<{ width, height, narrow }>
host.notify({ kind, message, title?, detail?, action? }) // toast; returns id
host.notifyError(error, fallbackMessage) // toast an error
host.navigate('/route') // hash-route navigation
host.onEvent(type, fn) // gateway event stream ('*' = all); returns disposer
host.logs(...) // tail an app log file
host.status() // one-shot system status snapshot
host.restartGateway() // restart the backend gateway
host.request<T>(method, params?) // gateway JSON-RPC β the real power
```
`host.request` is the same JSON-RPC the app itself uses (sessions, config, skills,
cron, kanban, β¦). `host.onEvent` streams live gateway events (message deltas,
session lifecycle, tool activity). Listeners are isolated β a throw in your
listener can't affect app dispatch. Every `host` door is async-safe: a sync throw
from an internal helper (e.g. no desktop bridge in a plain browser) becomes a
rejection your `.catch()` sees, never an error-boundary crash.
## Data layer β React Query + nanostores
Plugins share the app's single `QueryClient`, so plugin queries cache, dedupe,
poll, and invalidate exactly like core screens β never hand-roll a fetch loop.
```javascript
import { useQuery, useMutation, useQueryClient, atom, computed, useValue } from '@hermes/plugin-sdk'
function MyPanel() {
const { data, isLoading } = useQuery({
queryKey: ['my-plugin', 'items'],
queryFn: () => host.request('my.list', {})
})
// β¦
}
```
For state shared between a trigger and its panel (or a poll loop), use `atom` /
`computed` β the same primitive `host.state` uses. Subscribe in the leaf that
renders the value with `useValue`. To invalidate a query from **outside** React
(e.g. a `ctx.socket` frame arriving), import the shared `queryClient`:
```javascript
import { queryClient } from '@hermes/plugin-sdk'
ctx.socket('/events', () => {
queryClient.invalidateQueries({ queryKey: ['my-plugin', 'items'] })
})
```
## The UI kit and theming
Import the app's real components directly so your UI is native by default:
> `Button`, `Input`, `Textarea`, `Select*`, `Switch`, `Checkbox`,
> `SegmentedControl`, `Tabs*`, `Dialog*`, `ConfirmDialog`, `DropdownMenu*`,
> `ContextMenu*`, `Popover*`, `Tip`/`Tooltip*`, `Badge`, `Kbd`/`KbdGroup`,
> `SearchField`, `ScrollArea`, `Separator`, `Skeleton`, `GlyphSpinner`, `Loader`,
> `EmptyState`, `ErrorState`, `CopyButton`, `StatusDot`, `LogView`, `Codicon`,
> `DecodeText`.
Plus helpers: `cn` (class merge), `icons.*` (the app's lucide set), `haptic`,
`profileColor` / `profileColorSoft` (deterministic identity colors), the time
formatters `relativeTime` / `fmtDateTime` / `fmtDayTime` / `coarseElapsed`,
`useI18n` (localized copy β your plugin stays translatable), and
`evaluateRuntimeReadiness`.
**Style with theme variables, never hardcoded colors.** Panes already sit on the
app's editor background β leave the background alone and use vars for everything
else: `var(--ui-text-secondary)`, `var(--ui-text-tertiary)`,
`var(--ui-text-quaternary)`, `var(--ui-stroke-secondary)`, `var(--ui-accent)`.
For canvas drawing, resolve them once with
`getComputedStyle(canvas).getPropertyValue('--ui-accent')`. This is what makes a
plugin reskin automatically with every theme.
## A backend for your plugin
If your plugin needs server-side work, ship a Python `plugin_api.py` and reach it
through `ctx.rest` / `ctx.socket` β a namespace scoped to your plugin **by
construction**.
### The Python side
Desktop plugins reuse the dashboard plugin backend mount. Put the backend in a
`dashboard/` subfolder of a regular Hermes plugin and declare it in a
`manifest.json`:
```
~/.hermes/plugins/<id>/
βββ dashboard/
βββ manifest.json # { "name": "<id>", "api": "plugin_api.py" }
βββ plugin_api.py # exports `router = APIRouter()`
```
```python
# plugin_api.py
from fastapi import APIRouter
router = APIRouter()
@router.get("/board")
async def board():
return {"items": ["one", "two", "three"]}
@router.post("/action")
async def action(body: dict):
return {"ok": True, "received": body}
```
Routes mount under `/api/plugins/<id>/` (`GET /api/plugins/<id>/board`, β¦).
Backend code runs inside the gateway process, so it can import from the
hermes-agent codebase directly (`hermes_state`, `hermes_cli.config`, β¦). See
[Extending the Dashboard β Backend API routes](/user-guide/features/extending-the-dashboard#backend-api-routes)
for the full backend reference β the mount is identical.
:::caution The Python backend is gated separately
Enabling a plugin in the desktop **Settings β Plugins** panel is a renderer-side
choice; it does **not** import Python. A user plugin's `plugin_api.py` is
imported only when the plugin is in the `plugins.enabled` allow-list in
`config.yaml` (and not in `plugins.disabled`). Project plugins (`./.hermes/`)
never auto-import Python. This is a security boundary, not an oversight
(GHSA-mcfc-hp25-cjv7).
:::
### Calling it from the plugin
```javascript
register(ctx) {
// REST β namespace-relative path.
const load = () => ctx.rest('/board') // GET /api/plugins/<id>/board
const act = () => ctx.rest('/action', { method: 'POST', body: { go: true } })
// Live twin β a WebSocket to your own namespace.
const stop = ctx.socket('/events', frame => {
queryClient.invalidateQueries({ queryKey: [ctx.source, 'board'] })
})
}
```
`ctx.rest` is profile-aware and rejects path traversal (`..`) so you can never
address another plugin's API or a core route through it. `PluginRestOptions` is
`{ method?, body?, upload?: { filename, contentType?, bytes }, timeoutMs? }`.
`ctx.socket` auto-reconnects with backoff until disposed. **It resolves to a no-op
on OAuth remotes** (single-use WS tickets are core-managed) β treat the socket as
an accelerator over polling, never a replacement. Every consumer needs a polling
fallback anyway, since any socket can drop.
For gateway-wide data (not your own namespace), use `host.request` (JSON-RPC) and
`host.onEvent` (the gateway event stream) instead.
## Settings, enable state, and storage
Every plugin β enabled or not β inventories in **Settings β Plugins**, where the
user toggles it live (no app restart), reveals its folder, or rescans. The user's
choice is remembered:
- No choice yet β the plugin's own `defaultEnabled` (default `true`). Set
`defaultEnabled: false` to ship an opt-in plugin that stays dark until the user
flips it on.
- Explicit choice β persisted and honored across restarts. A disabled plugin
stays disabled β don't fight it; the user turned you off.
Persist your own state with `ctx.storage`, namespaced to your plugin
(`hermes.plugin.<id>.*`) so plugins can't read or clobber each other:
```javascript
ctx.storage.set('lastTab', 'board')
const tab = ctx.storage.get('lastTab', 'summary')
ctx.storage.remove('lastTab')
```
## Bundled plugins
A plugin can ship in-tree at `apps/desktop/src/plugins/<id>/plugin.tsx` (default
export a `HermesPlugin`). It's discovered by `discoverBundledPlugins()` at boot β
no import, no registry edit β and shares the exact inventory + live
enable/disable contract as a disk plugin. The two differences:
1. It goes through the app's Vite build, so you can write **real JSX** and import
the SDK by its `@hermes/plugin-sdk` alias.
2. It's still lint-fenced to `@hermes/plugin-sdk` + `react` only β no `@/β¦` app
internals.
No desktop plugins ship in the core tree today; the shipped app stays uncluttered
and demos live in the
[`hermes-example-plugins`](https://github.com/NousResearch/hermes-example-plugins)
companion repo.
## Security model
A loaded plugin is evaluated as ESM in the renderer realm with **full app
authority** β the React singleton, the whole SDK (`host.request` gateway RPC,
`ctx.rest`, storage, `navigate`). The isolation the loader provides is **error
isolation only**: a plugin can't crash the app (contributions are error-bounded,
listeners isolated), but it can do anything the app can.
This is acceptable for **local** sources β a disk file can already run code on
your machine β which is why the disk door only loads local files you (or your
agent) wrote. The optional `integrity` (`sha256-β¦`) check only proves the bytes
match a hash; it does **not** sandbox. A future remote-source door will need a
real boundary (iframe/worker + CSP + capability gating) before it can land; do
not treat this pipeline as a trust boundary.
## Pitfalls
- **JSX won't parse in a disk plugin.** The file loads uncompiled β use `jsx()` /
`jsxs()` (or `React.createElement`), not JSX syntax. (Bundled plugins are built,
so JSX is fine there.)
- **Only three specifiers resolve:** `@hermes/plugin-sdk`, `react`,
`react/jsx-runtime`. Any other import surfaces an up-front load error.
- **Never hardcode colors** (`#000`, `black`, `rgb(...)`). Leave the background
alone; use theme variables (`var(--ui-*)`) for everything.
- **Reference only what you imported.** A component you forgot to import (e.g.
`StatusDot`) is a `ReferenceError` at render β double-check every identifier in
your `jsx()` calls appears in the import line.
- **Read state imperatively in handlers** (`$atom.get()`), never from a render
closure β rapid events will otherwise see stale values. Subscribe (`useValue`)
only in the leaf that renders the value.
- **Canvas panes must track their container** with a `ResizeObserver` and resize
the canvas (width/height attributes, not just CSS) β panes resize constantly.
- **Don't poll faster than a few seconds** with `host.request`; prefer
`host.onEvent` / `ctx.socket` and let React Query dedupe.
- **`ctx.socket` is a no-op on OAuth remotes.** Always have a polling fallback.
## Reference
### SDK exports at a glance
| Category | Exports |
|----------|---------|
| Host | `host` (`.state.*`, `.notify`, `.notifyError`, `.navigate`, `.onEvent`, `.logs`, `.status`, `.restartGateway`, `.request`) |
| Plugin contract | `HermesPlugin`, `PluginContext`, `PluginContribution`, `PluginStorage`, `PluginRestOptions`, `Contribution` |
| Area constants | `PANES_AREA`, `ROUTES_AREA`, `SIDEBAR_NAV_AREA`, `STATUSBAR_AREAS`, `TITLEBAR_AREAS`, `PALETTE_AREA`, `KEYBINDS_AREA`, `THEMES_AREA`, `COMPOSER_AREAS` |
| Area payloads | `RouteContribution`, `SidebarNavContribution`, `StatusbarItem`, `TitlebarTool`, `PaletteContribution`, `KeybindContribution`, `ComposerMiddleware`, `ComposerAttachmentProvider` |
| React / state | `useValue`, `atom`, `computed`, `useQuery`, `useMutation`, `useQueryClient`, `queryClient`, `Contribute` |
| UI kit | `Button`, `Input`, `Textarea`, `Select*`, `Switch`, `Checkbox`, `SegmentedControl`, `Tabs*`, `Dialog*`, `ConfirmDialog`, `DropdownMenu*`, `ContextMenu*`, `Popover*`, `Tip`/`Tooltip*`, `Badge`, `Kbd`/`KbdGroup`, `SearchField`, `ScrollArea`, `Separator`, `Skeleton`, `GlyphSpinner`, `Loader`, `EmptyState`, `ErrorState`, `CopyButton`, `StatusDot`, `LogView`, `Codicon`, `DecodeText` |
| Helpers | `cn`, `icons`, `haptic`, `useI18n`, `profileColor`, `profileColorSoft`, `relativeTime`, `fmtDateTime`, `fmtDayTime`, `coarseElapsed`, `evaluateRuntimeReadiness` |
The canonical, always-current export list is `apps/desktop/src/sdk/index.ts`.
### Agents: the `hermes-desktop-plugins` skill
When an agent writes a desktop plugin, it should load the bundled
**`hermes-desktop-plugins`** skill β it carries the same contract as this page in
agent-facing form, with a ready-to-copy `templates/plugin.js`. This page is the
human/developer reference; the skill is the working checklist.
## Troubleshooting
**My plugin doesn't appear.** Confirm the file is at
`$HERMES_HOME/desktop-plugins/<id>/plugin.js` and the folder name matches the
export `id`. Run βK β **Reload desktop plugins**. Check the app for an error
toast naming the failure, and tail `hermes logs gui -f`.
**"unsupported import" on load.** A disk plugin may only import
`@hermes/plugin-sdk`, `react`, and `react/jsx-runtime`. Remove any other import.
**A `jsx` element renders nothing / throws `ReferenceError`.** An identifier used
in a `jsx()` call isn't imported. Add it to the import line.
**`ctx.rest` returns 404.** The backend isn't mounted: confirm
`~/.hermes/plugins/<id>/dashboard/manifest.json` has `"api": "plugin_api.py"`,
that the plugin is in `plugins.enabled` in `config.yaml`, and restart the gateway
(backend routes mount at startup). Tail `~/.hermes/logs/errors.log` for
`Failed to load plugin <id> API routes`.
**`ctx.socket` never fires.** On an OAuth remote it's a no-op by design β use your
polling fallback. Otherwise verify the backend exposes the matching
`@router.websocket(...)` route under its namespace.
**Colors look wrong after a theme switch.** You hardcoded a color. Replace it with
a `var(--ui-*)` theme variable.
|