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.