MibyanPlugin.
It imports one module — @mibyan/plugin-sdk — and gets everything: the app’s
live state, the gateway JSON-RPC door, a scoped REST/socket backend namespace,
React Query, and the app’s own UI kit so plugin UI looks native by default. No
repo clone, no npm run build, no patching app source. Drop the file in
$mibyan_HOME/desktop-plugins/<id>/plugin.js and the app loads it within seconds
and hot-reloads every save.
Mental model
The SDK follows the VS Code module model. A plugin author imports exactly one module and never touches app internals (they are lint-fenced out of a bundled plugin, and fail to resolve in a disk plugin). Capability comes in tiers:host.state.*— readonly views over the app’s live state (nanostore atoms): active session, per-session turn-busy, cwd, gateway socket status, model, profile, viewport.gatewayis the WebSocket, not turn-busy.host.*actions — curated safe verbs: toast, navigate, tail logs, restart the gateway, subscribe to the gateway event stream.host.request— the gateway JSON-RPC door: sessions, config, skills, cron — everything the app itself calls.captureGatewayFileDownload()— capture a gateway file-save action immediately before starting a REST read, and retain it alongside the returned data. The action(storedPath, suggestedName) => Promise<void>keeps that read’s connection/profile scope even if the user switches hosts before clicking. Invoke only on an explicit user download gesture, using the backend’s persisted file path, never a guessed workspace path. Electron handles authenticated streaming, the native save dialog, and older-gateway fallback; plugins never receive credentials or open remote paths withfile://. The host shows the same “Saved” / “Download failed” toasts as the Files panel and stays quiet on cancel; the promise settles when the save does and never rejects.ctx.rest/ctx.socket— your plugin’s own backend namespace (/api/plugins/<id>) if you ship aplugin_api.py.ui.*— the design language: the app’s real components, theme variables, icons, and formatters, so your UI matches the app pixel-for-pixel.
Two delivery modes
All three take the same
MibyanPlugin contract, appear in Capabilities → Plugins,
and enable/disable live. A unified package is just the disk door scanning inside
your agent plugin’s folder — see
One package, both SDKs. Everything on this page is
written against the disk door (what you and the agent write);
Bundled plugins notes the two
differences. Radio ships as a bundled SDK-only plugin, off by default. Enable it
in Capabilities → Plugins for free live streams, station search, and status-bar
playback controls with an audio-reactive waveform. It uses the existing plugin
toggle and contributes nothing while disabled. Reference demos live in the companion
mibyan-example-plugins
repo.
Quick start — your first plugin
Create$mibyan_HOME/desktop-plugins/hello/plugin.js (that’s ~/.mibyan/...
by default). Desktop plugins are app-level — one root for every profile, gateway,
or remote machine the window connects to. The folder name must equal the plugin id.
desktop-plugins/, loads the file within a few seconds,
and hot-reloads every later save in place. If it doesn’t appear, run ⌘K →
Reload desktop plugins. If loading fails, a toast names the error — fix and
save again.
No JSX, no buildThe disk file is loaded uncompiled, so JSX syntax will not parse. Write UI
with
jsx() / jsxs() calls from react/jsx-runtime (or React.createElement).
The only importable specifiers are @mibyan/plugin-sdk, react, and
react/jsx-runtime — everything else fails to resolve, on purpose.The plugin contract
A plugin default-exports aMibyanPlugin:
register receives a scoped PluginContext. It never touches the registry
directly — the context auto-tags provenance (source: 'plugin:<id>') and
namespaces every contribution id (<id>:<localId>), so two plugins can never
collide.
render, data, or both, depending on the area.
Contribution areas — the cookbook
Import the area constants from the SDK; each area has its owndata payload.
Panes
A pane is a tile in the layout tree.placement is the semantic role — the pane
stacks (as tabs) with existing panes of that role; the user can drag it anywhere
afterward.
placement is 'main' | 'left' | 'right' | 'top' | 'bottom'. To land on a
specific edge instead of stacking, add a dock gesture — the same thing as
dragging onto a pane’s drop chip:
dock.pane is any pane id (workspace is the main thread; also sessions,
terminal, files, review, logs); dock.pos is
'top' | 'bottom' | 'left' | 'right' | 'center'. Declare a width/height so
the pane doesn’t claim half the zone.
Closing the only pane contributed by a plugin disables that plugin, which can
be re-enabled from Capabilities → Plugins. When a plugin contributes multiple
panes, closing one dismisses only that pane and leaves the plugin’s other panes,
commands, and middleware active. Reset layout restores dismissed contributed
panes.
Pages and sidebar nav
A route mounts a full page in the workspace pane, like any built-in view. Pair it with a sidebar nav row (and/or a palette command) to make it reachable.codicon is a VS Code codicon
id. Navigate to a route from anywhere with host.navigate('/my-page').
Status bar and title bar
Status-bar items render into the left or right cluster of the bottom bar. Simplest is arender function; for a plain button use data as a
StatusbarItem ({ id, label?, icon?, detail?, variant?, menuItems?, … }).
TITLEBAR_AREAS.left | .center | .right as TitlebarTool
data ({ id, label, icon, active?, onSelect? }).
Title-bar slots are permanent mount points: a component you register there
stays mounted while the user moves between the chat and full pages (Capabilities,
Messaging, Artifacts, contributed routes), so a useEffect that injects global
side effects (a <style> tag, html[data-*] attributes, a MutationObserver)
runs its setup once per registration and its cleanup once at dispose — never
mid-navigation.
Controls that belong to ONE page (the Kanban board switcher) go in
WORKSPACE_PAGE_HEADER_AREA instead: it renders in the workspace panel’s
tab-header row while that page is on screen and is empty otherwise. Wrap the
control in <WorkspacePageHeaderControl> (below) inside your page’s own header
row. In the workspace pane it projects into the page header; when the page is
opened in a split route tile, which has no page header, it renders inline where
you placed it. A raw <Contribute area={WORKSPACE_PAGE_HEADER_AREA}> only
shows up in the workspace pane.
Palette commands and keybinds
defaults is just the initial binding.
Themes
A theme contribution ships a fullDesktopTheme as its data (name, label,
colors, …). It appears in the theme picker like a built-in.
useTheme() reads the
painted appearance (theme, themeName, availableThemes, resolvedMode) and
changes it (setTheme, setMode, previewTheme) from a component:
host.onEvent callback — has no component to hang the hook
on. Use requestTheme(name) there. An unresolvable name is refused rather than
coerced to the default skin, so the return value doubles as the availability
check and a wrong name can never silently reset someone’s appearance:
setAccentOverride(hex) and clear it in ctx.onDispose — the standalone
Accent Picker
plugin is the worked example (it is also a complete, installable disk plugin).
Composer extensions
COMPOSER_AREAS (top, bottom, underside, leading, actions,
attachments, middleware) let a plugin add controls around the message
composer, provide an attachment source, or transform a draft before it is sent
(ComposerMiddleware with a handler(draft) => draft | null). top is a
banner strip above the input and bottom a row below the input grid, both
inside the composer chrome; underside is the floating strip BELOW the whole
composer with no chrome of its own — the seat for a suggestion pill or a status
hint that should sit outside the input frame (the next-prompt plugin renders its
“next prompt” pill there).
Composer draft API — read and write the live input
For everything the composer areas can’t do — put text INTO the input, replace what’s there, read the current draft, or send it — usehost.composer. This
is the supported door; reaching for the ProseMirror DOM, [data-composer-target]
lookups, or synthetic InputEvents is out of the plugin surface (catalog rule 8)
and breaks the moment the app’s markup moves. Addressing: null = the composer
the user is typing in; a session id (stored or runtime) = that session’s
composer, in the primary pane or a tile; 'new' = the fresh draft that has no
session id yet.
null is answered only by the surface
the app’s focus bus currently routes to; 'new' only by the primary pane while it
shows no session — it never falls through to the active composer. No exact
surface → null/false, never
a broadcast into whichever pane happens to be mounted. Writes go through the
app’s own paint path, so @-ref / /-command tokens hydrate as chips and the
result is byte-for-byte what the user would get by pasting. These are discrete,
user-triggered actions with the same authority as typing — no plugin “owns” the
draft afterwards, so there is nothing to tear down on disable; a plugin that
wants a persistent presence around the input uses a COMPOSER_AREAS slot instead.
A multi-session plugin keeps its per-session state on its side (which session
its panel is editing) and passes that id here; the bus guarantees one
plugin write can never land in another session’s composer.
Migrating off DOM reach-in (the held catalog plugins that motivated this API):
sessionId in the table is the id the plugin’s UI is bound to; for a composer
slot render it is host.state.focusedSessionId.get().
Session rows — decorations + the session list API
SESSION_ROW_AREAS (leading, trailing) let a plugin decorate sidebar
session rows. Register a data contribution whose render({ sessionId })
returns a small element (a badge, a swatch, a tag) or null for rows you don’t
own — registering costs nothing on every other row:
_lineage_root_id ?? id), never
the live one. Resolution goes through the rows this window has loaded; an id
that matches no loaded row is written as given, so pass the slot’s durable id
(not a live id you remembered) for a session that may have scrolled out of the
list. reorder accepts the same ids and maps each to its row’s live id
internally — the Recents order store is keyed by the live id, like the drag
path. pin(id, true, index) slots the pin at that position in the
Pinned list (a drop target between two pins); without index it appends, like
the row’s ⇧-click.
Arbitration. The verbs are discrete user-triggered edits of user data —
last write wins, exactly as if the user had clicked, and no plugin owns the
result afterwards. Slot contributions are ALL mounted (registration order, not
first-wins), each inside its own error boundary: a plugin that throws or
returns null for a row cannot suppress another plugin’s decoration on it, and
two decorations on one row render side by side. Core keeps the row’s layout,
gestures and title — slots augment, never replace.
Teardown. The verbs need none. Slot contributions are removed by the
ctx.register disposer (disable/reload drops them and the row re-renders
without the decoration).
Migration for the held catalog plugins:
- drag-to-pin-session — replace the
__reactFiber$*walk foronTogglePin/onReorderSessions/session._lineage_root_idwith the row’s slot id (render: ({ sessionId }) => …underSESSION_ROW_AREAS.leadinggives you the durable id per row), thenhost.sessions.pin(sessionId, true, dropIndex)for a drop into the Pinned section,host.sessions.pin(sessionId, false)for a drop back into Recents,host.sessions.reorderPinned(ids)for a drag within the Pinned section, andhost.sessions.reorder([])for its “reset manual order” path. - better-session-appearance — replace the
localStoragemibyan.desktop.sessionColorswrite and the fiber-harvestedonChangewithhost.sessions.setColor(sessionId, hex)(nullclears), and render its per-row glyph throughSESSION_ROW_AREAS.leadinginstead of mutating the row’s status dot (the durable id it needed from_lineage_root_idis the slot’ssessionId).
COMPOSER_AREAS.modelPill overrides the model pill’s label — a provider
({ label: (ctx: ComposerModelPillContext) => string | null }) receives
{ model, reasoningEffort, compact } and returns the text to show, or null to
let the next provider (then the core label) win. The pill keeps its chrome, pin
dot, and menu; only the label changes — the sanctioned replacement for the
MutationObserver text-rewriting plugins do today.
Model pill label providers
null, '', a whitespace-only string, and any
non-string value (an object, array or number is never rendered — the label is
placed straight into JSX). A provider that throws also declines — the error
is swallowed and the pill falls through to the next provider, then to the core
label, so a broken plugin can never blank the pill. reasoningEffort is always
a string ('' when the model has no effort level, never undefined). In
compact (floating) mode the pill renders only the chevron and no provider is
called. label() is re-evaluated only when the registry, the model, the effort
level or the compact flag changes.
Teardown. The provider is an ordinary data contribution: ctx.register
returns its disposer and the loader drops it when the plugin is disabled or
reloaded, at which point the core label is restored. There is nothing to undo
in ctx.onDispose.
Migrating compact-reasoning-label. The plugin used to find the pill via
[data-slot="composer-root"] button span.truncate, regex-strip a trailing
effort word from span.textContent, and re-run that sweep from a body-wide
MutationObserver plus a 1 s setInterval. On current builds the core label no
longer contains the effort word (the level has its own ReasoningPill), so the
strip is a no-op; the sanctioned shape is to compute the label from the context
instead of editing rendered text:
Appearance settings
APPEARANCE_AREAS.extra renders contributions at the end of Settings →
Appearance, after the built-in sections. It is the seam for a plugin that
used to inject nodes into that page or drive its widgets through React
internals.
id (with Retry) and the rest of the page (and other plugins’
cards) keep rendering. The slot mounts on the top-level Appearance page only,
not on deep-link subpages (settings/appearance/<section>), and there is no
“first wins” — plugins cannot suppress each other here.
Teardown: the registration is owned by the plugin loader; disabling or
reloading the plugin disposes it and the card disappears on the next render.
Nothing persists app-side, so there is nothing to clean up in ctx.onDispose.
For colour picking use the app’s own swatch grid — ColorSwatches (already an
SDK export) renders exactly what the profile rail and project dialog render,
with your own onChange; feed it PROFILE_SWATCHES and pair it with
host.sessions.setColor(id, color) for session colours.
Migrations for the plugins that motivated this slot:
- better-session-appearance — replace the fiber walk that harvests the
Appearance submenu’s
{ onChange, swatches }and theclearBtn.after(...)/host.appendChild(panel)injection into the app dropdown with onectx.register({ area: APPEARANCE_AREAS.extra, id: 'rules', render })whose card renders<ColorSwatches swatches={PROFILE_SWATCHES} value onChange />plus its bold/glyph/auto-rule controls; drop thedata-better-session-appearanceattribute writes and the dropdownmax-heightoverrides. - mibyan-appearance-hub — mount its paper-texture / font / intro-copy
controls as an
APPEARANCE_AREAS.extracard instead of a status-bar menu that reaches into Settings; the settings values still go throughhost.settings(allowlisted keys) andTHEMES_AREA.
Embedding external content
Use the SDK’s<SandboxedFrame src title /> for any external web content
(reader views, dashboards, docs). It renders a sandboxed iframe with the app’s
guest-content posture: opaque origin, allow-scripts by default, no-referrer,
lazy loading. Never mount a raw Electron <webview>: it lands on the app’s
persist: preview partition, sharing the app’s cookies and storage.
ComponentProps<'iframe'>: allow
(Permissions-Policy delegation — would hand a third-party site the mic/camera
grant the app holds), srcdoc, name, allowFullScreen, csp,
credentialless and every other iframe attribute are not props and nothing is
spread onto the element, so they cannot reach the DOM even through a cast.
Arbitration (allowlist, not blocklist): the only tokens a caller may add are
allow-scripts, allow-forms, allow-downloads, allow-pointer-lock,
allow-orientation-lock, allow-presentation. Everything else —
allow-same-origin, allow-top-navigation*, allow-popups*, allow-modals,
allow-storage-access-by-user-activation, and any token the primitive does not
know — is dropped case-insensitively even if passed; an emptied set falls back
to the default posture (allow-scripts), because a frame with no sandbox
attribute is fully privileged. loading="lazy" and
referrerPolicy="no-referrer" are not props. The opaque origin IS the
containment: guest content cannot reach the app, its storage, or the preload
bridge.
Teardown: it is a plain React element — unmounting your pane/page removes the
frame and its realm; nothing is registered app-side.
Migration for rss-reader (#115972): replace the stubbed /preview → 501 →
host.openWorkspace('rss-browser') → empty RssBrowserFrame → ctx.os.openExternal
chain with <SandboxedFrame src={article.url} title={article.title} /> inside
the workspace page; drop the leftover .rss-browser-frame-host webview CSS.
Transcript directives — inline components the model addresses
TRANSCRIPT_DIRECTIVE_AREA makes the transcript itself a contribution area.
Register a named directive and the agent can render your component inline in
an assistant message by emitting a paragraph of the form ::name{key="value"}:
- The directive must be the entire paragraph —
::namemid-prose stays prose, so plugin components can never hijack running text. - Attributes are untrusted model output (
key="value"pairs, string-only). Validate your own fields; render nothing on garbage rather than guessing. - An unclaimed directive (no plugin registered for the name) renders as the plain paragraph it always was — nothing breaks when a plugin is off.
- Renders are wrapped in the contribution error boundary: a throw degrades to an inline error chip, never a dead message.
- First registration wins on a name collision; namespace adventurous names
with your slug (
myplugin-board, notboard).
::preview{file="…"}
renders the workspace HTML file live inside the message — a sandboxed
srcdoc iframe with an opaque origin (scripts run and the widget is fully
interactive; no reach into the app, its storage, or the bridge). The frame
sizes itself to the content (height live, width adopted from the content’s
intrinsic span, flush left in the message flow), and a theme prelude hands
the document the app’s resolved tokens (--foreground, --muted-foreground,
--accent, --border, --card), the app font, and a transparent
background — so widget-shaped HTML reads as native while a full page keeps
its own design. Non-HTML targets and remote gateways fall back to the
classic preview card. Tell the agent about your directive in a skill (that’s
how it learns to emit it).
Previewed widgets can also talk back. Inside the frame,
window.mibyan.send('get-price eth') (or a declarative
<button data-mibyan-send="get-price eth"> — no script needed) hands that
prompt to the agent as a user turn, off-screen: no bubble takes up the
transcript, the widget updating is the visible response. The turn is still
real — it wakes the agent, rides the composer’s steer/queue rules, and
persists (typed hidden) so resume and the session DB keep the full record.
Prompts are trimmed, capped at 500 chars (window.mibyan.maxLength), and
throttled to one per second per frame. Nothing is truncated or dropped
silently: send() returns a Promise that resolves { ok: true } once the
prompt reaches the chat’s composer, or { ok: false, error } where error is
too_long (with maxLength), throttled (with retryAfterMs), invalid,
or undelivered (no visible composer took it). Check it before showing a
widget as saved.
Mount-scoped chrome (Contribute)
ctx.register is for permanent contributions. When chrome should live and
die with a component that’s already on screen (a page’s own header control
leaves when the page unmounts), render <Contribute> inside it instead:
WorkspacePageHeaderControl instead. It picks
the placement from where the page renders: in the workspace pane it
contributes to WORKSPACE_PAGE_HEADER_AREA, and anywhere else (a split route
tile) it renders its children in place. Put it where the control should sit
when inline:
WorkspacePageHeaderControl is new in this release. Older desktop builds don’t
export it, and a named import of a missing SDK export stops the plugin module
from loading. A plugin that must also run on older builds either feature-detects
through a namespace import (import * as sdk from '@mibyan/plugin-sdk', then
sdk.WorkspacePageHeaderControl ?? …) or keeps the raw Contribute form above.
Sidebar nav visibility and order (SIDEBAR_NAV_PREFS_AREA)
A plugin hides or re-orders the sidebar’s top nav rows by contributing a
preference, not by writing a setting. Core merges every sidebarNav.prefs
contribution at render and applies the result to the rows it would otherwise
show; the default list itself never changes.
new-session, capabilities,
messaging, artifacts, cron (the SidebarNavId type; artifacts and
cron only render in Advanced mode). A contributed row’s id is its
registered SIDEBAR_NAV_AREA id, which ctx.register namespaces to
${pluginId}:${id} — a plugin that registered { id: 'kanban-nav', area: SIDEBAR_NAV_AREA } as kanban names that row 'kanban:kanban-nav' in
hide/order.
Arbitration. Hidden rows are the union of every contribution’s hide
(no plugin can un-hide another’s row; hide beats order), except
capabilities: the row hosting the Plugins tab is the user’s path to a
plugin’s own off-switch, so it can be moved but never hidden. Contributions
apply in the registry’s area order — lowest order, then registration —
and the first one’s order wins: later contributions place only ids not yet
placed, rows no order names keep their default relative order after the
named ones. Unknown ids are inert.
Teardown. The contribution lives in the registry, so disabling or reloading
the plugin disposes it and the rows come straight back — nothing to clear.
This is why it is not a host.sidebar.hide() verb: host is a singleton that
cannot attribute a write, a persisted preference would outlive the plugin, and
two plugins would overwrite each other’s order.
Persisting the user’s choice is the plugin’s job, in its own
ctx.storage: read the saved prefs on register, contribute them, and on every
edit save + dispose + re-contribute (re-registering the same id replaces it).
Host API
Everything onhost is reachable from anywhere in a plugin. State atoms are
readonly — read with .get() in handlers, subscribe with useValue(atom) in
components.
host.state.gateway is the WebSocket connection, not whether a chat turn is
running. A session can be mid-turn while the socket is open; another session
can be idle at the same time. Disable composer or plugin actions from the
focused session’s turn-busy (host.state.busyBySession[sessionId], or that
session’s view.$busy) — never from gateway, and never from a process-global
busy flag.
host.request is the same JSON-RPC the app itself uses (sessions, config, skills,
cron, kanban, …). host.requestProfile accepts a descriptor from
host.profileRoutes() and routes that RPC through its exact registry source and
profile without changing the active chat or gateway. The profile-only overload is
retained only for the sole-local/legacy topology; registry-aware plugins should pass
the descriptor so two sources exposing the same profile name cannot collide.
A call that may cold-start a pooled profile backend dials at background priority by
default, and background dials never get the slot the pool keeps free for user actions.
When the call IS a user action (a save, a button press, a dialog opening), pass
host.requestProfile(route, method, params, undefined, { spawnPriority: 'foreground' });
otherwise, with the pool full of warm backends, it waits out the 30-second slot timeout
and fails. Keep the background default for polling and roster warming.
host.openWorkspace(id, { render, title?, minWidth?, onClose? }) docks a
plugin-rendered view into the main workspace zone — the same center area
session tiles and previews use — as a tab, and reveals it. Re-calling it with
the same id refreshes the content in place and re-fronts the tab instead of
opening a duplicate. Closing the tab (the tab’s Close control or ⌘W) tears the
registration down and fires your onClose; the returned disposer closes it
programmatically. Feature-detect it (typeof host.openWorkspace === 'function') and fall back to a regular contributed pane on older desktop
builds — Bot Mode’s group-chat rooms are the reference consumer (main-window
takeover when available, in-panel view otherwise).
host.paneVisibility(paneId) returns a readonly reactive atom that is true
while a contributed pane is actually on screen: present in the layout tree,
not dismissed or hidden, its zone un-minimized, and holding its zone’s active
tab slot (a lone pane in its own zone counts). The id is the
contribution-scoped pane id, <pluginId>:<paneId>. Atoms are memoized per id,
so calling it in render is safe. Use it to register companion UI only while
your pane is visible — Bot Mode’s Cronjobs pane is the reference consumer: it
registers while the Bots pane holds the sidebar tab and unregisters when the
user tabs back to Sessions. Feature-detect on older desktops
(typeof host.paneVisibility === 'function') and fall back to
always-registered behavior.
host.profileRoutes() inventories every registered source in the current connection
registry. Connect-on-demand SSH sources expose a credential-free default seed
route without opening a tunnel, so a plugin can be the first caller that dials them;
an SSH remoteProfile remains the route’s backend targetProfile. connectionId
is the registry routing identity;
pair it with profile for keys and persistence. Endpoint, token, SSH host/key, and
other raw connection fields never cross the plugin IPC boundary. profile is the
source-local route used
for requests; targetProfile is the backend Mibyan profile served by that route.
They differ when a route explicitly maps to another backend profile (for example an
SSH remoteProfile override or a legacy per-profile URL alias). This distinction
preserves backend identity without exposing connection secrets.
Profile-shaped plugins get first-class methods too:
profiles.list (each profile + its most recent conversation as
last_session; pass include_sessions: false to skip the per-profile DB
probe; pass preferred_session_ids: { profileName: sessionId } for an
exact, existence-checked lookup of one pinned session per profile — each
named row gains a preferred_session summary that resolves hidden rows
and compression lineages to their live tip, or null when the id is
definitively gone; older gateways ignore the param and omit the field)
and profiles.create (name, description, clone_from,
clone_all, no_skills, soul, optional model + provider pin) — the
ws twins of the dashboard’s /api/profiles REST routes.
host.state.busy is the focused chat’s live turn (thinking and streaming).
host.state.awaitingResponse stays true from send until the first assistant
payload. Both follow the chat the user is actually looking at — the focused
session tile when one holds focus, else the primary workspace chat (the same
signal the statusbar’s busy pulse reads). Subscribe in a component:
host.onEvent (message.start,
message.delta, message.complete).
host.onEvent streams live gateway events (message deltas,
session lifecycle, tool activity). Listeners are isolated — a throw in your
listener can’t affect app dispatch. Every host door is async-safe: a sync throw
from an internal helper (e.g. no desktop bridge in a plain browser) becomes a
rejection your .catch() sees, never an error-boundary crash.
ctx.os is the curated OS door — every way a plugin reaches outside the app
window, in one namespace attributed to your plugin. ctx.os.notify posts a
native OS notification — the same Electron pipeline the app’s own
approval/turn alerts use. It fires only while the user is away from Mibyan
(backgrounded / unfocused); use host.notify for the in-app toast when
they’re looking at the app. Users can silence it per device under Settings ▸
Notifications ▸ “Plugin notifications”, and repeats from the same plugin are
throttled, so treat it as a signal for genuinely notable events — not a log.
Rich presentation + activation (extends the original ctx.os door):
activate is deeplink-compatible: mibyan://index-network/intent/1 and the
hash path /index-network/intent/1 resolve to the same in-app route (and the
same mibyan://… URL works as an OS deep link). Action buttons only render on
signed macOS builds; elsewhere the body click still activates. Navigation only
happens on user click — never from a background event alone.
The other doors (openExternal, revealPath, writeClipboard) resolve
false instead of throwing when the capability isn’t available (older desktop
shell, plain browser) — branch on the result rather than sniffing the bridge.
Desktop appearance settings — host.settings
host.settings is the supported door for the small set of Desktop-local
appearance preferences plugins may share with the native Settings page. Every
key is bound to the store atom + setter the Settings page itself uses, so a
plugin write is exactly a user click on that control: it takes effect at once,
persists through the preference’s existing storage schema, and the last write
wins (no plugin “owns” the value afterwards, nothing to tear down for set).
Unsupported desktop setting: … /
Invalid value for desktop setting: …) and nothing is written — host.settings
never touches localStorage directly, so it cannot bypass a store’s schema or
migration. Feature-detect host.settings when supporting older Desktop builds.
Deliberately not keys, and why:
Migration —
mibyan-appearance-hub, which today does
localStorage.setItem('mibyan.desktop.sessionListDensity', id) followed by
window.dispatchEvent(new StorageEvent('storage', …)) to wake the app’s store
(readSimpleKey/writeSimpleKey, readBoolKey/writeBoolKey):
host.settings.get(key); its MutationObserver on the Settings
page’s intro-splash switch becomes host.settings.subscribe('intro-splash.v1', fn)
(disposer → ctx.onDispose). prompt-snippets reads
localStorage.getItem('mibyan.desktop.keybinds') to back up its shortcut — that
is the keybind-map row above: contribute the default through KEYBINDS_AREA and
keep the user’s override in ctx.storage, not in the app’s map.
Typed capabilities bridge — host.skills, host.toolsets, host.profiles, host.pluginDecisions
api/* module functions the Capabilities page calls
(GET /api/skills, PUT /api/skills/toggle, GET /api/tools/toolsets,
PUT /api/tools/toolsets/<name>, GET /api/profiles) with the page’s profile
scoping. Omit profile to act on the app-wide active profile; pass a name or a
{ connectionId, profile } route to configure another profile without swapping
the foreground one. Nothing new is arbitrated: every call is already reachable
through host.request — the value is typing plus profile scoping, so stop
calling window.mibyanDesktop.api raw.
host.pluginDecisions mirrors the app’s plugin enable/disable map (plugin id →
true/false; an absent id means the user never chose and the plugin’s own
defaultEnabled applies). It is read-only by design: a set() would let one
plugin flip another plugin’s enable state — exactly “plugins messing with each
other’s functionality” — and host is a module singleton that cannot tell which
plugin is calling to restrict a writer to the caller’s own id. The object has no
set at runtime, not just in the types, and every value it hands out (get(),
.value, the argument to subscribe/listen callbacks) is a frozen copy —
assigning into it throws instead of leaking into the map the app reads and
persists. Enabling/disabling plugins stays in the
app’s Plugins tab; link to it with host.navigate('/capabilities?tab=plugins').
Teardown: the verbs are discrete user-triggered actions that write the same
backend state the page writes, so nothing is owned afterwards and there is
nothing to tear down. A subscribe() on host.pluginDecisions returns its
disposer — register it with ctx.onDispose so a disabled or reloaded plugin
stops listening.
Migration (better-capabilities):
Language packs — host.i18n.registerAppLocale / ctx.i18n.registerAppLocale
ctx.i18n.register localizes YOUR plugin’s strings. A language pack does the
opposite: it adds (or extends) a language for the WHOLE app — every core label,
dialog and tip — so a Polish user sees a Polish desktop. Registration is a
partial catalog merged over the bundled catalog for that id (or English for a
new language); anything the pack leaves out falls back per key, never to a raw
key. The switcher lists the language by its endonym at once (no flags — languages
are not countries), <html dir> follows rtl, and display.language stays
whatever the user chose: registering is not selecting.
results: n => `${n} results` ), a pack
gives a plain string with POSITIONAL placeholders — {0}, {1} in argument
order — and the merge wraps it into the same call shape. The full key set is
published in locales/_keys.desktop.json (regenerate with npm run i18n:keys
in apps/desktop and commit it; CI pins the file to en.ts), which is what mibyan plugins validate checks a pack’s <lang>.desktop.yaml against.
host.i18n.registerAppLocale is the same call for code with no ctx in reach;
it returns the disposer — hand it to ctx.onDispose. Prefer the ctx form.
A pack that also ships core (Python) and TUI strings needs no desktop code
at all: declare provides_locales: [pl] in plugin.yaml with
locales/pl.yaml, pl.tui.yaml, pl.desktop.yaml, and the gateway serves the
desktop file over i18n.catalog {lang, surface: 'desktop'}; the app pulls it
into the same registry (source backend) when display.language names it and
re-pulls on a profile switch.
Data layer — React Query + nanostores
Plugins share the app’s singleQueryClient, so plugin queries cache, dedupe,
poll, and invalidate exactly like core screens — never hand-roll a fetch loop.
atom /
computed — the same primitive host.state uses. Subscribe in the leaf that
renders the value with useValue. To invalidate a query from outside React
(e.g. a ctx.socket frame arriving), import the shared queryClient:
The UI kit and theming
Import the app’s real components directly so your UI is native by default:Button,Input,Textarea,Select*,Switch,Checkbox,SegmentedControl,Tabs*,Dialog*,ConfirmDialog,DropdownMenu*,ContextMenu*,Popover*,Tip/Tooltip*,Badge,Kbd/KbdGroup,SearchField,ScrollArea,Separator,Skeleton,GlyphSpinner,Loader,EmptyState,ErrorState,CopyButton,StatusDot,LogView,Codicon,DecodeText.
DecodeText’s loop is opt-in as of this change — it decodes once and holds by default, so pass loop explicitly on progress surfaces that should keep scrambling.
Plus helpers: cn (class merge), icons.* (the app’s lucide set), haptic,
profileColor / profileColorSoft (deterministic identity colors), the time
formatters relativeTime / fmtDateTime / fmtDayTime / coarseElapsed,
useI18n (localized copy — your plugin stays translatable), and
evaluateRuntimeReadiness.
Style with theme variables, never hardcoded colors. Panes already sit on the
app’s editor background — leave the background alone and use vars for everything
else: var(--ui-text-secondary), var(--ui-text-tertiary),
var(--ui-text-quaternary), var(--ui-stroke-secondary), var(--ui-accent).
For canvas drawing, resolve them once with
getComputedStyle(canvas).getPropertyValue('--ui-accent'). This is what makes a
plugin reskin automatically with every theme.
A backend for your plugin
If your plugin needs server-side work, ship a Pythonplugin_api.py and reach it
through ctx.rest / ctx.socket — a namespace scoped to your plugin by
construction.
One package, both SDKs
A feature that needs a desktop UI and agent-side code (a Python plugin, its backend routes, skills) doesn’t have to ship as two co-dependent installs. Put adesktop/plugin.js inside the agent package. When the package lands in any
local plugins/ root (default home or a profile), the Electron main process
copies that half into $mibyan_HOME/desktop-plugins/<id>/ beside a
.mibyan-package.json marker, and the renderer loads it through the exact same
pipeline as the standalone disk door (hot reload included):
desktop/plugin.js half is an ordinary disk plugin — same contract, same
imports, same ctx.rest('/…') reaching the plugin_api.py sitting beside it.
Installing, sharing, or removing the feature is one folder: the app-root copy
is refreshed when the source plugin.js changes (mibyan plugins update, or
Rescan) and removed when the package folder disappears. The copy is what
makes the desktop half app-level: it exists once, however many profiles
carry the package, and it never appears or disappears when the user switches
the Capabilities profile selector. The renderer never scans plugins/ itself.
The marker records the package name and its origin (catalog sidecar or git
remote), which is what the Install here button on the Plugins page uses to
install the agent half into another profile. The copy is staged beside the
target and renamed into place, so an interrupted copy (a transient file lock, a
crash mid-copy) never leaves a half-written folder behind; a leftover
desktop-plugins/<id>/ that has no marker and no plugin.js is treated as
such damage and replaced on the next Rescan, while a marker-less folder
that does hold a plugin.js is a standalone plugin you installed by hand and
is never overwritten.
Two enable switches still apply, on purpose, and both default to off: the
desktop half ships opt-in — it inventories in Capabilities → Plugins but stays
disabled until the user toggles it — matching the Python half’s
plugins.enabled gate in config.yaml (the security boundary below). Dropping
a package into ~/.mibyan/plugins is inert on every surface until the user
says otherwise. The desktop half degrades gracefully when the backend half is
off — ctx.rest returns errors, not crashes.
The copy is local to the machine the desktop app runs on. Against a remote
backend, the remote box’s
~/.mibyan/plugins is not reachable as a filesystem —
only locally installed packages contribute a desktop half this way. For a
remote backend the install dialog clones the desktop half separately into
desktop-plugins/, the same as a desktop-only repo. A package whose agent half
was installed on the remote host without that clone shows its Desktop half as
unavailable (remote backend) on the Plugins page — not as a pending copy —
and the tooltip points at Install from Git with the Desktop target checked.Distributing with an install link
Ship your plugin repo (agent half, desktop half, or both) and link to it with themibyan:// scheme — a plain anchor on your website or README:
force=1 replaces an existing install; dev builds use
mibyan-dev://. Full link reference:
One-click install links.
The Python side
Desktop plugins reuse the dashboard plugin backend mount. Put the backend in adashboard/ subfolder of a regular Mibyan plugin and declare it in a
manifest.json:
/api/plugins/<id>/ (GET /api/plugins/<id>/board, …).
Backend code runs inside the gateway process, so it can import from the
mibyan-agent codebase directly (mibyan_state, mibyan_cli.config, …). See
Extending the Dashboard → Backend API routes
for the full backend reference — the mount is identical.
Pushing events to your desktop half
Your backend runs inside the gateway process, so it can push an update to your own desktop half over the app’s global event stream — the same streamhost.onEvent subscribes to:
broadcast_plugin_event(plugin_id, event, payload=None): the wire name is
always plugin.<plugin_id>.<event>. plugin_id is your catalog name
([a-z0-9_-]{1,64}, no dots — it is the namespace and can’t spell another
plugin’s); event is the BARE dotted name ("feed.updated", not
"plugin.rss-reader.feed.updated"), segments of [A-Za-z0-9_-], so "",
"../x" or "a..b" raise ValueError instead of stranding the desktop half
on a name nobody emits. payload is a JSON dict (or omitted → {}), delivered
as the event’s payload; the frame carries session_id: "" like every global
event. Delivery is fire-and-forget (a wedged client is skipped, never stalling
your handler). Where it lands depends on the process the call runs in:
Use this instead of importing
tui_gateway.server internals; for plugin-scoped frames
with a payload tailored per connection, ctx.socket('/events') remains the
richer door.
Migration (rss-reader): drop the ~/.mibyan/rss-reader/commands.jsonl queue,
GET /commands and the 3 s ctx.rest('/commands') poll — the Python side
calls broadcast_plugin_event('rss-reader', 'feed.updated', payload) where it
used to enqueue, and the desktop side replaces the timer with
host.onEvent('plugin.rss-reader.feed.updated', fn) inside register(ctx).
Calling it from the plugin
ctx.rest is profile-aware and rejects path traversal (..) so you can never
address another plugin’s API or a core route through it. PluginRestOptions is
{ method?, body?, upload?: { filename, contentType?, bytes }, timeoutMs? }.
ctx.socket auto-reconnects with backoff until disposed. It resolves to a no-op
on OAuth remotes (single-use WS tickets are core-managed) — treat the socket as
an accelerator over polling, never a replacement. Every consumer needs a polling
fallback anyway, since any socket can drop.
For gateway-wide data (not your own namespace), use host.request (JSON-RPC) and
host.onEvent (the gateway event stream) instead.
Settings, enable state, and storage
Every plugin — enabled or not — inventories in Capabilities → Plugins, where the user toggles it live (no app restart), reveals its folder, or rescans. The user’s choice is remembered:- No choice yet → the plugin’s own
defaultEnabled(defaulttrue). SetdefaultEnabled: falseto ship an opt-in plugin that stays dark until the user flips it on. - Explicit choice → persisted and honored across restarts. A disabled plugin stays disabled — don’t fight it; the user turned you off.
ctx.storage, namespaced to your plugin
(mibyan.plugin.<id>.*) so plugins can’t read or clobber each other:
Bundled plugins
A plugin can ship in-tree atapps/desktop/src/plugins/<id>/plugin.tsx (default
export a MibyanPlugin). It’s discovered by discoverBundledPlugins() at boot —
no import, no registry edit — and shares the exact inventory + live
enable/disable contract as a disk plugin. The two differences:
- It goes through the app’s Vite build, so you can write real JSX and import
the SDK by its
@mibyan/plugin-sdkalias. - It’s still lint-fenced to
@mibyan/plugin-sdk+reactonly — no@/…app internals.
mibyan-example-plugins
companion repo.
Security model
A loaded plugin is evaluated as ESM in the renderer realm with full app authority — the React singleton, the whole SDK (host.request gateway RPC,
ctx.rest, storage, navigate) and the window.mibyanDesktop native bridge
(files, git, terminal, installs). The isolation the loader provides is error
isolation only: a plugin can’t crash the app (contributions are error-bounded,
listeners isolated, a throwing register() is rolled back and reported on the
plugin’s row), but it can do anything the app can. Plugin storage namespaces
are a convention, not a wall.
This is acceptable for local sources — a disk file can already run code on
your machine — which is why the disk door only loads local files you (or your
agent) wrote. For catalog
installs the trust comes from admission — a human reviewed the exact pinned
commit — backed by two tripwires: the desktop surface lint at admission and
the loader’s import allowlist (@mibyan/plugin-sdk and react* only; a static
or dynamic import of anything else, including https: URLs, fails the load).
Neither is a sandbox. A future remote-source door will need a real boundary
(iframe/worker + CSP + capability gating) before it can land; do not treat this
pipeline as a trust boundary.
Pitfalls
- JSX won’t parse in a disk plugin. The file loads uncompiled — use
jsx()/jsxs()(orReact.createElement), not JSX syntax. (Bundled plugins are built, so JSX is fine there.) - Only three specifiers resolve:
@mibyan/plugin-sdk,react,react/jsx-runtime. Any other import surfaces an up-front load error. - Never hardcode colors (
#000,black,rgb(...)). Leave the background alone; use theme variables (var(--ui-*)) for everything. - Reference only what you imported. A component you forgot to import (e.g.
StatusDot) is aReferenceErrorat render — double-check every identifier in yourjsx()calls appears in the import line. - Read state imperatively in handlers (
$atom.get()), never from a render closure — rapid events will otherwise see stale values. Subscribe (useValue) only in the leaf that renders the value. - Canvas panes must track their container with a
ResizeObserverand resize the canvas (width/height attributes, not just CSS) — panes resize constantly. - Don’t poll faster than a few seconds with
host.request; preferhost.onEvent/ctx.socketand let React Query dedupe. - Bare globals are not tracked.
window.setInterval,window.addEventListener, a<style>you append — the host never sees them, so they survive disable and every hot-reload (ES modules can’t be unloaded; a hot-edit loop stacks live copies). Usectx.setTimeout/ctx.setInterval/ctx.addEventListener, and wire anything else toctx.onDispose. Module-scope state is yours to reset. - Module evaluation has a 10 s deadline. A top-level
awaitthat never settles (waiting for a gateway that isn’t up) fails the load asimport timed outinstead of stalling the plugin scan; do the waiting insideregister(). - One id, one file. Two folders exporting the same
id(a standalone install beside a unified-package copy) load first-wins in folder-name order; the later one showsduplicate idon its own row in Capabilities ▸ Plugins. ctx.socketis a no-op on OAuth remotes. Always have a polling fallback.
Reference
SDK exports at a glance
The canonical, always-current export list is
apps/desktop/src/sdk/index.ts.
Agents: the mibyan-desktop-plugins skill
When an agent writes a desktop plugin, it should load the bundled
mibyan-desktop-plugins skill — it carries the same contract as this page in
agent-facing form, with a ready-to-copy templates/plugin.js. This page is the
human/developer reference; the skill is the working checklist.
Troubleshooting
My plugin doesn’t appear. Confirm the file is at$mibyan_HOME/desktop-plugins/<id>/plugin.js and the folder name matches the
export id. Run ⌘K → Reload desktop plugins. Check the app for an error
toast naming the failure, and tail mibyan logs gui -f.
“unsupported import” on load. A disk plugin may only import
@mibyan/plugin-sdk, react, and react/jsx-runtime. Remove any other import.
A jsx element renders nothing / throws ReferenceError. An identifier used
in a jsx() call isn’t imported. Add it to the import line.
ctx.rest returns 404. The backend isn’t mounted: confirm
~/.mibyan/plugins/<id>/dashboard/manifest.json has "api": "plugin_api.py",
that the plugin is in plugins.enabled in config.yaml, and restart the gateway
(backend routes mount at startup). Tail ~/.mibyan/logs/errors.log for
Failed to load plugin <id> API routes.
ctx.socket never fires. On an OAuth remote it’s a no-op by design — use your
polling fallback. Otherwise verify the backend exposes the matching
@router.websocket(...) route under its namespace.
Colors look wrong after a theme switch. You hardcoded a color. Replace it with
a var(--ui-*) theme variable.
