12 KiB
Desktop App Plugins — UI Panes, Commands, Widgets
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.
There are TWO on-disk doors, same contract and hot reload:
$HERMES_HOME/desktop-plugins/<id>/plugin.js— standalone desktop plugin. Loads enabled by default.$HERMES_HOME/plugins/<id>/desktop/plugin.js— the desktop HALF of a unified agent-plugin package: the same folder that carries the Python plugin (plugin.yaml) and itsdashboard/plugin_api.pybackend ships its desktop UI beside them, so one feature installs/uninstalls as one folder. This half is OPT-IN: it inventories in Settings → Plugins but stays off until the user toggles it (matching the Python half'splugins.enabledgate). Tell the user to flip it on after installing — don't debug a "plugin not appearing" report before checking that toggle.
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(in 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,busy,awaitingResponse,busyBySession,cwd,gateway(socket state, not turn-busy),model,profile,viewport, plus the tile-aware focused session atoms:focusedSessionId(runtime id — key forsession.*RPC),focusedStoredSessionId(durable id — navigation / list matching),focusedSessionProfile(owner profile of the focused chat — prefer overprofilefor per-bot/profile readouts;profileis the gateway socket's home, which does not move with tab focus), andfocusedUsage(live streamedUsageStatsof the focused session, no RPC needed).busyis true while the focused chat is working after a send (thinking and streaming).awaitingResponseis true until the first assistant payload.busyBySessionmaps runtime session id → mid-turn, for rosters that watch every session. Prefer the focused atoms for any readout that should follow the user between tiles. 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),THEMES_AREA(datais a fullDesktopTheme).- THEMES: registering one only lists it in the picker. Select it with
useTheme().setTheme(name)from a component, orrequestTheme(name)from a callback with no component around it (a gateway event, a socket handler).requestThemereturnsfalsefor a name that doesn't resolve and leaves the appearance alone, so use it as the availability check instead of coercing the user back to the default skin. - 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'). - TRANSCRIPT directives: register
area: TRANSCRIPT_DIRECTIVE_AREAwithdata: { name: 'task', render: ({ attrs, streaming }) => jsx(...) }and the assistant can render your component inline in a chat message by emitting::task{id="BB-12"}alone on its own line. Attrs are untrustedkey="value"strings — validate them. Unclaimed/malformed directives fall back to plain text; core's own::preview{file="…"}is the reference. After registering one, TELL the model it exists (a bundled skill or the user's instructions) — it won't discover the name on its own. ctx.storage.get/set/remove— persistence namespaced to your plugin.ctx.os— the curated OS door, attributed to your plugin:ctx.os.notify({ title, body?, silent?, icon?, activate?, onActivate?, actions? })posts a native OS notification. Fires only while the user is away from Hermes (usehost.notifyfor the in-app toast); gated by Settings ▸ Notifications ▸ "Plugin notifications" and throttled per plugin — reserve it for genuinely notable events.activateaccepts a plugin deep link (hermes://index-network/intent/1), a hash path (/index-network/intent/1), or{ path, params }— same resolver as OS deep links. Action buttons may set their ownactivateor anonActioncallback (renderer-only; only the action id crosses IPC).ctx.os.openExternal(url),ctx.os.revealPath(path), andctx.os.writeClipboard(text)resolvefalse(never throw) when the capability isn't available.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, 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.