Desktop Plugin SDK (@hermes/plugin-sdk)
حزمة تطوير إضافات سطح المكتب
What this page is, and what it holds.
This page covers Desktop Plugin SDK (@hermes/plugin-sdk). It carries a source warning and takes about 19 minutes to read. A plugin runs with the agent's full permissions. Do not install one you cannot read.
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.
Outcomes taken from this page, not a template.
- Understand what الإضافات is and when you need it.
- Run
hermes desktopandhermes dashboardand understand what happens next. - Read the table and take only the row that applies to you.
- Avoid the mistake the source warns about.
Exactly as they appear in Hermes.
hermes desktophermes dashboard
Jump to the part you need.
Nothing summarised away.
The documentation body below is reproduced from the official source so commands and identifiers stay exact. Each section carries a short note describing what it contains.
The native Hermes 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.
Mental model
Explains the idea itself. Read it slowly; the later sections build on it.
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.ctx.rest/ctx.socket— your plugin's own backend namespace (/api/plugins/<id>) if you ship aplugin_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
Explains the idea itself. Read it slowly; the later sections build on it.
| 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 Settings → 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 ↗. Everything on this page is
written against the disk door (what you and the agent write);
Bundled plugins ↗ notes the two
differences. No desktop plugins ship in the core tree today — reference demos
live in the companion
hermes-example-plugins ↗
repo.
Quick start — your first plugin
Ordered, practical steps. Run one and confirm it worked before moving on.
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.
// ~/.hermes/desktop-plugins/hello/plugin.js
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}`
})
]
})
}
// 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
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?) // 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 powerhost.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.
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:
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.
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.
Data layer — React Query + nanostores
Explains the idea itself. Read it slowly; the later sections build on it.
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.
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:
ctx.socket('/events', () => {
queryClient.invalidateQueries({ queryKey: ['my-plugin', 'items'] })
})The UI kit and theming
Explains the idea itself. Read it slowly; the later sections build on it.
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
Carries a warning. Read it before running anything here. The upstream warning appears below.
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
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. The
desktop app also scans $HERMES_HOME/plugins/<id>/ — the regular agent-plugin
root — for a desktop/plugin.js, and 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.restThe 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.
Two enable switches still apply, on purpose, and both default to off: the
desktop half ships opt-in — it inventories in Settings → 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.
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()`# 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
for the full backend reference — the mount is identical.
Calling it from the plugin
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
Explains the idea itself. Read it slowly; the later sections build on it.
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(defaulttrue). SetdefaultEnabled: falseto 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:
ctx.storage.set('lastTab', 'board')
const tab = ctx.storage.get('lastTab', 'summary')
ctx.storage.remove('lastTab')Bundled plugins
Explains the idea itself. Read it slowly; the later sections build on it.
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:
- It goes through the app's Vite build, so you can write real JSX and import the SDK by its
@hermes/plugin-sdkalias. - It's still lint-fenced to
@hermes/plugin-sdk+reactonly — 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 ↗
companion repo.
Security model
Explains the idea itself. Read it slowly; the later sections build on it.
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
Explains the idea itself. Read it slowly; the later sections build on it.
- JSX won't parse in a disk plugin. The file loads uncompiled — use
jsx()/jsxs()(orReact.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 aReferenceErrorat render — double-check every identifier in yourjsx()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
ResizeObserverand resize the canvas (width/height attributes, not just CSS) — panes resize constantly. - Don't poll faster than a few seconds with
host.request; preferhost.onEvent/ctx.socketand let React Query dedupe. ctx.socketis a no-op on OAuth remotes. Always have a polling fallback.
Reference
A lookup table. Do not read it all; find the row that applies to you.
SDK exports at a glance
| Category | Exports |
|---|---|
| Host | host (.state.*, .notify, .notifyError, .navigate, .onEvent, .logs, .status, .restartGateway, .request) |
| Plugin contract | HermesPlugin, PluginContext, PluginContribution, PluginStorage, PluginOs, PluginRestOptions, PluginNativeNotificationInput, 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
A troubleshooting section. Find the symptom that matches yours rather than reading it end to end.
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.
3 questions answered by this page alone.
Every option is a real identifier from the Hermes documentation. The wrong ones are real too, just from other pages.