skills/hermes-desktop-plugins/SKILL.md
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.
$HERMES_HOME/desktop-plugins/ (usually
~/.hermes/desktop-plugins/).$HERMES_HOME/desktop-plugins/<name>/plugin.js from
templates/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 plugin id.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 — set title and
data: { placement, dock?, width?, height? }; the pane auto-joins a
matching zone), PALETTE_AREA (⌘K commands), KEYBINDS_AREA (rebindable
actions).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, add dock: { pane, pos } — the same
gesture as dragging onto a pane's drop chip. pane is any pane id
(workspace is the main thread; also sessions, terminal, files,
review, logs), pos is 'top'|'bottom'|'left'|'right'|'center'.
E.g. "below the conversation" = dock: { pane: 'workspace', pos: 'bottom' }
— declare a height (e.g. '200px') so it doesn't take half the zone.area: ROUTES_AREA with data: { path: '/my-page' }
and a render — 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 a
PALETTE_AREA command calling host.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 core en.ts). Values are literal strings or
interpolator functions; nested trees are addressed by dot-path. Read them
reactively in components with usePluginI18n(id) returning t('key', ...args)
(re-renders on a locale switch), or via ctx.i18n.t in handlers/stores.
Resolution follows the app's active locale, then your en, then the raw key.useQuery/useMutation/useQueryClient/queryClient (the app's ONE
React Query client — cache, dedupe, refetchInterval, invalidate like core;
never hand-roll a poll loop), plus atom/computed for plugin-local state.plugin_api.py (under
~/.hermes/plugins/<id>/dashboard/, manifest "api": "plugin_api.py"), reach
it with ctx.rest('/path', { method?, body?, timeoutMs? }) and its live twin
ctx.socket('/events', onMessage) — both scoped to /api/plugins/<id> by
construction (traversal rejected). ctx.socket is a no-op on OAuth
remotes, so always keep a polling fallback. The Python backend is imported
only when the plugin is in plugins.enabled in config.yaml (separate from
the in-app enable toggle). For gateway-wide data use host.request /
host.onEvent instead.Contribute (mount-scoped): render jsx(Contribute, { area, id, children })
inside a component so page-owned chrome (e.g. a titlebar control in
TITLEBAR_AREAS.center) leaves when the page unmounts — ctx.register is for
permanent contributions.defaultEnabled: false on the default export ships an opt-in plugin: it
inventories in Settings → Plugins, off until the user flips it on.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, plus cn
and icons.*. Prefer these over hand-rolled elements so the plugin looks
native; style with theme vars, never hardcoded colors.id; the folder name must match.templates/plugin.js; keep the default export shape
({ id, name, register(ctx) }).area: 'panes' with a placement hint and a
render returning your component — the app places it into a sensible
zone automatically; the user can drag it anywhere afterwards.host.request and/or subscribe with host.onEvent;
never poll faster than a few seconds.#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 with
getComputedStyle(canvas).getPropertyValue('--ui-accent').StatusDot) is a ReferenceError at render. Double-check every
identifier in your jsx() calls appears in the import line.ResizeObserver and
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('div', { children: ... }) from react/jsx-runtime.@hermes/plugin-sdk, react, and
react/jsx-runtime; other specifiers fail to resolve.$atom.get()), never from render
closures — rapid events will otherwise see stale values.useValue) only in the leaf that
renders the value.