A Kanban board opened in a split route tile rendered no board switcher: the board contributed it to WORKSPACE_PAGE_HEADER_AREA unconditionally, and only the workspace pane paints that area. The tile's contribution also leaked into another page's header and shared its id with the full page's, so closing the tile removed the page's switcher. Add WorkspacePageHeaderControl (exported via the plugin SDK). The workspace pane's render provides a private host context; inside it the control projects into the page header, anywhere else it renders inline. The board mounts BoardSwitcher once, through it, in its own header row. Fixes #123597 Originally authored by Justin Haynes (@jhaynes).
1616 lines
81 KiB
Markdown
1616 lines
81 KiB
Markdown
---
|
|
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/<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.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/<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, 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<void>` 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/<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 |
|
|
| **Unified package** | `$HERMES_HOME/plugins/<id>/desktop/plugin.js` | plugins that also ship agent-side code | none — same disk pipeline |
|
|
| **Bundled** | `apps/desktop/src/plugins/<id>/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:<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 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:<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
|
|
/** 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.<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>` |
|
|
| Page header | `WORKSPACE_PAGE_HEADER_AREA` | `<WorkspacePageHeaderControl>` 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 `<style>` tag, `html[data-*]` attributes, a `MutationObserver`)
|
|
runs its setup once per registration and its cleanup once at dispose — never
|
|
mid-navigation.
|
|
|
|
Controls that belong to ONE page (the Kanban board switcher) go in
|
|
`WORKSPACE_PAGE_HEADER_AREA` instead: it renders in the workspace panel's
|
|
tab-header row while that page is on screen and is empty otherwise. Wrap the
|
|
control in `<WorkspacePageHeaderControl>` (below) inside your page's own header
|
|
row. In the workspace pane it projects into the page header; when the page is
|
|
opened in a split route tile, which has no page header, it renders inline where
|
|
you placed it. A raw `<Contribute area={WORKSPACE_PAGE_HEADER_AREA}>` only
|
|
shows up in the workspace pane.
|
|
|
|
### 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 })
|
|
```
|
|
|
|
Registering a theme lists it; it does not select it. `useTheme()` reads the
|
|
painted appearance (`theme`, `themeName`, `availableThemes`, `resolvedMode`) and
|
|
changes it (`setTheme`, `setMode`, `previewTheme`) from a component:
|
|
|
|
```javascript
|
|
import { Button, useTheme } from '@hermes/plugin-sdk'
|
|
|
|
function ThemePicker() {
|
|
const { availableThemes, setTheme, themeName } = useTheme()
|
|
|
|
return availableThemes.map(t => (
|
|
<Button key={t.name} disabled={t.name === themeName} onClick={() => setTheme(t.name)}>
|
|
{t.label}
|
|
</Button>
|
|
))
|
|
}
|
|
```
|
|
|
|
A switch driven by something other than a render — a gateway connecting, a
|
|
socket event, any `host.onEvent` callback — has no component to hang the hook
|
|
on. Use `requestTheme(name)` there. An unresolvable name is refused rather than
|
|
coerced to the default skin, so the return value doubles as the availability
|
|
check and a wrong name can never silently reset someone's appearance:
|
|
|
|
```javascript
|
|
import { host, requestTheme } from '@hermes/plugin-sdk'
|
|
|
|
host.onEvent('gateway.ready', () => {
|
|
if (!requestTheme('noir')) {
|
|
host.notifyError('Connected, but the noir theme is not installed.')
|
|
}
|
|
})
|
|
```
|
|
|
|
Both doors persist per profile, so a plugin-driven switch sticks exactly like a
|
|
manual pick. To tint the *active* theme rather than replace it, use
|
|
`setAccentOverride(hex)` and clear it in `ctx.onDispose` — the standalone
|
|
[Accent Picker](https://github.com/NousResearch/hermes-desktop-accent-picker)
|
|
plugin is the worked example (it is also a complete, installable disk plugin).
|
|
|
|
### Composer extensions
|
|
|
|
`COMPOSER_AREAS` (`top`, `bottom`, `underside`, `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`). `top` is a
|
|
banner strip above the input and `bottom` a row below the input grid, both
|
|
inside the composer chrome; `underside` is the floating strip BELOW the whole
|
|
composer with no chrome of its own — the seat for a suggestion pill or a status
|
|
hint that should sit outside the input frame (the next-prompt plugin renders its
|
|
"next prompt" pill there).
|
|
|
|
### Composer draft API — read and write the live input
|
|
|
|
For everything the composer areas can't do — put text INTO the input, replace
|
|
what's there, read the current draft, or send it — use `host.composer`. This
|
|
is the supported door; reaching for the ProseMirror DOM, `[data-composer-target]`
|
|
lookups, or synthetic `InputEvent`s is out of the plugin surface (catalog rule 8)
|
|
and breaks the moment the app's markup moves. Addressing: `null` = the composer
|
|
the user is typing in; a session id (stored or runtime) = that session's
|
|
composer, in the primary pane or a tile; `'new'` = the fresh draft that has no
|
|
session id yet.
|
|
|
|
```javascript
|
|
import { host } from '@hermes/plugin-sdk'
|
|
|
|
// Append to the active composer (modes: 'block' | 'inline' | 'prefix';
|
|
// 'prefix' seats a slash command at the start). Acknowledged like setDraft:
|
|
// true when a mounted surface applied the text, false when the text is blank
|
|
// or no live surface answers for the address.
|
|
const inserted = await host.composer.insertText(null, 'draft note', { mode: 'inline' })
|
|
|
|
// Replace a session's whole draft — '@'-ref and '/command' tokens hydrate
|
|
// into chips exactly like an official paste. False when no mounted surface
|
|
// answers (an unmounted session is never half-written).
|
|
const ok = await host.composer.setDraft('sess-1', 'plan:\n- @file:src/app.ts')
|
|
|
|
// Read the live draft: the mounted surface's in-DOM text (unsaved keystrokes
|
|
// included), falling back to the debounced persisted stash. Null when nothing
|
|
// holds it.
|
|
const text = await host.composer.getDraft('sess-1')
|
|
|
|
// Send as if the user typed + pressed Enter. Fail-closed like the app's own
|
|
// panels: no visible surface for the address → false, never a broadcast.
|
|
const sent = host.composer.submit('sess-1', 'ship it')
|
|
|
|
// Put the caret in a composer (same addressing). insertText/setDraft already
|
|
// focus a visible surface they paint; use this to return the caret after a
|
|
// plugin popover closes or from a "go to input" keybind.
|
|
host.composer.focus(null)
|
|
```
|
|
|
|
```ts
|
|
host.composer: {
|
|
getDraft(sessionId: string | null): Promise<string | null>
|
|
setDraft(sessionId: string | null, text: string): Promise<boolean>
|
|
insertText(sessionId: string | null, text: string, opts?: { mode?: 'block' | 'inline' | 'prefix' }): Promise<boolean>
|
|
submit(sessionId: string | null, text: string): boolean
|
|
focus(sessionId: string | null): void
|
|
}
|
|
```
|
|
|
|
**Arbitration.** Every verb is fail-closed on its address: a request is
|
|
answered only by the mounted composer that owns that session (its tile, or the
|
|
primary pane when it shows that session); `null` is answered only by the surface
|
|
the app's focus bus currently routes to; `'new'` only by the primary pane while it
|
|
shows no session — it never falls through to the active composer. No exact
|
|
surface → `null`/`false`, never
|
|
a broadcast into whichever pane happens to be mounted. Writes go through the
|
|
app's own paint path, so `@`-ref / `/`-command tokens hydrate as chips and the
|
|
result is byte-for-byte what the user would get by pasting. These are discrete,
|
|
user-triggered actions with the same authority as typing — no plugin "owns" the
|
|
draft afterwards, so there is **nothing to tear down** on disable; a plugin that
|
|
wants a persistent presence around the input uses a `COMPOSER_AREAS` slot instead.
|
|
|
|
A multi-session plugin keeps its per-session state on its side (which session
|
|
its panel is editing) and passes that id here; the bus guarantees one
|
|
plugin write can never land in another session's composer.
|
|
|
|
**Migrating off DOM reach-in** (the held catalog plugins that motivated this API):
|
|
|
|
| Plugin | Was | Now |
|
|
|---|---|---|
|
|
| next-prompt (#120660) | `window.dispatchEvent(new CustomEvent('hermes:composer-insert', …))` + a `setTimeout` `hermes:composer-focus`; `[data-composer-target]`/`[data-pane-hidden]` scan for the visible target | `await host.composer.insertText(null, suggestion.text, { mode: 'block' })`, then `host.composer.focus(null)` if the pill lost the caret |
|
|
| prompt-snippets (#116030) | same `hermes:composer-insert` event; `[data-slot="composer-input"]`/ProseMirror `textContent` + synthetic `InputEvent` fallback; `surfaceEditorEl().focus()` | `host.composer.insertText(sid, text, { mode: 'block' })`; `setDraft(sid, (await getDraft(sid) ?? '') + '\n' + text)` replaces the fallback; `host.composer.focus(sid)` — `sid = host.state.focusedSessionId.get()` |
|
|
| prompt-enhancer (#116031) | walks the editor's child nodes to serialize, rebuilds chip DOM, `replaceChildren` + synthetic `InputEvent` | `const draft = await host.composer.getDraft(sid)` → transform → `await host.composer.setDraft(sid, enhanced)` (chips hydrate app-side); revert is another `setDraft` |
|
|
| memory-review (#115966) | `host.request('slash.exec', { session_id, command })` for `/memory …` — already SDK-only | optional: `host.composer.insertText(sid, '/memory pending', { mode: 'prefix' })` to seat the command for the user instead of executing it |
|
|
| intelligent-tool-break (#115964) | "Message" button only toasts "type /break" (no composer write) | `host.composer.setDraft(host.state.focusedSessionId.get(), '/break ')` then `host.composer.focus(null)` restores the intended behaviour |
|
|
|
|
`sessionId` in the table is the id the plugin's UI is bound to; for a composer
|
|
slot render it is `host.state.focusedSessionId.get()`.
|
|
|
|
### Session rows — decorations + the session list API
|
|
|
|
`SESSION_ROW_AREAS` (`leading`, `trailing`) let a plugin decorate sidebar
|
|
session rows. Register a `data` contribution whose `render({ sessionId })`
|
|
returns a small element (a badge, a swatch, a tag) or `null` for rows you don't
|
|
own — registering costs nothing on every other row:
|
|
|
|
```ts
|
|
import { SESSION_ROW_AREAS, type SessionRowSlotContribution } from '@hermes/plugin-sdk'
|
|
|
|
ctx.register({
|
|
area: SESSION_ROW_AREAS.trailing,
|
|
id: 'my-tag',
|
|
data: {
|
|
render: ({ sessionId }) => (owned.has(sessionId) ? <span className="my-tag">★</span> : null)
|
|
} satisfies SessionRowSlotContribution
|
|
})
|
|
```
|
|
|
|
Pair it with the session list API, which writes the same stores the app's own
|
|
controls write (so a plugin action and a hand click can never disagree):
|
|
|
|
```ts
|
|
host.sessions.pin(storedSessionId: string, pinned?: boolean, index?: number): void
|
|
host.sessions.reorder(storedSessionIds: string[]): void // Recents; [] = clear manual order → default sort
|
|
host.sessions.reorderPinned(storedSessionIds: string[]): void // Pinned section; omitted pins keep their slot
|
|
host.sessions.setColor(storedSessionId: string, color: string | null): void
|
|
```
|
|
|
|
Ids are STORED session ids: a live id is resolved to its durable lineage root,
|
|
so pins and colours survive compression's id rotation — and the row-decoration
|
|
slots hand your render that same durable id (`_lineage_root_id ?? id`), never
|
|
the live one. Resolution goes through the rows this window has loaded; an id
|
|
that matches no loaded row is written as given, so pass the slot's durable id
|
|
(not a live id you remembered) for a session that may have scrolled out of the
|
|
list. `reorder` accepts the same ids and maps each to its row's live id
|
|
internally — the Recents order store is keyed by the live id, like the drag
|
|
path. `pin(id, true, index)` slots the pin at that position in the
|
|
Pinned list (a drop target between two pins); without `index` it appends, like
|
|
the row's ⇧-click.
|
|
|
|
**Arbitration.** The verbs are discrete user-triggered edits of user data —
|
|
last write wins, exactly as if the user had clicked, and no plugin owns the
|
|
result afterwards. Slot contributions are ALL mounted (registration order, not
|
|
first-wins), each inside its own error boundary: a plugin that throws or
|
|
returns `null` for a row cannot suppress another plugin's decoration on it, and
|
|
two decorations on one row render side by side. Core keeps the row's layout,
|
|
gestures and title — slots augment, never replace.
|
|
|
|
**Teardown.** The verbs need none. Slot contributions are removed by the
|
|
`ctx.register` disposer (disable/reload drops them and the row re-renders
|
|
without the decoration).
|
|
|
|
Migration for the held catalog plugins:
|
|
|
|
- **drag-to-pin-session** — replace the `__reactFiber$*` walk for `onTogglePin`
|
|
/ `onReorderSessions` / `session._lineage_root_id` with the row's slot id
|
|
(`render: ({ sessionId }) => …` under `SESSION_ROW_AREAS.leading` gives you
|
|
the durable id per row), then `host.sessions.pin(sessionId, true, dropIndex)`
|
|
for a drop into the Pinned section, `host.sessions.pin(sessionId, false)` for
|
|
a drop back into Recents, `host.sessions.reorderPinned(ids)` for a drag
|
|
within the Pinned section, and `host.sessions.reorder([])` for its
|
|
"reset manual order" path.
|
|
- **better-session-appearance** — replace the `localStorage`
|
|
`hermes.desktop.sessionColors` write and the fiber-harvested `onChange` with
|
|
`host.sessions.setColor(sessionId, hex)` (`null` clears), and render its
|
|
per-row glyph through `SESSION_ROW_AREAS.leading` instead of mutating the
|
|
row's status dot (the durable id it needed from `_lineage_root_id` is the
|
|
slot's `sessionId`).
|
|
|
|
`COMPOSER_AREAS.modelPill` overrides the model pill's **label** — a provider
|
|
(`{ label: (ctx: ComposerModelPillContext) => string | null }`) receives
|
|
`{ model, reasoningEffort, compact }` and returns the text to show, or `null` to
|
|
let the next provider (then the core label) win. The pill keeps its chrome, pin
|
|
dot, and menu; only the label changes — the sanctioned replacement for the
|
|
MutationObserver text-rewriting plugins do today.
|
|
|
|
#### Model pill label providers
|
|
|
|
```ts
|
|
import { COMPOSER_AREAS, type ComposerModelPillContext, type ComposerModelPillProvider } from '@hermes/plugin-sdk'
|
|
|
|
interface ComposerModelPillContext {
|
|
model: string // the model slug the pill would show
|
|
reasoningEffort: string // the session's live effort level, '' when the model has none
|
|
compact: boolean // floating-composer mode: chevron only, providers are NOT consulted
|
|
}
|
|
interface ComposerModelPillProvider {
|
|
label: (ctx: ComposerModelPillContext) => string | null
|
|
}
|
|
|
|
ctx.register({
|
|
area: COMPOSER_AREAS.modelPill,
|
|
id: 'my-label',
|
|
data: { label: ({ model, reasoningEffort }) => reasoningEffort ? `${model} · ${reasoningEffort}` : null } satisfies ComposerModelPillProvider
|
|
})
|
|
```
|
|
|
|
**Arbitration.** Providers are consulted in registry order and the *first
|
|
non-empty string wins*; later providers are not called. Anything else declines
|
|
and the next provider is asked: `null`, `''`, a whitespace-only string, and any
|
|
non-string value (an object, array or number is never rendered — the label is
|
|
placed straight into JSX). A provider that **throws** also declines — the error
|
|
is swallowed and the pill falls through to the next provider, then to the core
|
|
label, so a broken plugin can never blank the pill. `reasoningEffort` is always
|
|
a `string` (`''` when the model has no effort level, never `undefined`). In
|
|
compact (floating) mode the pill renders only the chevron and no provider is
|
|
called. `label()` is re-evaluated only when the registry, the model, the effort
|
|
level or the compact flag changes.
|
|
|
|
**Teardown.** The provider is an ordinary data contribution: `ctx.register`
|
|
returns its disposer and the loader drops it when the plugin is disabled or
|
|
reloaded, at which point the core label is restored. There is nothing to undo
|
|
in `ctx.onDispose`.
|
|
|
|
**Migrating compact-reasoning-label.** The plugin used to find the pill via
|
|
`[data-slot="composer-root"] button span.truncate`, regex-strip a trailing
|
|
effort word from `span.textContent`, and re-run that sweep from a body-wide
|
|
`MutationObserver` plus a 1 s `setInterval`. On current builds the core label no
|
|
longer contains the effort word (the level has its own `ReasoningPill`), so the
|
|
strip is a no-op; the sanctioned shape is to compute the label from the context
|
|
instead of editing rendered text:
|
|
|
|
```js
|
|
register(ctx) {
|
|
ctx.register({
|
|
area: COMPOSER_AREAS.modelPill,
|
|
id: 'compact-reasoning-label',
|
|
// Decline (null) whenever there is nothing to change so the core label wins.
|
|
data: { label: ({ model }) => shorten(model) ?? null }
|
|
})
|
|
// No MutationObserver, no setInterval, no injected <style>: the contribution is
|
|
// disposed with the plugin.
|
|
}
|
|
```
|
|
|
|
The reasoning-pill visibility CSS the plugin also injected has no hook; it is
|
|
only needed if the app ever hides that label at narrow widths.
|
|
|
|
### Appearance settings
|
|
|
|
`APPEARANCE_AREAS.extra` renders contributions at the end of **Settings →
|
|
Appearance**, after the built-in sections. It is the seam for a plugin that
|
|
used to inject nodes into that page or drive its widgets through React
|
|
internals.
|
|
|
|
```ts
|
|
APPEARANCE_AREAS = { extra: 'appearance.extra' } as const
|
|
|
|
ctx.register({
|
|
area: APPEARANCE_AREAS.extra,
|
|
id: 'session-colour-rules', // unique within your plugin
|
|
render: () => <MyAppearanceCard /> // any React tree; SDK hooks allowed
|
|
})
|
|
```
|
|
|
|
*Arbitration:* every registration mounts, in registry order, each inside its
|
|
own error boundary — a contribution that throws collapses to an inline error
|
|
card naming its `id` (with Retry) and the rest of the page (and other plugins'
|
|
cards) keep rendering. The slot mounts on the top-level Appearance page only,
|
|
not on deep-link subpages (`settings/appearance/<section>`), and there is no
|
|
"first wins" — plugins cannot suppress each other here.
|
|
|
|
*Teardown:* the registration is owned by the plugin loader; disabling or
|
|
reloading the plugin disposes it and the card disappears on the next render.
|
|
Nothing persists app-side, so there is nothing to clean up in `ctx.onDispose`.
|
|
|
|
For colour picking use the app's own swatch grid — `ColorSwatches` (already an
|
|
SDK export) renders exactly what the profile rail and project dialog render,
|
|
with your own `onChange`; feed it `PROFILE_SWATCHES` and pair it with
|
|
`host.sessions.setColor(id, color)` for session colours.
|
|
|
|
Migrations for the plugins that motivated this slot:
|
|
|
|
* **better-session-appearance** — replace the fiber walk that harvests the
|
|
Appearance submenu's `{ onChange, swatches }` and the `clearBtn.after(...)` /
|
|
`host.appendChild(panel)` injection into the app dropdown with one
|
|
`ctx.register({ area: APPEARANCE_AREAS.extra, id: 'rules', render })` whose
|
|
card renders `<ColorSwatches swatches={PROFILE_SWATCHES} value onChange />`
|
|
plus its bold/glyph/auto-rule controls; drop the `data-better-session-appearance`
|
|
attribute writes and the dropdown `max-height` overrides.
|
|
* **hermes-appearance-hub** — mount its paper-texture / font / intro-copy
|
|
controls as an `APPEARANCE_AREAS.extra` card instead of a status-bar menu
|
|
that reaches into Settings; the settings *values* still go through
|
|
`host.settings` (allowlisted keys) and `THEMES_AREA`.
|
|
|
|
### Embedding external content
|
|
|
|
Use the SDK's `<SandboxedFrame src title />` for any external web content
|
|
(reader views, dashboards, docs). It renders a sandboxed iframe with the app's
|
|
guest-content posture: opaque origin, `allow-scripts` by default, `no-referrer`,
|
|
lazy loading. Never mount a raw Electron `<webview>`: it lands on the app's
|
|
`persist:` preview partition, sharing the app's cookies and storage.
|
|
|
|
```ts
|
|
interface SandboxedFrameProps {
|
|
src: string // absolute http(s): or data: URL; any other scheme renders nothing (console.warn)
|
|
title: string // required — an untitled frame is unlabelled in the a11y tree
|
|
sandbox?: string // extra tokens; filtered through the allowlist below
|
|
className?: string; style?: CSSProperties
|
|
onLoad?, onError?: ReactEventHandler<HTMLIFrameElement>
|
|
ref?: Ref<HTMLIFrameElement>
|
|
}
|
|
```
|
|
|
|
The props are an explicit allowlist, not `ComponentProps<'iframe'>`: `allow`
|
|
(Permissions-Policy delegation — would hand a third-party site the mic/camera
|
|
grant the app holds), `srcdoc`, `name`, `allowFullScreen`, `csp`,
|
|
`credentialless` and every other iframe attribute are not props and nothing is
|
|
spread onto the element, so they cannot reach the DOM even through a cast.
|
|
|
|
*Arbitration (allowlist, not blocklist):* the only tokens a caller may add are
|
|
`allow-scripts`, `allow-forms`, `allow-downloads`, `allow-pointer-lock`,
|
|
`allow-orientation-lock`, `allow-presentation`. Everything else —
|
|
`allow-same-origin`, `allow-top-navigation*`, `allow-popups*`, `allow-modals`,
|
|
`allow-storage-access-by-user-activation`, and any token the primitive does not
|
|
know — is dropped case-insensitively even if passed; an emptied set falls back
|
|
to the default posture (`allow-scripts`), because a frame with **no** `sandbox`
|
|
attribute is fully privileged. `loading="lazy"` and
|
|
`referrerPolicy="no-referrer"` are not props. The opaque origin IS the
|
|
containment: guest content cannot reach the app, its storage, or the preload
|
|
bridge.
|
|
|
|
*Teardown:* it is a plain React element — unmounting your pane/page removes the
|
|
frame and its realm; nothing is registered app-side.
|
|
|
|
Migration for **rss-reader** (#115972): replace the stubbed `/preview` → 501 →
|
|
`host.openWorkspace('rss-browser')` → empty `RssBrowserFrame` → `ctx.os.openExternal`
|
|
chain with `<SandboxedFrame src={article.url} title={article.title} />` inside
|
|
the workspace page; drop the leftover `.rss-browser-frame-host webview` CSS.
|
|
|
|
### Transcript directives — inline components the model addresses
|
|
|
|
`TRANSCRIPT_DIRECTIVE_AREA` makes the transcript itself a contribution area.
|
|
Register a named directive and the agent can render your component inline in
|
|
an assistant message by emitting a paragraph of the form `::name{key="value"}`:
|
|
|
|
```javascript
|
|
import { TRANSCRIPT_DIRECTIVE_AREA } from '@hermes/plugin-sdk'
|
|
|
|
ctx.register({
|
|
id: 'task-card',
|
|
area: TRANSCRIPT_DIRECTIVE_AREA,
|
|
data: {
|
|
name: 'task', // the model writes ::task{id="BB-12"}
|
|
render: ({ attrs, streaming }) => jsx(TaskCard, { taskId: attrs.id, streaming })
|
|
}
|
|
})
|
|
```
|
|
|
|
Rules the host enforces so the surface stays safe:
|
|
|
|
- The directive must be the **entire paragraph** — `::name` mid-prose stays
|
|
prose, so plugin components can never hijack running text.
|
|
- Attributes are **untrusted model output** (`key="value"` pairs, string-only).
|
|
Validate your own fields; render nothing on garbage rather than guessing.
|
|
- An **unclaimed** directive (no plugin registered for the name) renders as
|
|
the plain paragraph it always was — nothing breaks when a plugin is off.
|
|
- Renders are wrapped in the contribution error boundary: a throw degrades to
|
|
an inline error chip, never a dead message.
|
|
- First registration wins on a name collision; namespace adventurous names
|
|
with your slug (`myplugin-board`, not `board`).
|
|
|
|
Core ships one directive as the reference consumer: `::preview{file="…"}`
|
|
renders the workspace HTML file **live inside the message** — a sandboxed
|
|
`srcdoc` iframe with an opaque origin (scripts run and the widget is fully
|
|
interactive; no reach into the app, its storage, or the bridge). The frame
|
|
sizes itself to the content (height live, width adopted from the content's
|
|
intrinsic span, flush left in the message flow), and a theme prelude hands
|
|
the document the app's resolved tokens (`--foreground`, `--muted-foreground`,
|
|
`--accent`, `--border`, `--card`), the app font, and a transparent
|
|
background — so widget-shaped HTML reads as native while a full page keeps
|
|
its own design. Non-HTML targets and remote gateways fall back to the
|
|
classic preview card. Tell the agent about your directive in a skill (that's
|
|
how it learns to emit it).
|
|
|
|
Previewed widgets can also **talk back**. Inside the frame,
|
|
`window.hermes.send('get-price eth')` (or a declarative
|
|
`<button data-hermes-send="get-price eth">` — no script needed) hands that
|
|
prompt to the agent as a user turn, off-screen: no bubble takes up the
|
|
transcript, the widget updating is the visible response. The turn is still
|
|
real — it wakes the agent, rides the composer's steer/queue rules, and
|
|
persists (typed `hidden`) so resume and the session DB keep the full record.
|
|
Prompts are trimmed, capped at 500 chars, and throttled to one per second
|
|
per frame.
|
|
|
|
### 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 header control
|
|
leaves when the page unmounts), render `<Contribute>` inside it instead:
|
|
|
|
```javascript
|
|
import { Contribute, WORKSPACE_PAGE_HEADER_AREA } from '@hermes/plugin-sdk'
|
|
|
|
jsx(Contribute, {
|
|
area: WORKSPACE_PAGE_HEADER_AREA,
|
|
id: 'my-page:switcher', // namespace with your slug
|
|
children: jsx(MySwitcher, {})
|
|
})
|
|
```
|
|
|
|
It registers on mount and disposes on unmount automatically.
|
|
|
|
For a page-header control, use `WorkspacePageHeaderControl` instead. It picks
|
|
the placement from where the page renders: in the workspace pane it
|
|
contributes to `WORKSPACE_PAGE_HEADER_AREA`, and anywhere else (a split route
|
|
tile) it renders its children in place. Put it where the control should sit
|
|
when inline:
|
|
|
|
```javascript
|
|
import { WorkspacePageHeaderControl } from '@hermes/plugin-sdk'
|
|
|
|
jsx(WorkspacePageHeaderControl, {
|
|
id: 'my-page:switcher', // namespace with your slug
|
|
children: jsx(MySwitcher, {})
|
|
})
|
|
```
|
|
|
|
`WorkspacePageHeaderControl` is new in this release. A plugin that must also
|
|
run on older desktop builds, where the import is `undefined`, keeps the raw
|
|
`Contribute` form above.
|
|
|
|
### Sidebar nav visibility and order (`SIDEBAR_NAV_PREFS_AREA`)
|
|
|
|
A plugin hides or re-orders the sidebar's top nav rows by **contributing a
|
|
preference**, not by writing a setting. Core merges every `sidebarNav.prefs`
|
|
contribution at render and applies the result to the rows it would otherwise
|
|
show; the default list itself never changes.
|
|
|
|
```ts
|
|
import { SIDEBAR_NAV_PREFS_AREA, type SidebarNavPrefsContribution } from '@hermes/plugin-sdk'
|
|
|
|
// Payload (`data`) of a sidebarNav.prefs contribution
|
|
interface SidebarNavPrefsContribution {
|
|
hide?: string[] // rows to drop
|
|
order?: string[] // rows to place first, in this order
|
|
}
|
|
|
|
ctx.register({
|
|
id: 'prefs',
|
|
area: SIDEBAR_NAV_PREFS_AREA,
|
|
data: { hide: ['cron'], order: ['capabilities', 'new-session'] } satisfies SidebarNavPrefsContribution
|
|
})
|
|
```
|
|
|
|
Nav ids are the rows' own ids. Core rows: `new-session`, `capabilities`,
|
|
`messaging`, `artifacts`, `cron` (the `SidebarNavId` type; `artifacts` and
|
|
`cron` only render in Advanced mode). A contributed row's id is its
|
|
**registered** `SIDEBAR_NAV_AREA` id, which `ctx.register` namespaces to
|
|
`${pluginId}:${id}` — a plugin that registered `{ id: 'kanban-nav', area:
|
|
SIDEBAR_NAV_AREA }` as `kanban` names that row `'kanban:kanban-nav'` in
|
|
`hide`/`order`.
|
|
|
|
**Arbitration.** Hidden rows are the **union** of every contribution's `hide`
|
|
(no plugin can un-hide another's row; hide beats order), except
|
|
`capabilities`: the row hosting the Plugins tab is the user's path to a
|
|
plugin's own off-switch, so it can be moved but never hidden. Contributions
|
|
apply in the registry's area order — **lowest `order`, then registration** —
|
|
and the first one's `order` wins: later contributions place only ids not yet
|
|
placed, rows no order names keep their default relative order after the
|
|
named ones. Unknown ids are inert.
|
|
|
|
**Teardown.** The contribution lives in the registry, so disabling or reloading
|
|
the plugin disposes it and the rows come straight back — nothing to clear.
|
|
This is why it is not a `host.sidebar.hide()` verb: `host` is a singleton that
|
|
cannot attribute a write, a persisted preference would outlive the plugin, and
|
|
two plugins would overwrite each other's order.
|
|
|
|
**Persisting the user's choice** is the plugin's job, in its own
|
|
`ctx.storage`: read the saved prefs on `register`, contribute them, and on every
|
|
edit save + dispose + re-contribute (re-registering the same `id` replaces it).
|
|
|
|
```ts
|
|
// sidebar-manager: replaces `[data-sbm-off] { display:none }` + re-parenting <li>s
|
|
let dispose = () => {}
|
|
const apply = (prefs: SidebarNavPrefsContribution) => {
|
|
dispose()
|
|
dispose = ctx.register({ id: 'prefs', area: SIDEBAR_NAV_PREFS_AREA, data: prefs })
|
|
}
|
|
apply(ctx.storage.get('navPrefs', {}))
|
|
// in the editor's onChange:
|
|
ctx.storage.set('navPrefs', next); apply(next)
|
|
```
|
|
|
|
Session sections (Pinned, Recents, Cron jobs) are not covered — nav rows only.
|
|
|
|
## 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.awaitingResponse // ReadableAtom<boolean> true until the first assistant payload
|
|
host.state.busy // ReadableAtom<boolean> focused chat is working after a send
|
|
host.state.busyBySession // ReadableAtom<Record<string, boolean>> runtime id → mid-turn
|
|
host.state.focusedSessionId // ReadableAtom<string | null> (runtime id of the FOCUSED session — tile-aware; prefer for session.* RPC)
|
|
host.state.focusedSessionProfile // ReadableAtom<string> (owner profile of the focused chat — prefer over `profile` for per-bot/profile readouts)
|
|
host.state.focusedStoredSessionId // ReadableAtom<string | null> (durable id — navigation / session-list matching)
|
|
host.state.focusedUsage // ReadableAtom<UsageStats | null> (live streamed usage of the focused session, no RPC needed)
|
|
host.state.cwd // ReadableAtom<string>
|
|
host.state.gateway // ReadableAtom<string> socket state ('idle' | 'connecting' | 'open' | …)
|
|
host.state.model // ReadableAtom<string>
|
|
host.state.profile // ReadableAtom<string>
|
|
host.state.viewport // ReadableAtom<{ width, height, narrow }>
|
|
```
|
|
|
|
`host.state.gateway` is the WebSocket connection, not whether a chat turn is
|
|
running. A session can be mid-turn while the socket is `open`; another session
|
|
can be idle at the same time. Disable composer or plugin actions from the
|
|
**focused session's** turn-busy (`host.state.busyBySession[sessionId]`, or that
|
|
session's `view.$busy`) — never from `gateway`, and never from a process-global
|
|
busy flag.
|
|
|
|
```ts
|
|
host.notify({ kind, message, title?, detail?, action? }) // toast; returns id
|
|
host.notifyError(error, fallbackMessage) // toast an error
|
|
ctx.os.notify({ title, body?, silent?, icon?, activate?, onActivate?, actions? })
|
|
// native OS notification (attributed to your plugin)
|
|
ctx.os.openExternal(url) // OS default handler (browser, mail, spotify:) → Promise<boolean>
|
|
ctx.os.revealPath(path) // reveal in Finder / Explorer → Promise<boolean>
|
|
ctx.os.writeClipboard(text) // system clipboard → Promise<boolean>
|
|
host.navigate('/route') // hash-route navigation
|
|
host.openSession(id, { profile?, intent? }) // open a stored session core-style;
|
|
// profile: soft-swap to that profile's backend first
|
|
// intent: 'in-place' (default) | 'stack' | 'tab' | 'window'
|
|
host.newChat(profile?) // fresh chat draft, optionally in another profile
|
|
host.openWorkspace(id, { render, title?, minWidth?, onClose? })
|
|
// dock a plugin-rendered tab into the MAIN
|
|
// workspace zone and reveal it; returns a disposer
|
|
host.paneVisibility(paneId) // ReadableAtom<boolean> — is a contributed pane
|
|
// actually on screen (its zone's active tab)?
|
|
host.onEvent(type, fn) // gateway event stream ('*' = all); returns disposer.
|
|
// Calls made during register() are retired with the
|
|
// plugin; elsewhere prefer ctx.onEvent (always tracked)
|
|
host.logs(...) // tail an app log file
|
|
host.status() // one-shot system status snapshot
|
|
host.restartGateway() // restart the backend gateway
|
|
host.profileRoutes() // [{ profile, targetProfile, connectionId, mode }]
|
|
host.requestProfile<T>(route, method, params?, timeoutMs?, { spawnPriority? }) // registry-routed RPC; no foreground swap
|
|
host.requestProfile<T>(profile, method, params?) // legacy v1/local overload
|
|
host.request<T>(method, params?) // active-gateway JSON-RPC — the real power
|
|
host.sessions.pin(storedSessionId, pinned?, index?) // pin/unpin (default pinned=true); index = slot in Pinned;
|
|
// same store the row's ⇧-click / drop writes
|
|
host.sessions.reorder(ids) // replace the manual Recents order (what a drag persists); [] resets
|
|
host.sessions.reorderPinned(ids) // permute the Pinned section (the pinned drag path)
|
|
host.sessions.setColor(storedSessionId, color | null) // per-session colour override; null clears
|
|
host.skills.list(profile?) // every skill for the scope (Capabilities endpoints)
|
|
host.skills.setEnabled(name, on, profile?) // enable/disable a skill — the Capabilities toggle
|
|
host.toolsets.list(profile?) // toolsets + enabled state
|
|
host.toolsets.setEnabled(name, on, profile?)// enable/disable a toolset
|
|
host.profiles.list(scope?: ProfileScope) // the profile list the profile rail reads
|
|
host.pluginDecisions // READ-ONLY atom: this window's plugin on/off decisions (frozen copies)
|
|
```
|
|
|
|
`host.request` is the same JSON-RPC the app itself uses (sessions, config, skills,
|
|
cron, kanban, …). `host.requestProfile` accepts a descriptor from
|
|
`host.profileRoutes()` and routes that RPC through its exact registry source and
|
|
profile without changing the active chat or gateway. The profile-only overload is
|
|
retained only for the sole-local/legacy topology; registry-aware plugins should pass
|
|
the descriptor so two sources exposing the same profile name cannot collide.
|
|
|
|
A call that may cold-start a pooled profile backend dials at background priority by
|
|
default, and background dials never get the slot the pool keeps free for user actions.
|
|
When the call IS a user action (a save, a button press, a dialog opening), pass
|
|
`host.requestProfile(route, method, params, undefined, { spawnPriority: 'foreground' })`;
|
|
otherwise, with the pool full of warm backends, it waits out the 30-second slot timeout
|
|
and fails. Keep the background default for polling and roster warming.
|
|
|
|
`host.openWorkspace(id, { render, title?, minWidth?, onClose? })` docks a
|
|
plugin-rendered view into the **main workspace zone** — the same center area
|
|
session tiles and previews use — as a tab, and reveals it. Re-calling it with
|
|
the same `id` refreshes the content in place and re-fronts the tab instead of
|
|
opening a duplicate. Closing the tab (the tab's Close control or ⌘W) tears the
|
|
registration down and fires your `onClose`; the returned disposer closes it
|
|
programmatically. Feature-detect it (`typeof host.openWorkspace ===
|
|
'function'`) and fall back to a regular contributed pane on older desktop
|
|
builds — Bot Mode's group-chat rooms are the reference consumer (main-window
|
|
takeover when available, in-panel view otherwise).
|
|
|
|
`host.paneVisibility(paneId)` returns a readonly reactive atom that is `true`
|
|
while a contributed pane is actually on screen: present in the layout tree,
|
|
not dismissed or hidden, its zone un-minimized, and holding its zone's active
|
|
tab slot (a lone pane in its own zone counts). The id is the
|
|
contribution-scoped pane id, `<pluginId>:<paneId>`. Atoms are memoized per id,
|
|
so calling it in render is safe. Use it to register companion UI only while
|
|
your pane is visible — Bot Mode's Cronjobs pane is the reference consumer: it
|
|
registers while the Bots pane holds the sidebar tab and unregisters when the
|
|
user tabs back to Sessions. Feature-detect on older desktops
|
|
(`typeof host.paneVisibility === 'function'`) and fall back to
|
|
always-registered behavior.
|
|
|
|
`host.profileRoutes()` inventories every registered source in the current connection
|
|
registry. Connect-on-demand SSH sources expose a credential-free `default` seed
|
|
route without opening a tunnel, so a plugin can be the first caller that dials them;
|
|
an SSH `remoteProfile` remains the route's backend `targetProfile`. `connectionId`
|
|
is the registry routing identity;
|
|
pair it with `profile` for keys and persistence. Endpoint, token, SSH host/key, and
|
|
other raw connection fields never cross the plugin IPC boundary. `profile` is the
|
|
source-local route used
|
|
for requests; `targetProfile` is the backend Hermes profile served by that route.
|
|
They differ when a route explicitly maps to another backend profile (for example an
|
|
SSH `remoteProfile` override or a legacy per-profile URL alias). This distinction
|
|
preserves backend identity without exposing connection secrets.
|
|
|
|
Profile-shaped plugins get first-class methods too:
|
|
`profiles.list` (each profile + its most recent conversation as
|
|
`last_session`; pass `include_sessions: false` to skip the per-profile DB
|
|
probe; pass `preferred_session_ids: { profileName: sessionId }` for an
|
|
exact, existence-checked lookup of one pinned session per profile — each
|
|
named row gains a `preferred_session` summary that resolves hidden rows
|
|
and compression lineages to their live tip, or `null` when the id is
|
|
definitively gone; older gateways ignore the param and omit the field)
|
|
and `profiles.create` (`name`, `description`, `clone_from`,
|
|
`clone_all`, `no_skills`, `soul`, optional `model` + `provider` pin) — the
|
|
ws twins of the dashboard's `/api/profiles` REST routes.
|
|
`host.state.busy` is the focused chat's live turn (thinking and streaming).
|
|
`host.state.awaitingResponse` stays true from send until the first assistant
|
|
payload. Both follow the chat the user is actually looking at — the focused
|
|
session tile when one holds focus, else the primary workspace chat (the same
|
|
signal the statusbar's busy pulse reads). Subscribe in a component:
|
|
|
|
```javascript
|
|
const busy = useValue(host.state.busy)
|
|
```
|
|
|
|
For token-level detail, listen with `host.onEvent` (`message.start`,
|
|
`message.delta`, `message.complete`).
|
|
|
|
`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.
|
|
|
|
`ctx.os` is the curated OS door — every way a plugin reaches outside the app
|
|
window, in one namespace attributed to your plugin. `ctx.os.notify` posts a
|
|
**native OS notification** — the same Electron pipeline the app's own
|
|
approval/turn alerts use. It fires only while the user is away from Hermes
|
|
(backgrounded / unfocused); use `host.notify` for the in-app toast when
|
|
they're looking at the app. Users can silence it per device under Settings ▸
|
|
Notifications ▸ "Plugin notifications", and repeats from the same plugin are
|
|
throttled, so treat it as a signal for genuinely notable events — not a log.
|
|
|
|
Rich presentation + activation (extends the original `ctx.os` door):
|
|
|
|
```ts
|
|
ctx.os.notify({
|
|
title: 'New match found',
|
|
body: 'Someone matched your signal',
|
|
icon: '/abs/path/to/icon.png', // Electron Notification icon
|
|
// Body click → focus Hermes + navigate. Same vocabulary as OS deep links:
|
|
activate: 'hermes://index-network/intent/1',
|
|
// or: activate: '/index-network/intent/1'
|
|
// or: activate: { path: '/index-network/intent/1' }
|
|
onActivate: () => focusLocalState('1'), // optional renderer callback
|
|
actions: [
|
|
{ id: 'open', label: 'Open', activate: 'hermes://index-network/intent/1' },
|
|
{ id: 'dismiss', label: 'Dismiss', onAction: () => dismiss('1') },
|
|
],
|
|
})
|
|
```
|
|
|
|
`activate` is deeplink-compatible: `hermes://index-network/intent/1` and the
|
|
hash path `/index-network/intent/1` resolve to the same in-app route (and the
|
|
same `hermes://…` URL works as an OS deep link). Action buttons only render on
|
|
signed macOS builds; elsewhere the body click still activates. Navigation only
|
|
happens on user click — never from a background event alone.
|
|
|
|
The other doors (`openExternal`, `revealPath`, `writeClipboard`) resolve
|
|
`false` instead of throwing when the capability isn't available (older desktop
|
|
shell, plain browser) — branch on the result rather than sniffing the bridge.
|
|
|
|
### Desktop appearance settings — `host.settings`
|
|
|
|
`host.settings` is the supported door for the small set of Desktop-local
|
|
appearance preferences plugins may share with the native Settings page. Every
|
|
key is bound to the store atom + setter the Settings page itself uses, so a
|
|
plugin write is exactly a user click on that control: it takes effect at once,
|
|
persists through the preference's existing storage schema, and the last write
|
|
wins (no plugin "owns" the value afterwards, nothing to tear down for `set`).
|
|
|
|
```ts
|
|
type DesktopSettingValues = {
|
|
'backdrop.v1': boolean
|
|
'composerPopout.gesturesEnabled': boolean
|
|
'intro-splash.v1': boolean
|
|
'reasoning.collapsedByDefault': boolean
|
|
sessionListDensity: 'compact' | 'comfortable' | 'detailed'
|
|
tabStripDefault: 'auto' | 'always' | 'never'
|
|
}
|
|
host.settings.get<K extends DesktopSettingKey>(key: K): DesktopSettingValues[K]
|
|
host.settings.set<K extends DesktopSettingKey>(key: K, value: DesktopSettingValues[K]): void
|
|
host.settings.subscribe<K extends DesktopSettingKey>(key: K, fn: (value: DesktopSettingValues[K]) => void): () => void
|
|
```
|
|
|
|
```ts
|
|
register(ctx) {
|
|
host.settings.set('sessionListDensity', 'detailed')
|
|
|
|
// subscribe emits the current value now, then after every native or plugin write.
|
|
const dispose = host.settings.subscribe('backdrop.v1', enabled => { /* … */ })
|
|
// Teardown rule: `host` is a module singleton and cannot tell which plugin
|
|
// subscribed, so YOU retire the listener — otherwise it outlives a disable/reload.
|
|
ctx.onDispose(dispose)
|
|
}
|
|
```
|
|
|
|
Arbitration: the allowlist above is closed. An unknown key or a value outside
|
|
the key's type throws **synchronously** (`Unsupported desktop setting: …` /
|
|
`Invalid value for desktop setting: …`) and nothing is written — `host.settings`
|
|
never touches `localStorage` directly, so it cannot bypass a store's schema or
|
|
migration. Feature-detect `host.settings` when supporting older Desktop builds.
|
|
|
|
Deliberately **not** keys, and why:
|
|
|
|
| Wanted | Use instead | Why not a raw key |
|
|
|--------|-------------|-------------------|
|
|
| keybind map (`hermes.desktop.keybinds`) | `KEYBINDS_AREA` contribution | a raw map write rebinds every other plugin's shortcuts; the area merges per plugin and is torn down with it |
|
|
| active theme / mode record | `THEMES_AREA` (register a theme; the user selects it) | theme selection is per window/profile and arbitrated by the app, not a flat preference |
|
|
| `pluginDecisions` (desktop plugin on/off) | the app's Plugins tab (a read-only view is a separate SDK hook) | a plugin toggling another plugin's enable state is plugins interfering with each other |
|
|
| `toolView.technical`, `embed-mode`, `titlebarAppActions`, `translucency.v2`, `user-bubble-transparency.v1`, `hermesDesktop.zoom.*` | follow-up keys after each store is audited | some drive the main process or window chrome; each needs its own guard and ownership review before it becomes plugin-writable |
|
|
|
|
Migration — `hermes-appearance-hub`, which today does
|
|
`localStorage.setItem('hermes.desktop.sessionListDensity', id)` followed by
|
|
`window.dispatchEvent(new StorageEvent('storage', …))` to wake the app's store
|
|
(`readSimpleKey`/`writeSimpleKey`, `readBoolKey`/`writeBoolKey`):
|
|
|
|
```ts
|
|
// before
|
|
localStorage.setItem('hermes.desktop.backdrop.v1', String(on))
|
|
window.dispatchEvent(new StorageEvent('storage', { key: 'hermes.desktop.backdrop.v1', newValue: String(on) }))
|
|
// after — the store notifies its own subscribers; no synthetic StorageEvent
|
|
host.settings.set('backdrop.v1', on)
|
|
host.settings.set('sessionListDensity', id) // was hermes.desktop.sessionListDensity
|
|
host.settings.set('tabStripDefault', id) // was hermes.desktop.tabStripDefault
|
|
host.settings.set('reasoning.collapsedByDefault', on) // was hermes.desktop.reasoning.collapsedByDefault
|
|
host.settings.set('composerPopout.gesturesEnabled', on)
|
|
host.settings.set('intro-splash.v1', mode !== 'off') // replaces clicking #setting-field-appearance.intro-splash
|
|
```
|
|
|
|
Reads become `host.settings.get(key)`; its `MutationObserver` on the Settings
|
|
page's intro-splash switch becomes `host.settings.subscribe('intro-splash.v1', fn)`
|
|
(disposer → `ctx.onDispose`). `prompt-snippets` reads
|
|
`localStorage.getItem('hermes.desktop.keybinds')` to back up its shortcut — that
|
|
is the keybind-map row above: contribute the default through `KEYBINDS_AREA` and
|
|
keep the user's override in `ctx.storage`, not in the app's map.
|
|
|
|
### Typed capabilities bridge — `host.skills`, `host.toolsets`, `host.profiles`, `host.pluginDecisions`
|
|
|
|
```ts
|
|
type ProfileScope = undefined | null | string | { connectionId?: null | string; profile?: null | string }
|
|
|
|
host.skills.list(profile?: ProfileScope): Promise<SkillInfo[]>
|
|
host.skills.setEnabled(name: string, enabled: boolean, profile?: ProfileScope): Promise<{ ok: boolean; name: string; enabled: boolean }>
|
|
host.toolsets.list(profile?: ProfileScope): Promise<ToolsetInfo[]>
|
|
host.toolsets.setEnabled(name: string, enabled: boolean, profile?: ProfileScope): Promise<{ ok: boolean; name: string; enabled: boolean }>
|
|
host.profiles.list(scope?: ProfileScope): Promise<{ profiles: ProfileInfo[] }>
|
|
host.pluginDecisions: ReadableAtom<Record<string, boolean>> // get() / subscribe() / listen() — no set()
|
|
```
|
|
|
|
These wrap the **same `api/*` module functions the Capabilities page calls**
|
|
(`GET /api/skills`, `PUT /api/skills/toggle`, `GET /api/tools/toolsets`,
|
|
`PUT /api/tools/toolsets/<name>`, `GET /api/profiles`) with the page's profile
|
|
scoping. Omit `profile` to act on the app-wide active profile; pass a name or a
|
|
`{ connectionId, profile }` route to configure another profile without swapping
|
|
the foreground one. Nothing new is arbitrated: every call is already reachable
|
|
through `host.request` — the value is typing plus profile scoping, so stop
|
|
calling `window.hermesDesktop.api` raw.
|
|
|
|
`host.pluginDecisions` mirrors the app's plugin enable/disable map (plugin id →
|
|
`true`/`false`; an absent id means the user never chose and the plugin's own
|
|
`defaultEnabled` applies). It is **read-only by design**: a `set()` would let one
|
|
plugin flip another plugin's enable state — exactly "plugins messing with each
|
|
other's functionality" — and `host` is a module singleton that cannot tell which
|
|
plugin is calling to restrict a writer to the caller's own id. The object has no
|
|
`set` at runtime, not just in the types, and every value it hands out (`get()`,
|
|
`.value`, the argument to `subscribe`/`listen` callbacks) is a **frozen copy** —
|
|
assigning into it throws instead of leaking into the map the app reads and
|
|
persists. Enabling/disabling plugins stays in the
|
|
app's Plugins tab; link to it with `host.navigate('/capabilities?tab=plugins')`.
|
|
|
|
Teardown: the verbs are discrete user-triggered actions that write the same
|
|
backend state the page writes, so nothing is owned afterwards and there is
|
|
nothing to tear down. A `subscribe()` on `host.pluginDecisions` returns its
|
|
disposer — register it with `ctx.onDispose` so a disabled or reloaded plugin
|
|
stops listening.
|
|
|
|
Migration (better-capabilities):
|
|
|
|
```ts
|
|
// before // after
|
|
desktopApi({ path: '/api/skills' }) host.skills.list()
|
|
desktopApi({ path: '/api/skills/toggle', method: 'PUT', host.skills.setEnabled(name, enabled)
|
|
body: { name, enabled } })
|
|
desktopApi({ path: '/api/tools/toolsets' }) host.toolsets.list()
|
|
desktopApi({ path: `/api/tools/toolsets/${name}`, host.toolsets.setEnabled(name, enabled)
|
|
method: 'PUT', body: { enabled } })
|
|
desktopApi({ path: '/api/profiles' }) host.profiles.list()
|
|
JSON.parse(localStorage.getItem( host.pluginDecisions.get()
|
|
'hermes.desktop.pluginDecisions.v2')) ctx.onDispose(host.pluginDecisions.subscribe(fn))
|
|
localStorage.setItem('hermes.desktop.pluginDecisions.v2') // declined — host.navigate('/capabilities?tab=plugins')
|
|
row.querySelector('[data-slot="switch"]').click() // same: the app's Plugins tab owns the toggle
|
|
```
|
|
|
|
## 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`.
|
|
|
|
`DecodeText`'s `loop` is opt-in as of this change — it decodes once and holds by default, so pass `loop` explicitly on progress surfaces that should keep scrambling.
|
|
|
|
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**.
|
|
|
|
### One package, both SDKs {#one-package-both-sdks}
|
|
|
|
A feature that needs a desktop UI **and** agent-side code (a Python plugin, its
|
|
backend routes, skills) doesn't have to ship as two co-dependent installs. Put a
|
|
`desktop/plugin.js` inside the agent package. When the package lands in any
|
|
local `plugins/` root (default home or a profile), the Electron main process
|
|
copies that half into `$HERMES_HOME/desktop-plugins/<id>/` beside a
|
|
`.hermes-package.json` marker, and the renderer loads it through the exact same
|
|
pipeline as the standalone disk door (hot reload included):
|
|
|
|
```
|
|
~/.hermes/plugins/<id>/ # ONE installable folder
|
|
├── plugin.yaml # the agent half: tools, hooks, commands
|
|
├── skills/…
|
|
├── dashboard/
|
|
│ ├── manifest.json # { "name": "<id>", "api": "plugin_api.py" }
|
|
│ └── plugin_api.py # backend routes → /api/plugins/<id>/
|
|
└── desktop/
|
|
└── plugin.js # the desktop half: panes, commands, ctx.rest
|
|
```
|
|
|
|
The `desktop/plugin.js` half is an ordinary disk plugin — same contract, same
|
|
imports, same `ctx.rest('/…')` reaching the `plugin_api.py` sitting beside it.
|
|
Installing, sharing, or removing the feature is one folder: the app-root copy
|
|
is refreshed when the source `plugin.js` changes (`hermes plugins update`, or
|
|
**Rescan**) and removed when the package folder disappears. The copy is what
|
|
makes the desktop half **app-level**: it exists once, however many profiles
|
|
carry the package, and it never appears or disappears when the user switches
|
|
the Capabilities profile selector. The renderer never scans `plugins/` itself.
|
|
The marker records the package name and its origin (catalog sidecar or git
|
|
remote), which is what the **Install here** button on the Plugins page uses to
|
|
install the agent half into another profile. The copy is staged beside the
|
|
target and renamed into place, so an interrupted copy (a transient file lock, a
|
|
crash mid-copy) never leaves a half-written folder behind; a leftover
|
|
`desktop-plugins/<id>/` that has no marker and no `plugin.js` is treated as
|
|
such damage and replaced on the next **Rescan**, while a marker-less folder
|
|
that *does* hold a `plugin.js` is a standalone plugin you installed by hand and
|
|
is never overwritten.
|
|
|
|
Two enable switches still apply, on purpose, and both default to **off**: the
|
|
desktop half ships opt-in — it inventories in **Capabilities → Plugins** but stays
|
|
disabled until the user toggles it — matching the Python half's
|
|
`plugins.enabled` gate in `config.yaml` (the security boundary below). Dropping
|
|
a package into `~/.hermes/plugins` is inert on every surface until the user
|
|
says otherwise. The desktop half degrades gracefully when the backend half is
|
|
off — `ctx.rest` returns errors, not crashes.
|
|
|
|
:::note
|
|
The copy is local to the machine the desktop app runs on. Against a remote
|
|
backend, the remote box's `~/.hermes/plugins` is not reachable as a filesystem —
|
|
only locally installed packages contribute a desktop half this way. For a
|
|
remote backend the install dialog clones the desktop half separately into
|
|
`desktop-plugins/`, the same as a desktop-only repo. A package whose agent half
|
|
was installed on the remote host without that clone shows its Desktop half as
|
|
**unavailable (remote backend)** on the Plugins page — not as a pending copy —
|
|
and the tooltip points at **Install from Git** with the Desktop target checked.
|
|
:::
|
|
|
|
### Distributing with an install link {#install-link}
|
|
|
|
Ship your plugin repo (agent half, desktop half, or both) and link to it with
|
|
the `hermes://` scheme — a plain anchor on your website or README:
|
|
|
|
```html
|
|
<a href="hermes://plugin/install?repo=owner/repo&enable=1">Install in Hermes</a>
|
|
```
|
|
|
|
The user gets a confirmation dialog (repo id, source links, a probe of what
|
|
the repo ships) and picks components before anything is installed — deep links
|
|
never auto-install. `force=1` replaces an existing install; dev builds use
|
|
`hermes-dev://`. Full link reference:
|
|
[One-click install links](../user-guide/features/plugins.md#one-click-install-links-desktop).
|
|
|
|
### 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.md#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 **Capabilities → 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).
|
|
:::
|
|
|
|
#### Pushing events to your desktop half
|
|
|
|
Your backend runs inside the gateway process, so it can push an update to your
|
|
own desktop half over the app's global event stream — the same stream
|
|
`host.onEvent` subscribes to:
|
|
|
|
```python
|
|
from hermes_cli.plugin_events import broadcast_plugin_event
|
|
|
|
broadcast_plugin_event("rss-reader", "feed.updated", {"count": 3})
|
|
# → event "plugin.rss-reader.feed.updated" reaches every connected desktop client
|
|
```
|
|
|
|
```javascript
|
|
// register(ctx): the subscription is retired with the plugin
|
|
host.onEvent('plugin.rss-reader.feed.updated', ({ payload }) => refreshFeeds(payload))
|
|
```
|
|
|
|
`broadcast_plugin_event(plugin_id, event, payload=None)`: the wire name is
|
|
always `plugin.<plugin_id>.<event>`. `plugin_id` is your catalog name
|
|
(`[a-z0-9_-]{1,64}`, no dots — it is the namespace and can't spell another
|
|
plugin's); `event` is the BARE dotted name (`"feed.updated"`, not
|
|
`"plugin.rss-reader.feed.updated"`), segments of `[A-Za-z0-9_-]`, so `""`,
|
|
`"../x"` or `"a..b"` raise `ValueError` instead of stranding the desktop half
|
|
on a name nobody emits. `payload` is a JSON dict (or omitted → `{}`), delivered
|
|
as the event's `payload`; the frame carries `session_id: ""` like every global
|
|
event. Delivery is fire-and-forget (a wedged client is skipped, never stalling
|
|
your handler). Where it lands depends on the process the call runs in:
|
|
|
|
| Caller runs in | Reaches |
|
|
|---|---|
|
|
| `hermes serve` (the Desktop backend): `plugin_api.py` routers, plugin slash commands, tools and hooks in the agent turn | every connected Desktop window |
|
|
| the `dashboard.turn_isolation` compute-host child (tools/hooks of an isolated turn) | relayed over the host pipe to `hermes serve`, then every window |
|
|
| the stdio TUI (`hermes` in a terminal) | that terminal's client |
|
|
| `hermes gateway run` (messaging platforms), `hermes chat`, cron, `hermes plugins validate` | nobody — no Desktop client is attached to that process; the call is a logged no-op |
|
|
|
|
Use this instead of importing `tui_gateway.server` internals; for plugin-scoped frames
|
|
with a payload tailored per connection, `ctx.socket('/events')` remains the
|
|
richer door.
|
|
|
|
Migration (rss-reader): drop the `~/.hermes/rss-reader/commands.jsonl` queue,
|
|
`GET /commands` and the 3 s `ctx.rest('/commands')` poll — the Python side
|
|
calls `broadcast_plugin_event('rss-reader', 'feed.updated', payload)` where it
|
|
used to enqueue, and the desktop side replaces the timer with
|
|
`host.onEvent('plugin.rss-reader.feed.updated', fn)` inside `register(ctx)`.
|
|
|
|
### 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 **Capabilities → 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`) and the `window.hermesDesktop` native bridge
|
|
(files, git, terminal, installs). The isolation the loader provides is **error
|
|
isolation only**: a plugin can't crash the app (contributions are error-bounded,
|
|
listeners isolated, a throwing `register()` is rolled back and reported on the
|
|
plugin's row), but it can do anything the app can. Plugin storage namespaces
|
|
are a convention, not a wall.
|
|
|
|
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. For [catalog](../user-guide/features/plugin-catalog.md#trust-model)
|
|
installs the trust comes from admission — a human reviewed the exact pinned
|
|
commit — backed by two tripwires: the `desktop surface` lint at admission and
|
|
the loader's import allowlist (`@hermes/plugin-sdk` and `react*` only; a static
|
|
or dynamic `import` of anything else, including `https:` URLs, fails the load).
|
|
Neither is a 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.
|
|
- **Bare globals are not tracked.** `window.setInterval`, `window.addEventListener`,
|
|
a `<style>` you append — the host never sees them, so they survive disable and
|
|
every hot-reload (ES modules can't be unloaded; a hot-edit loop stacks live
|
|
copies). Use `ctx.setTimeout` / `ctx.setInterval` / `ctx.addEventListener`, and
|
|
wire anything else to `ctx.onDispose`. Module-scope state is yours to reset.
|
|
- **Module evaluation has a 10 s deadline.** A top-level `await` that never
|
|
settles (waiting for a gateway that isn't up) fails the load as `import timed
|
|
out` instead of stalling the plugin scan; do the waiting inside `register()`.
|
|
- **One id, one file.** Two folders exporting the same `id` (a standalone install
|
|
beside a unified-package copy) load first-wins in folder-name order; the later
|
|
one shows `duplicate id` on its own row in Capabilities ▸ Plugins.
|
|
- **`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.*`, `.settings`, `.notify`, `.notifyError`, `.navigate`, `.onEvent`, `.logs`, `.status`, `.restartGateway`, `.request`, `.composer`, `.sessions`, `.skills`, `.toolsets`, `.profiles`, `.pluginDecisions`) |
|
|
| Plugin contract | `HermesPlugin`, `PluginContext`, `PluginContribution`, `PluginStorage`, `PluginOs`, `PluginRestOptions`, `PluginNativeNotificationInput`, `PluginNotificationAction`, `HermesOpenTarget`, `Contribution` |
|
|
| Area constants | `PANES_AREA`, `ROUTES_AREA`, `SIDEBAR_NAV_AREA`, `STATUSBAR_AREAS`, `TITLEBAR_AREAS`, `WORKSPACE_PAGE_HEADER_AREA`, `PALETTE_AREA`, `KEYBINDS_AREA`, `THEMES_AREA`, `COMPOSER_AREAS`, `SESSION_ROW_AREAS`, `SIDEBAR_NAV_PREFS_AREA`, `APPEARANCE_AREAS` |
|
|
| Area payloads | `RouteContribution`, `SidebarNavContribution`, `StatusbarItem`, `TitlebarTool`, `PaletteContribution`, `KeybindContribution`, `ComposerMiddleware`, `ComposerAttachmentProvider`, `SessionRowSlotContribution`, `SidebarNavPrefsContribution` |
|
|
| React / state | `useValue`, `atom`, `computed`, `useQuery`, `useMutation`, `useQueryClient`, `queryClient`, `Contribute`, `WorkspacePageHeaderControl` |
|
|
| Theming | `useTheme`, `requestTheme`, `setAccentOverride`, `$accentOverride`, `retintTheme`, `themeHue`, `DesktopTheme`, `DesktopThemeColors`, plus OKLCH math (`hexToOklch`, `oklchToHex`, `oklchToSrgb255`, `mixOklab`, `maxChroma`, `hueDelta`, `normalizeHex`) and sRGB measures (`contrastRatio` — `number | null`, null for unparseable input — `readableOn`) |
|
|
| 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`, `SandboxedFrame` |
|
|
| Helpers | `cn`, `icons`, `haptic`, `useI18n`, `profileColor`, `profileColorSoft`, `relativeTime`, `fmtDateTime`, `fmtDayTime`, `coarseElapsed`, `evaluateRuntimeReadiness`, `catalogProviderMatches` |
|
|
|
|
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.
|