Resources
1Install
npx skillscat add russ534/myopenclawbot/hermes-desktop-plugins Install via the SkillsCat registry.
Hermes Desktop Plugins Skill
Write plugins for the Hermes desktop app: statusbar items, layout panes,
command-palette commands, keybinds, routes, and themes. A plugin is a single
plain-JavaScript ESM file the app loads at runtime — no build step, no repo
changes. A plugin can also talk to its own Python backend namespace
(ctx.rest/ctx.socket → /api/plugins/<id>); the general Python plugin
system (~/.hermes/plugins/) is otherwise documented separately.
Full human reference (every export, area payloads, backend, security):website/docs/developer-guide/desktop-plugin-sdk.md.
When to Use
- The user asks for a new desktop UI element (a pane, a statusbar widget, a
dashboard, a command) without modifying the app itself. - You want to surface data you compute (via gateway RPC) inside the app.
Prerequisites
- The Hermes desktop app (it loads plugins; the CLI/gateway alone does not).
- Write access to
$HERMES_HOME/desktop-plugins/(usually~/.hermes/desktop-plugins/).
How to Run
- Create
$HERMES_HOME/desktop-plugins/<name>/plugin.jsfromtemplates/plugin.js(relative to this skill directory) — that's~/.hermes/...by default, or~/.hermes/profiles/<profile>/...under a
named profile. Keep<name>equal to the pluginid. - The desktop app watches that directory: the plugin loads within a few
seconds of the file landing, and every later save hot-reloads it in
place. No reload step. (Fallback if it doesn't appear: ⌘K →
Reload desktop plugins.) - If loading fails the app shows a toast naming the error — fix the file
and save again.
Quick Reference
The ONLY import surface is @hermes/plugin-sdk (plus react /react/jsx-runtime, which resolve to the app's own React — write UI withjsx() calls, not JSX syntax; the file is not compiled).
host.state.*— readonly reactive atoms:activeSessionId,cwd,gateway,model,profile,viewport. Read with.get()in handlers,useValue(atom)in components.host.request(method, params)— gateway JSON-RPC (sessions, config,
skills, cron — everything the app uses).host.onEvent(type, fn)— live gateway events ('*'for all). Returns a
disposer.host.notify({ kind, message }),host.navigate(path),host.logs(...),host.status(),haptic('tap').ctx.register({ id, area, order?, render?, data? })— contribute UI.
Key areas:'statusBar.right'/'statusBar.left'(chips),'panes'(layout zones — settitleanddata: { placement, dock?, width?, height? }; the pane auto-joins a
matching zone),PALETTE_AREA(⌘K commands),KEYBINDS_AREA(rebindable
actions).- Pane placement:
placement: 'left'|'right'|'bottom'|'main'is the
semantic role — the pane stacks (tabs) with existing panes of that role.
To land on a specific EDGE instead, adddock: { pane, pos }— the same
gesture as dragging onto a pane's drop chip.paneis any pane id
(workspaceis the main thread; alsosessions,terminal,files,review,logs),posis'top'|'bottom'|'left'|'right'|'center'.
E.g. "below the conversation" =dock: { pane: 'workspace', pos: 'bottom' }
— declare aheight(e.g.'200px') so it doesn't take half the zone. - Full PAGES: register
area: ROUTES_AREAwithdata: { path: '/my-page' }
and arender— the page mounts in the workspace (main) pane like any
built-in view. Make it reachable with a sidebar nav row:ctx.register({ id: 'nav', area: SIDEBAR_NAV_AREA, data: { path: '/my-page', label: 'My Page', codicon: 'project' } })
(renders below Artifacts, lights up at the route) — and/or aPALETTE_AREAcommand callinghost.navigate('/my-page'). ctx.storage.get/set/remove— persistence namespaced to your plugin.ctx.i18n.register({ en, ja, ... })— ship your OWN locale bundles, scoped
to your plugin (never edit coreen.ts). Values are literal strings or
interpolator functions; nested trees are addressed by dot-path. Read them
reactively in components withusePluginI18n(id)returningt('key', ...args)
(re-renders on a locale switch), or viactx.i18n.tin handlers/stores.
Resolution follows the app's active locale, then youren, then the raw key.- Data:
useQuery/useMutation/useQueryClient/queryClient(the app's ONE
React Query client — cache, dedupe,refetchInterval, invalidate like core;
never hand-roll a poll loop), plusatom/computedfor plugin-local state. - Backend: if the plugin ships a Python
plugin_api.py(under~/.hermes/plugins/<id>/dashboard/, manifest"api": "plugin_api.py"), reach
it withctx.rest('/path', { method?, body?, timeoutMs? })and its live twinctx.socket('/events', onMessage)— both scoped to/api/plugins/<id>by
construction (traversal rejected).ctx.socketis a no-op on OAuth
remotes, so always keep a polling fallback. The Python backend is imported
only when the plugin is inplugins.enabledinconfig.yaml(separate from
the in-app enable toggle). For gateway-wide data usehost.request/host.onEventinstead. Contribute(mount-scoped): renderjsx(Contribute, { area, id, children })
inside a component so page-owned chrome (e.g. a titlebar control inTITLEBAR_AREAS.center) leaves when the page unmounts —ctx.registeris for
permanent contributions.defaultEnabled: falseon the default export ships an opt-in plugin: it
inventories in Settings → Plugins, off until the user flips it on.- Users manage plugins in Settings → Plugins (enable/disable live, reveal
folder). A disabled plugin stays disabled across restarts — don't fight
it; the user turned you off. - UI: the app's design language, importable directly —
Button,Input,Textarea,Select*,Switch,Checkbox,SegmentedControl,Tabs*,Dialog*,ConfirmDialog,DropdownMenu*,ContextMenu*,Popover*,Tip/Tooltip*,Badge,Kbd/KbdGroup,SearchField,ScrollArea,Separator,Skeleton,GlyphSpinner,EmptyState,ErrorState,CopyButton,StatusDot,LogView,Codicon,DecodeText, pluscn
andicons.*. Prefer these over hand-rolled elements so the plugin looks
native; style with theme vars, never hardcoded colors.
Procedure
- Pick a short kebab-case
id; the folder name must match. - Start from
templates/plugin.js; keep the default export shape
({ id, name, register(ctx) }). - For a pane, register
area: 'panes'with aplacementhint and arenderreturning your component — the app places it into a sensible
zone automatically; the user can drag it anywhere afterwards. - Fetch data with
host.requestand/or subscribe withhost.onEvent;
never poll faster than a few seconds. - Write the file with your file tools, then ask the user to run
Reload desktop plugins from ⌘K.
Pitfalls
- NEVER hardcode colors or backgrounds (
#000,black,rgb(...)). Panes
already sit on the app's editor background — leave the background alone
and use theme variables for everything else:var(--ui-text-secondary),var(--ui-text-quaternary),var(--ui-stroke-secondary),var(--ui-accent). For canvas drawing, resolve them once withgetComputedStyle(canvas).getPropertyValue('--ui-accent'). - Reference only what you imported — a component you forgot to import
(e.g.StatusDot) is a ReferenceError at render. Double-check every
identifier in yourjsx()calls appears in the import line. - Canvas panes MUST track their container with a
ResizeObserverand
re-size the canvas (width/height attributes, not just CSS) — panes resize
constantly (sash drags, layout switches); a mount-time-only size leaves
blank space or blurry scaling. - JSX syntax will not parse — the file loads uncompiled. Use
jsx('div', { children: ... })fromreact/jsx-runtime. - Do not import anything except
@hermes/plugin-sdk,react, andreact/jsx-runtime; other specifiers fail to resolve. - Handlers must read state imperatively (
$atom.get()), never from render
closures — rapid events will otherwise see stale values. - Keep components small; subscribe (
useValue) only in the leaf that
renders the value.
Verification
- The plugin's UI appears after Reload desktop plugins.
- No error toast ("Plugin failed to load") appears; if it does, the
message names the failure — fix and reload. - For panes: the new zone is visible and draggable like any core pane.