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. This skill does not cover backend plugins (~/.hermes/plugins/);
those are Python and documented separately.
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 with
jsx() 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.- 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, pluscnandicons.*. 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.