Desktop Plugin Maker
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.
Verified placement contracts and the complete 25-position test matrix:
references/position-surface-contracts.md. Load this reference whenever a
plugin must move across panes, docks, bars, popovers, composer seams, routes,
or sidebar navigation.
Placement invariant: keep one shared logical state but implement a separate
adapter for each destination contract. Render slots use render() => ReactNode;
data-provider areas use their exact declarative payload. Never move the same
unadapted contribution object across unlike areas.
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.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 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 — set title and
data: { 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, 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.
Full PAGES: register 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.
Data: 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.
Backend: if the plugin ships a Python 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.
For a local backend-only desktop plugin, hermes plugins enable <id> may say
the plugin is not installed when only dashboard/manifest.json exists. Add a
minimal root plugin.yaml plus an empty __init__.py, then run the enable
command again. This writes plugins.enabled as a real YAML list. Do not use
hermes config set plugins.enabled '["<id>"]': some versions serialize that
as a quoted scalar, so the API will not mount. Restart the Desktop gateway
after enabling because Python routes mount only at startup.
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.
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, plus cn
and icons.*. 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 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.
- Fetch data with
host.request and/or subscribe with host.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.
Production Packaging and Migration
Open-source repository layout
When publishing examples, keep each desktop plugin self-contained under
plugins/<id>/plugin.js. If it requires a backend, include only the portable
backend package (plugin.yaml, __init__.py, dashboard/manifest.json, and
dashboard/plugin_api.py) in the matching plugins/<id>/ folder. Document the
separate install locations clearly:
repo/plugins/<id>/plugin.js → <HERMES_HOME>/desktop-plugins/<id>/plugin.js
repo/plugins/<id>/dashboard/... → <HERMES_HOME>/plugins/<id>/dashboard/...
Ship a root README, per-plugin README where setup differs, a license, and a
.gitignore. Exclude credentials, OAuth artifacts, account/device/wallet data,
state databases, logs, local paths, ROMs/media/save files, and node_modules.
Never substitute a demo artifact (for example, an HTML deck) for the requested
plugin source: keep a presenter/launcher generic unless the user specifically
asks to bundle a particular artifact.
Before publishing, scan the export for usernames, home paths, key/token
patterns, bearer headers, and accidental binary/user-data files. Also check that
README links point to actual current folders.
Rename invariant
The disk-plugin folder, exported id, backend namespace, storage namespace,
and palette command IDs normally share one kebab-case identifier. A rename must
update all of them together, then remove the obsolete folder:
- Change the exported plugin
id and command/storage identifiers.
- Rename both desktop and backend directories if a backend exists.
- Update README links and installation paths.
- Verify the old folder is absent and the new
plugin.js parses.
A visible product name may remain more descriptive than its stable ID (for
example, id: 'markets', name: Hyperliquid Markets).
Embedded documents and local files
For a route that presents a user-selected local HTML document, use the browser
File API (await file.text()) and assign the result to an iframe srcdoc.
This keeps the content in memory: do not upload, copy, index, or persist it
unless the user explicitly requests a storage design.
Treat chosen HTML as active content. Embed it in a sandboxed iframe with the
minimum permissions required; never give it Hermes APIs, plugin storage, or
filesystem access. State the supported format and unsupported conversions
clearly—an HTML presenter is not automatically a PowerPoint/PDF renderer.
Routes unmount when users navigate away. If preserving a live iframe/emulator
session is required, create it once at module scope, move it into a hidden
parking container on unmount, and reattach it on return. This preserves only
in-memory state until Hermes closes or the plugin reloads; use the underlying
app's own save mechanism for durable state. Do not simulate persistence by
copying private user files into plugin storage.
Verification
- Run
node --check plugin.js from the plugin directory. On this
On Windows/MSYS, absolute Windows paths can be translated incorrectly by
automatic linting.
- For a Python bridge, run
python -m py_compile dashboard/plugin_api.py;
parse JSON manifests explicitly.
- Do a focused ad-hoc static contract check when a plugin is added, renamed,
or converted (IDs/folders, expected contribution areas, no unsupported
imports, and no stale links). This is verification evidence, not a claim
that a full test suite passed.
- For an export, inventory the expected plugin folders, assert obsolete paths
are absent, and run the privacy scan described above.
- Only with the user's explicit permission, use
computer_use to visually
confirm a hot-loaded plugin's route/surface and its key interaction.
Pitfalls
Title-bar areas have two distinct contracts. TITLEBAR_AREAS.left, .center, and .right are generic render slots: register them with render: () => ReactNode, or mount <Contribute area={TITLEBAR_AREAS.*}>...</Contribute>. Do not put a TitlebarTool object in data for these exported areas. The shell consumes declarative tool data from separate internal areas (titleBar.tools.left/right); unless the SDK exposes a public constant for those, use the supported render-slot API.
Icon types are surface-specific: title/status render slots use ReactNodes; sidebar codicon and ComposerAttachmentProvider.icon use codicon-name strings.
COMPOSER_AREAS.attachments is a data-provider area, not a render slot. Register {data: {label, icon, run}}; use render() only for composer top, bottom, underside, leading, and actions.
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 with
getComputedStyle(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 your jsx() calls appears in the import line.
Canvas panes MUST track their container with a 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 syntax will not parse — the file loads uncompiled. Use
jsx('div', { children: ... }) from react/jsx-runtime.
On this Windows/MSYS host, automatic write_file Node lint can mis-translate an absolute C:\\... path to C:\\c\\... and report a false MODULE_NOT_FOUND. Verify with node --check plugin.js from the plugin folder.
Do not import anything except @hermes/plugin-sdk, react, and
react/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.
Visual checks
- 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.
1---2name: desktop-plugin-maker3description: Build, verify, and package Hermes Desktop plugins.4---56# Desktop Plugin Maker78Write plugins for the Hermes desktop app: statusbar items, layout panes,9command-palette commands, keybinds, routes, and themes. A plugin is a single10plain-JavaScript ESM file the app loads at runtime — no build step, no repo11changes. A plugin can also talk to its own Python backend namespace12(`ctx.rest`/`ctx.socket` → `/api/plugins/<id>`); the general Python plugin13system (`~/.hermes/plugins/`) is otherwise documented separately.1415Full human reference (every export, area payloads, backend, security):16`website/docs/developer-guide/desktop-plugin-sdk.md`.1718Verified placement contracts and the complete 25-position test matrix:19`references/position-surface-contracts.md`. Load this reference whenever a20plugin must move across panes, docks, bars, popovers, composer seams, routes,21or sidebar navigation.2223**Placement invariant:** keep one shared logical state but implement a separate24adapter for each destination contract. Render slots use `render() => ReactNode`;25data-provider areas use their exact declarative payload. Never move the same26unadapted contribution object across unlike areas.2728## When to Use2930- The user asks for a new desktop UI element (a pane, a statusbar widget, a31 dashboard, a command) without modifying the app itself.32- You want to surface data you compute (via gateway RPC) inside the app.3334## Prerequisites3536- The Hermes desktop app (it loads plugins; the CLI/gateway alone does not).37- Write access to `$HERMES_HOME/desktop-plugins/` (usually38 `~/.hermes/desktop-plugins/`).3940## How to Run41421. Create `$HERMES_HOME/desktop-plugins/<name>/plugin.js` from43 `templates/plugin.js` (relative to this skill directory) — that's44 `~/.hermes/...` by default, or `~/.hermes/profiles/<profile>/...` under a45 named profile. Keep `<name>` equal to the plugin `id`.462. The desktop app watches that directory: the plugin loads within a few47 seconds of the file landing, and every later save hot-reloads it in48 place. No reload step. (Fallback if it doesn't appear: ⌘K →49 **Reload desktop plugins**.)503. If loading fails the app shows a toast naming the error — fix the file51 and save again.5253## Quick Reference5455The ONLY import surface is `@hermes/plugin-sdk` (plus `react` /56`react/jsx-runtime`, which resolve to the app's own React — write UI with57`jsx()` calls, not JSX syntax; the file is not compiled).5859- `host.state.*` — readonly reactive atoms: `activeSessionId`, `cwd`,60 `gateway`, `model`, `profile`, `viewport`. Read with `.get()` in handlers,61 `useValue(atom)` in components.62- `host.request(method, params)` — gateway JSON-RPC (sessions, config,63 skills, cron — everything the app uses).64- `host.onEvent(type, fn)` — live gateway events (`'*'` for all). Returns a65 disposer.66- `host.notify({ kind, message })`, `host.navigate(path)`, `host.logs(...)`,67 `host.status()`, `haptic('tap')`.68- `ctx.register({ id, area, order?, render?, data? })` — contribute UI.69 Key areas: `'statusBar.right'`/`'statusBar.left'` (chips),70 `'panes'` (layout zones — set `title` and71 `data: { placement, dock?, width?, height? }`; the pane auto-joins a72 matching zone), `PALETTE_AREA` (⌘K commands), `KEYBINDS_AREA` (rebindable73 actions).74- Pane placement: `placement: 'left'|'right'|'bottom'|'main'` is the75 semantic role — the pane stacks (tabs) with existing panes of that role.76 To land on a specific EDGE instead, add `dock: { pane, pos }` — the same77 gesture as dragging onto a pane's drop chip. `pane` is any pane id78 (`workspace` is the main thread; also `sessions`, `terminal`, `files`,79 `review`, `logs`), `pos` is `'top'|'bottom'|'left'|'right'|'center'`.80 E.g. "below the conversation" = `dock: { pane: 'workspace', pos: 'bottom' }`81 — declare a `height` (e.g. `'200px'`) so it doesn't take half the zone.82- Full PAGES: register `area: ROUTES_AREA` with `data: { path: '/my-page' }`83 and a `render` — the page mounts in the workspace (main) pane like any84 built-in view. Make it reachable with a sidebar nav row:85 `ctx.register({ id: 'nav', area: SIDEBAR_NAV_AREA, data: { path: '/my-page', label: 'My Page', codicon: 'project' } })`86 (renders below Artifacts, lights up at the route) — and/or a87 `PALETTE_AREA` command calling `host.navigate('/my-page')`.88- `ctx.storage.get/set/remove` — persistence namespaced to your plugin.89- `ctx.i18n.register({ en, ja, ... })` — ship your OWN locale bundles, scoped90 to your plugin (never edit core `en.ts`). Values are literal strings or91 interpolator functions; nested trees are addressed by dot-path. Read them92 reactively in components with `usePluginI18n(id)` returning `t('key', ...args)`93 (re-renders on a locale switch), or via `ctx.i18n.t` in handlers/stores.94 Resolution follows the app's active locale, then your `en`, then the raw key.95- Data: `useQuery`/`useMutation`/`useQueryClient`/`queryClient` (the app's ONE96 React Query client — cache, dedupe, `refetchInterval`, invalidate like core;97 never hand-roll a poll loop), plus `atom`/`computed` for plugin-local state.98- Backend: if the plugin ships a Python `plugin_api.py` (under99 `~/.hermes/plugins/<id>/dashboard/`, manifest `"api": "plugin_api.py"`), reach100 it with `ctx.rest('/path', { method?, body?, timeoutMs? })` and its live twin101 `ctx.socket('/events', onMessage)` — both scoped to `/api/plugins/<id>` by102 construction (traversal rejected). `ctx.socket` is a **no-op on OAuth103 remotes**, so always keep a polling fallback. The Python backend is imported104 only when the plugin is in `plugins.enabled` in `config.yaml` (separate from105 the in-app enable toggle). For gateway-wide data use `host.request` /106 `host.onEvent` instead.107108 For a local backend-only desktop plugin, `hermes plugins enable <id>` may say109 the plugin is not installed when only `dashboard/manifest.json` exists. Add a110 minimal root `plugin.yaml` plus an empty `__init__.py`, then run the enable111 command again. This writes `plugins.enabled` as a real YAML list. Do not use112 `hermes config set plugins.enabled '["<id>"]'`: some versions serialize that113 as a quoted scalar, so the API will not mount. Restart the Desktop gateway114 after enabling because Python routes mount only at startup.115- `Contribute` (mount-scoped): render `jsx(Contribute, { area, id, children })`116 inside a component so page-owned chrome (e.g. a titlebar control in117 `TITLEBAR_AREAS.center`) leaves when the page unmounts — `ctx.register` is for118 permanent contributions.119- `defaultEnabled: false` on the default export ships an opt-in plugin: it120 inventories in Settings → Plugins, off until the user flips it on.121- Users manage plugins in Settings → Plugins (enable/disable live, reveal122 folder). A disabled plugin stays disabled across restarts — don't fight123 it; the user turned you off.124- UI: the app's design language, importable directly — `Button`, `Input`,125 `Textarea`, `Select*`, `Switch`, `Checkbox`, `SegmentedControl`, `Tabs*`,126 `Dialog*`, `ConfirmDialog`, `DropdownMenu*`, `ContextMenu*`, `Popover*`,127 `Tip`/`Tooltip*`, `Badge`, `Kbd`/`KbdGroup`, `SearchField`, `ScrollArea`,128 `Separator`, `Skeleton`, `GlyphSpinner`, `EmptyState`, `ErrorState`,129 `CopyButton`, `StatusDot`, `LogView`, `Codicon`, `DecodeText`, plus `cn`130 and `icons.*`. Prefer these over hand-rolled elements so the plugin looks131 native; style with theme vars, never hardcoded colors.132133## Procedure1341351. Pick a short kebab-case `id`; the folder name must match.1362. Start from `templates/plugin.js`; keep the default export shape137 (`{ id, name, register(ctx) }`).1383. For a pane, register `area: 'panes'` with a `placement` hint and a139 `render` returning your component — the app places it into a sensible140 zone automatically; the user can drag it anywhere afterwards.1414. Fetch data with `host.request` and/or subscribe with `host.onEvent`;142 never poll faster than a few seconds.1435. Write the file with your file tools, then ask the user to run144 **Reload desktop plugins** from ⌘K.145146## Production Packaging and Migration147148### Open-source repository layout149150When publishing examples, keep each desktop plugin self-contained under151`plugins/<id>/plugin.js`. If it requires a backend, include only the portable152backend package (`plugin.yaml`, `__init__.py`, `dashboard/manifest.json`, and153`dashboard/plugin_api.py`) in the matching `plugins/<id>/` folder. Document the154separate install locations clearly:155156```text157repo/plugins/<id>/plugin.js → <HERMES_HOME>/desktop-plugins/<id>/plugin.js158repo/plugins/<id>/dashboard/... → <HERMES_HOME>/plugins/<id>/dashboard/...159```160161Ship a root README, per-plugin README where setup differs, a license, and a162`.gitignore`. Exclude credentials, OAuth artifacts, account/device/wallet data,163state databases, logs, local paths, ROMs/media/save files, and `node_modules`.164Never substitute a demo artifact (for example, an HTML deck) for the requested165plugin source: keep a presenter/launcher generic unless the user specifically166asks to bundle a particular artifact.167168Before publishing, scan the export for usernames, home paths, key/token169patterns, bearer headers, and accidental binary/user-data files. Also check that170README links point to actual current folders.171172### Rename invariant173174The disk-plugin folder, exported `id`, backend namespace, storage namespace,175and palette command IDs normally share one kebab-case identifier. A rename must176update all of them together, then remove the obsolete folder:1771781. Change the exported plugin `id` and command/storage identifiers.1792. Rename both desktop and backend directories if a backend exists.1803. Update README links and installation paths.1814. Verify the old folder is absent and the new `plugin.js` parses.182183A visible product name may remain more descriptive than its stable ID (for184example, `id: 'markets'`, name: `Hyperliquid Markets`).185186### Embedded documents and local files187188For a route that presents a user-selected local HTML document, use the browser189File API (`await file.text()`) and assign the result to an iframe `srcdoc`.190This keeps the content in memory: do not upload, copy, index, or persist it191unless the user explicitly requests a storage design.192193Treat chosen HTML as active content. Embed it in a sandboxed iframe with the194minimum permissions required; never give it Hermes APIs, plugin storage, or195filesystem access. State the supported format and unsupported conversions196clearly—an HTML presenter is not automatically a PowerPoint/PDF renderer.197198Routes unmount when users navigate away. If preserving a live iframe/emulator199session is required, create it once at module scope, move it into a hidden200parking container on unmount, and reattach it on return. This preserves only201in-memory state until Hermes closes or the plugin reloads; use the underlying202app's own save mechanism for durable state. Do not simulate persistence by203copying private user files into plugin storage.204205## Verification2062071. Run `node --check plugin.js` **from the plugin directory**. On this208 On Windows/MSYS, absolute Windows paths can be translated incorrectly by209 automatic linting.2102. For a Python bridge, run `python -m py_compile dashboard/plugin_api.py`;211 parse JSON manifests explicitly.2123. Do a focused ad-hoc static contract check when a plugin is added, renamed,213 or converted (IDs/folders, expected contribution areas, no unsupported214 imports, and no stale links). This is verification evidence, not a claim215 that a full test suite passed.2164. For an export, inventory the expected plugin folders, assert obsolete paths217 are absent, and run the privacy scan described above.2185. Only with the user's explicit permission, use `computer_use` to visually219 confirm a hot-loaded plugin's route/surface and its key interaction.220221## Pitfalls222223- **Title-bar areas have two distinct contracts.** `TITLEBAR_AREAS.left`, `.center`, and `.right` are generic render slots: register them with `render: () => ReactNode`, or mount `<Contribute area={TITLEBAR_AREAS.*}>...</Contribute>`. Do not put a `TitlebarTool` object in `data` for these exported areas. The shell consumes declarative tool data from separate internal areas (`titleBar.tools.left/right`); unless the SDK exposes a public constant for those, use the supported render-slot API.224- Icon types are surface-specific: title/status render slots use ReactNodes; sidebar `codicon` and `ComposerAttachmentProvider.icon` use codicon-name strings.225- `COMPOSER_AREAS.attachments` is a data-provider area, not a render slot. Register `{data: {label, icon, run}}`; use `render()` only for composer `top`, `bottom`, `underside`, `leading`, and `actions`.226227228- NEVER hardcode colors or backgrounds (`#000`, `black`, `rgb(...)`). Panes229 already sit on the app's editor background — leave the background alone230 and use theme variables for everything else: `var(--ui-text-secondary)`,231 `var(--ui-text-quaternary)`, `var(--ui-stroke-secondary)`,232 `var(--ui-accent)`. For canvas drawing, resolve them once with233 `getComputedStyle(canvas).getPropertyValue('--ui-accent')`.234- Reference only what you imported — a component you forgot to import235 (e.g. `StatusDot`) is a ReferenceError at render. Double-check every236 identifier in your `jsx()` calls appears in the import line.237- Canvas panes MUST track their container with a `ResizeObserver` and238 re-size the canvas (width/height attributes, not just CSS) — panes resize239 constantly (sash drags, layout switches); a mount-time-only size leaves240 blank space or blurry scaling.241- JSX syntax will not parse — the file loads uncompiled. Use242 `jsx('div', { children: ... })` from `react/jsx-runtime`.243- On this Windows/MSYS host, automatic `write_file` Node lint can mis-translate an absolute `C:\\...` path to `C:\\c\\...` and report a false `MODULE_NOT_FOUND`. Verify with `node --check plugin.js` from the plugin folder.244- Do not import anything except `@hermes/plugin-sdk`, `react`, and245 `react/jsx-runtime`; other specifiers fail to resolve.246- Handlers must read state imperatively (`$atom.get()`), never from render247 closures — rapid events will otherwise see stale values.248- Keep components small; subscribe (`useValue`) only in the leaf that249 renders the value.250251### Visual checks252253- The plugin's UI appears after **Reload desktop plugins**.254- No error toast ("Plugin <name> failed to load") appears; if it does, the255 message names the failure — fix and reload.256- For panes: the new zone is visible and draggable like any core pane.