Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Built-in GUI plugins
Every feature of the desktop and web app that is not the core shell is a built-in GUI plugin: the terminal, review, files, the browser, SSH, WSL, the updater and more. Each one builds only on the GUI plugin SDK in packages/plugin/src/gui, the same @opencode/plugin/gui that third-party plugins use. The SDK is typed so that the common bugs fail to compile or fail the lint, and every declaration has TSDoc, so editor hovers answer most questions. This guide shows how the parts fit, with code from the plugins that ship. Build your first plugin walks through src/pairing, a small built-in that uses both processes. bun run lint checks every code block marked with its source against that source.
- Window SDK:
@opencode/plugin/gui(index.ts) - Main-process SDK:
@opencode/plugin/gui/main(main.ts) - Host-only, never published:
@opencode/plugin/gui/host(host.ts):compose, the bridge protocol and the plugin bundle evaluator - Built-ins only, never published:
@opencode/plugin/gui/builtin(builtin.ts):onIdle, store migrations, lazily loaded locales (builtinCatalog), and the registry fields and host abilities no third-party plugin needs (see Built-in only) - Rules for agents:
AGENTS.md
flowchart LR
subgraph main["Electron main (desktop only)"]
mh["main host<br/>packages/desktop/src/main/plugin"] --> me["main.ts entries"]
end
subgraph window["Each window (desktop or web)"]
wh["window host<br/>packages/app/src/runtime/plugin"] --> we["renderer.tsx entries"]
we -- "ctx.add(Registry, item)" --> wh
we -- "Contract (in process)" --> other["other plugins"]
end
me -- "Ipc: methods, state, events<br/>(schemas over the bridge)" --> we
Anatomy
src/pairing/
├── index.ts Plugin.define: os, stores, i18n
├── contract.ts tokens other code may import: Ipc, Contract, Registry
├── renderer.tsx window entry, default export Plugin.Setup<typeof definition>
├── main.ts main entry, default export Plugin.MainSetup (desktop only)
├── page.tsx heavy UI, loaded with lazy()
└── i18n/en.ts English copy
Unit tests of logic that carries a contract sit beside the code as *.test.ts, as in src/ssh/ and src/updater/.
Plugin.define is the manifest, the same call a third-party plugin's ./gui-experimental makes. The host lists attach each built-in's lazy windowEntry and mainEntry. The host reads the manifest before any entry loads. No definition declares an id: the host gives a built-in its key in compose, and a bundle the server plugin's id, as ctx.id. That id prefixes every id the plugin creates: commands, panel keys, stored keys, contract and Ipc ids.
| Field | What it declares | In the context |
|---|---|---|
os |
The operating systems it runs on; omit it to run everywhere, the web included | |
stores |
Window state the host loads before it is read | ctx.stores.name |
i18n |
The plugin's copy | ctx.t, ctx.plural |
Contracts are not declared: a plugin provides its own with ctx.provide and reads others' with ctx.use at runtime.
src/renderer.tsandsrc/main.tslist the built-ins, each throughcomposefrom@opencode/plugin/gui/host. These are the only files that name built-ins. "Built-in" is the host's flag for what these lists load, as the TUI marks its built-ins; the definitions themselves are ordinaryPlugin.definecalls.- Another plugin imports only your
contract.ts. - A window entry loads with the app. Keep it small and put heavy UI behind
lazy().
The context
Setup receives one context. Components read the same object with usePlugin<typeof definition>().
| Member | Window | Main |
|---|---|---|
| Host APIs | ctx.layout, ctx.sessions, ctx.storage, ctx.desktop, … (see the catalog) |
ctx.storage, ctx.windows, … |
| Another plugin's contract | ctx.use(token): Accessor<Live<T>> |
|
| Your own main entry | ctx.main(Ipc): its IpcClient |
|
| Declared stores | ctx.stores.name: Persisted, or (session) => Persisted |
ctx.storage.store(key, options) |
| Contribute to a registry | ctx.add(Registry, item) |
ctx.add(MenubarItem, item) |
| Provide a contract | ctx.provide(Contract, impl) |
ctx.provide(Ipc, impl) |
| Lifetime | Solid's onCleanup and ctx.signal |
ctx.onCleanup(fn) and ctx.signal |
| Copy | ctx.t(key, params), ctx.plural(key, count) |
the same |
- Host APIs are getters: an API you never read costs nothing.
- No host API throws before the app interface mounts: reads return their documented defaults (
ctx.layout.ready()is false), and writes such asctx.layout.openwait, then apply in call order. A dialogctx.dialogs.openopens meanwhile shows after the interface's first render. - Registries and contracts are tokens:
ctx.add(Command, …),ctx.provide(FileTree, …). - A plugin provides only tokens under its own id (
<id>or<id>.<name>): the window provides contracts, main provides Ipcs. The host throws for any other. - Bind each contract component with
bindPluginduring the provider's setup sousePluginreads the provider's context, not the consumer's. Props retain their getters. Leave data methods such asChanges.diffs()unwrapped. Annotate the bound component's props: TypeScript cannot infer them insidectx.provide.
ctx.provide(FileTree, { Tree: bindPlugin((props: FileTreeProps) => <Tree {...props} />) })
- Labels follow the language only when the host reads them again. Contribute an item with labels in the function form,
ctx.add(Command, () => ({ ... title: ctx.t("open") ... })): the window runs the function again when what it reads changes, and main reads it on every list. A plaintitle: ctx.t("open")in a fixed item keeps the language it was read in. - An item with a field of a literal type (
hidden: true, asection, anicon, a menu name) needs a return annotation in the function form,(): Command => ({ ... }), or no overload matches. - Objects the host keeps stable read their labels from getters,
get title() { return ctx.t("title") }: the tabsPanel.listreturns, and items with arender, such as panels and settings pages.
const setup: Plugin.Setup<typeof definition> = (ctx) => {
const changes = ctx.use(Changes) // Accessor<Live<Changes>>
const prefs = ctx.stores.prefs.value // loaded before setup
ctx.add(Command, () => ({ id: "open", title: ctx.t("open"), run: () => ctx.layout.settings(ctx.id) }))
}
Lifetimes
stateDiagram-v2
[*] --> loading: enabled
loading --> active: setup settled
loading --> failed: setup threw
active --> failed: an effect threw
active --> loading: reload
active --> disabled: disabled
active --> [*]: window closed
note right of active
Everything registered through ctx is withdrawn on leaving:
contributions, contracts, Ipc listeners, dialogs, embeds.
A registration made after its owner ended is withdrawn at once.
end note
- A registration ends with the current Solid owner: a component, a
createKeyedrun, or else the plugin. - Teardown of other work: return a cleanup from setup, as server and TUI plugins do, or use
onCleanupin the window andctx.onCleanupin main. - Window setup is synchronous: its
Cleanup | voidreturn type rejects async functions, and the host fails thenable-returning JavaScript setups instead of waiting for them. Put async work increateKeyedorcreateLatest, withctx.signalor a signal derived from it. Only main setup may be async. After eachawaitthere is no owner; return if the signal aborted before registering or changing state. Late registrations remain safe: the host releases them when the owner or instance ended.
The project menu (src/details/location-menu.tsx) keeps a session's uncommitted changes loaded only while review is
active and the menu shows. When review restarts or goes away, the outer run ends and its watch goes with it:
createKeyed(ctx.use(Changes), (changes) =>
createKeyed(
() => props.shown && props.screen,
(screen) => onCleanup(changes.watch(screen, "details")),
),
)
The routed session
flowchart LR
route["route: A → B → A"] --> objects["MountedSession objects: A₁, B₁, A₂"]
route --> screen["one SessionScreen: file, comment, composer"]
objects -- "props.session / input.session (getters)" --> renders["panel and slot renders, mounted once"]
objects -- "ctx.sessions.current()" --> setup["setup and handlers"]
screen -- "ctx.screen.current()" --> actions["actions on the routed session"]
- Each routed session gets its own frozen
MountedSession:key,id,tab,server,directoryandvisitnever change on one object, andlocation,projectand the other fields read that session's data, never the route's. It has nothing that acts on another session. - A session that moves to another directory gets a new object too, with the new
directoryand the samekeyandvisit. Key per-session state bykey. - Renders stay mounted. The host hands them the next object through a reactive getter, so read
props.session(panels),input.session(slots) orstate.session(tab labels) where you use it, and never copy it into a variable. - The workspace files, line comments and composer follow the route, so they belong to the session screen. An action through it targets the session routed at that moment. Read it inside a render or a handler, not once in setup. Key per-screen state, such as cached tab objects, by the screen; key per-session state by
session.key. ctx.screen.current()andctx.sessions.current()are published together in one batched effect after the screen's first render, with v2's original session-mount timing. Renders already receive their owning screen and session directly. Both public accessors are undefined during the first render, on Home, on a draft, before the interface mounts and once the route leaves the mounted screen. The screen has nosessionproperty.- All panel callbacks take one named input:
list({ session, screen, open }),render({ tab, session, screen }),focus({ tab, session, screen, restored }),enter({ tab, session, screen }),close({ tab, session, screen }),reopen({ tab, session, screen }),normalize({ id, session, screen }). Session fields are getters; the screen is the constant owning screen. Render tab fields are live getters too; lifecycle callbacks receive their event's tab. Stored ids andrestoreddescribe that call. Do not destructure live getters. Everysession.*slot gets a non-nullscreen;window.bottomdoes not. - Render effects can run before public publication too: change-list views call
Changes.watch(input.screen, source), notctx.screen.current(), so their first demand is never lost. fallback: trueopts a tab into selection when the stored selection is missing or cannot be selected; false or omission opts out. The host takes the first eligible regular tab, then a built-in'sfirsttab, then a pinned tab, preserving order within each tier. Closing a tab still selects its neighbour.- A
session.panelmenu item'sprimary: truemakes the strip's + run it directly; the first primary item inorderwins. It does not run automatically when an empty side region opens: the host shows its New tab view with the non-primary items that are not already open.opened: truehides an ordinary item from that launcher list, but not from the + menu.
ctx.add(Panel, {
id: "main",
region: "side",
list: (input) => (input.open.includes("main") ? [tab] : []),
// Runs once; `props.session` returns B's object after a switch to B, without a remount.
render: (props) => <View session={props.session} screen={props.screen} tab={props.tab} />,
})
// The composer follows the route, so it belongs to the screen: the file reaches the session routed when this runs.
ctx.add(Command, () => ({
id: "attach-readme",
title: ctx.t("command.attachReadme"),
enabled: !!ctx.screen.current(),
run: () => ctx.screen.current()?.composer.attach({ type: "file", path: "README.md" }),
}))
Referencing a subject
Attach a type: "note" part to reference a subject outside workspace lines. Set comment: "" to attach only the
subject: the model reads "The user referenced ." With a comment it reads "The user made the following
comment regarding : " instead.
For an uncommented note, optional title supplies the primary heading. Optional subtitle supplies a plain-text
footer beside the icon, with or without a comment. Each defaults to label when omitted. For example, the browser
uses a selector as title and its localized annotation label as subtitle; a comment replaces only the heading.
These display fields do not change subject. Both survive draft storage, sent-message metadata and restoration.
Only the process-bound live reference is dropped when a stored draft or sent message is restored.
Quoting selected content
Attach a type: "quote" part with a commentID, then call screen.composer.focus(commentID, { anchor }) to open its
optional comment beside the source. anchor(size) returns the editor's physical viewport { top, left } for its
current width and frozen opening height. It is first called after measurement (never with an unmeasured zero
height), then again on scroll, resize, and changes to reactive values it reads. The editor holds its opening bottom
edge as its input grows upward. Return undefined when the source is hidden or gone to fall back to the quote's
composer tile. The anchor is client-only and never persisted. Omitting the options
anchors the editor to the tile, as clicking that tile does. To quote without opening a comment, call
screen.composer.focus() instead; the built-in selection toolbars offer this through Alt-click.
The built-in terminal follows manual scrolling until new output or a grid resize invalidates the captured source. It then falls back to the quote's composer tile without closing the editor or discarding its unsaved comment.
Primitives
| Primitive | Use it for |
|---|---|
Plugin.define(definition) |
The manifest, typed so Plugin.Setup<typeof definition> sees the declarations |
compose({ id: definition }) (host only) |
A process's list of built-ins, keyed by id |
Registry.define<T>(id) |
A list your plugin owns and others contribute to |
Contract.define<T, Id>(id) |
An in-process API one plugin provides to others |
Ipc.define(spec) |
The main ↔ window contract |
Store.global / Store.session |
Declared window state the host loads before you read it |
createKeyed(source, fn, { otherwise, equals }) |
Side effects per provider generation or per value; the only sanctioned effect |
createLatest(source, fetch) |
Async data that never suspends and drops stale replies |
createVisitState(initial) |
State that resets each time the user routes back to the session |
usePlugin / usePanel / useDrawer |
The context, the panel frame, and the narrow-screen drawer in components |
bindPlugin(component) |
One contract component under its provider's context; data methods stay plain |
onIdle(fn) (built-ins only) |
Preloading a lazy chunk while the app is idle |
Lifetimes shows createKeyed in files.
// Forget a selection when the user goes Home and back.
const [selected, setSelected] = createVisitState<string | undefined>(undefined)
// Compile the settings page while the app idles, not when settings opens. Import `onIdle` from `@opencode/plugin/gui/builtin`.
const Page = lazy(() => import("./page"))
onCleanup(onIdle(() => void Page.preload()))
Pairing's shipping page reads through its state machine's driver, not createLatest, because a read can retry, time out and race a change of the same setting. Each read or change is an attempt of one generation of main: the driver passes the attempt's abort signal directly to the Ipc method, and every outcome, a timeout included, goes back to the machine, which ignores answers for an attempt it no longer runs. A new generation reads again:
// Reads or changes the display sleep setting through the generation that asked for it. `screenActive` has no input,
// so its options are its only argument. The attempt's signal ends either call at its timeout, or when the page or
// that generation goes away.
const runScreen = (effect: Extract<PairingEffect, { type: "read-screen" | "set-screen" }>) => {
const attempt = effect.attempt
const fail = () =>
dispatch({ type: effect.type === "read-screen" ? "screen.read.failed" : "screen.change.failed", attempt })
const live = main()
if (live?.generation !== effect.generation) return fail()
const signal = start(attempt, effect.timeoutMs, fail)
if (effect.type === "read-screen")
return void live.value
.screenActive({ signal })
.then((enabled) => dispatch({ type: "screen.read.succeeded", attempt, enabled }), fail)
void live.value
.setScreenActive(effect.enabled, { signal })
.then(() => dispatch({ type: "screen.change.succeeded", attempt }), fail)
}
Catalog
Each line links to the file whose TSDoc covers every field.
SDK icon fields take an IconName from @opencode/plugin/gui: the names of the app's icon catalog (@opencode/ui/icons/catalog), which bun run generate in packages/plugin copies into packages/plugin/src/gui/icon-name.ts. src/icon-name.typecheck.ts fails the typecheck when the two drift. A plugin bundle cannot require @opencode/ui, so it renders an icon with ctx.ui.Icon, and the app's buttons, menus, menu rows, dialog frame, fields, selects, settings pages and rows, Markdown and code with the other ctx.ui components. Built-ins use them too where they need nothing more, such as the settings rows of pairing, updates and review.
Registries
| Registry | Process | What an item is |
|---|---|---|
Command |
window | A palette command with an optional keybind and slash command |
MenuItem |
window | An item of a host menu: the side panel + menu, Add server, a server row |
Panel |
window | Tabs in the session's side region |
SettingsPage |
window | A settings page, a section on a host page, or rows in a host section |
Server |
window | A source of servers, such as SSH hosts |
LinkHandler |
window | Opens local links, such as file paths in messages, and says whether their target exists |
TitlebarItem |
window | A pill in the titlebar or the tabs footer |
ComposerPill |
window | A pill in the row above a session's composer, optionally opening a menu of rows or a popover of your own content |
CodeBlock |
window | Your UI in place of the highlighted code of complete fenced code blocks of the languages you list, such as a diagram or a table |
CodeBlockAction |
window | A button beside the Copy button of complete fenced code blocks of the languages you list, such as "Run in terminal" |
Slot |
window | Content for window.bottom, session.header, session.location.menu, session.panel.end; built-ins also fill session.location.workspace, session.location.branch.menu and session.location.path.menu through BuiltinSlot |
Style |
window | CSS imported with ?inline |
Theme |
window | A color theme in the app's theme file format, listed in Settings › Appearance › Theme |
MenubarItem |
main | An item of the native app menu |
session.location.menu contributes items after the project menu's location details. It receives the owning screen,
the live session, and shown; keep data subscriptions active only while shown is true. Loading headers without an
owning screen show the host's location details without this slot. Render its rows with ctx.ui.MenuItem,
ctx.ui.MenuSub, ctx.ui.MenuGroup and ctx.ui.MenuSeparator, so they navigate and close like the host's rows.
session.location.branch.menu (built-ins only, through BuiltinSlot) contributes items after Copy branch name in
the branch submenu, with the same screen, live session, and project-menu shown context. It renders only when a
branch name and owning screen are available. Use it for actions on the branch's working directory, such as opening
an integrated terminal.
session.location.path.menu (built-ins only, through BuiltinSlot) contributes items after the host's copy and
reveal actions in the session directory's path submenu. It receives the same session context and renders only with
an owning screen. Use it for external-app actions on session.directory without duplicating the host's file-manager
action.
session.location.workspace (built-ins only, through BuiltinSlot) replaces the current location row with the same
session context plus its label and icon; one contribution owns it. Without a contribution, the host keeps its
static location row. Use it to attach location actions directly
to the displayed repository or worktree instead of adding a second menu item.
Host APIs
| Property | Type (window, main) | What it does |
|---|---|---|
ctx.layout |
Layout |
Side panel tabs, scroll offsets, settings, open project |
ctx.sessions |
Sessions |
Sessions of open tabs, and the mounted MountedSession |
ctx.screen |
Screen |
Session screen files, comments, composer text, parts and model |
ctx.storage |
Storage |
Stores for keys known only at runtime, and window memory |
ctx.system |
System |
Clipboard, saving files, openExternal |
ctx.desktop |
Desktop | undefined |
Desktop-only: reveal, launch, installed, zoom, pick files |
ctx.dialogs |
Dialogs |
open, confirm, alert, prompt, select; close with owner |
ctx.toast |
Toasts |
Toasts that close when the plugin goes away |
ctx.tabs |
Tabs |
The shell's tab strip: list, open, show, move and close tabs |
ctx.attention |
Attention |
System notifications and sounds, per the user's settings |
ctx.commands |
Commands |
Runs a published or app command, as the palette does |
ctx.links |
Links |
Opens a local link, or checks it exists, with the best handler |
ctx.embeds |
Embeds |
Shows a web page the main entry created |
ctx.ui |
Ui |
Icons, buttons, menu rows, dialog frame, fields, settings rows |
ctx.app |
App |
Version, channel, platform, packaged |
ctx.locale |
Locale |
Locale and writing direction |
ctx.appearance |
Appearance |
The user's mono font, and the light or dark scheme |
ctx.router |
Router |
The route path, and whether it is changing |
ctx.keybinds |
Keybinds |
Display and matching of command keybinds |
ctx.servers |
Servers |
The servers the app lists, and each one's live ServerRef |
ctx.workspaces |
Workspaces |
on("remove", …) when a workspace is removed |
ctx.signal (main) |
AbortSignal |
Aborts when the instance goes away |
ctx.onCleanup (main) |
(fn) => void |
Runs fn when the instance goes away |
ctx.storage (main) |
Storage |
Synchronous storage, the same Persisted shape |
ctx.log (main) |
Log |
The desktop log file |
ctx.app (main) |
App |
The same shape; platform is "desktop" |
ctx.serverEndpoints (main) |
ServerEndpoints |
URL and credentials of a server the windows use |
ctx.windows (main) |
Windows |
The app's main windows |
ctx.embeds (main) |
Embeds |
create(view, window) places a web page |
ctx.cli (main) |
Cli |
The opencode CLI the app runs |
Built-in only
@opencode/plugin/gui/builtin holds what only the built-ins use. It is never published and is not in the modules a plugin bundle may require; a public SDK module never imports it.
| Export | What it adds |
|---|---|
onIdle(fn) |
Runs work while the main thread is idle, e.g. a lazy chunk's preload |
BuiltinPanel, BuiltinPanelTab |
legacy stored keys of a panel; first and stable dom ids of a tab |
BuiltinCommand |
follows: share another command's shortcut inside scope |
BuiltinStore, BuiltinStoreFrom |
Window stores imported from parts of the app's own keys |
builtinContext(ctx) |
ctx.desktop.forceFocus, ctx.locale.setDirection |
builtinMainContext(ctx), MainStoreFrom, Lifecycle |
Main stores imported from settings files or other namespaces, and lifecycle.restart(handoff) |
builtinCatalog(catalog), BuiltinCatalog |
Locales other than English as loaders, loaded when the user picks the locale |
Each registry token has the same id as the public one, so the host reads every item through it. The host builds every context with the built-in members; builtinContext and builtinMainContext only type them. A plugin bundle's catalog holds every locale inline, and the host rejects a bundle whose catalog has a loader.
i18n: builtinCatalog({ en, de: () => import("./i18n/de") }),
Ipc: main ↔ window
An Ipc is the typed contract between a plugin's main entry and its windows. Its schemas encode every value that crosses the bridge.
sequenceDiagram
participant W as updater window (ctx.main(Updater))
participant B as bridge
participant M as updater main (ctx.provide)
W->>B: subscribe
B->>M: state(window)
M-->>W: available + state snapshot
W->>B: check(), input encoded
B->>M: decoded input, Caller { window, signal }
M->>M: the state moves to "checking", provider.changed()
M-->>W: state push (an event always wins over an older snapshot)
M-->>W: reply, output encoded
M-->>W: emit("check", null, window): the app menu asks this window to check
- Define it in
contract.ts. Its id is your plugin id, or<id>.<name>. A method without an input schema takes only optional call options:pairing.screenActive({ signal }), never anundefinedplaceholder. - The main entry provides it with
ctx.provide. The plugin's own windows call it withctx.main(Ipc): a call waits while main starts, and rejects once main cannot answer. Another plugin follows it withctx.use(Ipc), aLiveaccessor. - On the web there is no main process:
ctx.usereads the Ipc asinactive, and everyctx.maincall rejects. - Import a token's schemas only where they are needed: a chunk that loads with the first session can import the full token and call
ctx.mainthere, so no schemas load at startup.
Live and failure handling
ctx.use(token)() returns Live<T>, one object per provider transition. A provider that restarts is pending until it is back.
| Status | When | What the user must see |
|---|---|---|
pending |
The provider has not started, e.g. main is not up yet | A loading state, or nothing to click |
active |
value is the contract or client; generation counts activations |
The feature |
inactive, "disabled" |
Turned off, not composed, or not on this platform (every Ipc on the web) | An unavailable state, never a dead button |
inactive, "failed" |
Its setup threw | An unavailable state |
- Offer an action only while it can answer, or let it answer with an unavailable state. Never a silent
return, never an endless spinner. createKeyedruns once per active generation. What the run registers ends with that generation.otherwiseruns while there is none.- An Ipc that goes away is a suspension, not a failure: keep what the user had and resume when it returns. No retry timer.
- An Ipc call can reject while the event that explains it is still in flight. When main reports an outcome as an event, let the event decide.
- A contribution that throws renders nothing and records the error. The rest of the window keeps working.
The updater's actions branch on the state its main entry pushes and still answer while main is missing:
// Main missing (not loaded yet, failed, or not on this platform) answers that nothing can check or install.
const act = (name: "check" | "install") =>
dispatchWindow({ type: "action.requested", action: name, available: state() !== undefined })
The pairing settings page waits for its main side only where it needs it. Pairing codes come from the local server's ServerRef, so they never wait for main; remote access and the display setting render only while main is active, so the page never offers a switch that cannot answer:
// Remote access and the display setting need pairing's main side; codes come from the server, so they never wait.
const main = () => {
const live = props.pairing()
return live.status === "active" ? live : undefined
}
Stored state
Desktop windows load storage over IPC; the web reads it synchronously. A read before load passes every web test and still breaks desktop, so declare your stores.
| Kind | Value | Loads |
|---|---|---|
Store.global(schema, initial, from?) |
window ctx.stores.name.value: never undefined |
Before setup |
Store.session(schema, initial, from?) |
window ctx.stores.name(session).value: undefined until loaded |
When the session mounts |
ctx.storage.store(key, options) |
window value: undefined until loaded |
When opened; for runtime keys |
main ctx.storage.store(key, options) |
value: always defined |
Synchronously |
A public from names older keys of the same storage. Built-ins also import parts of the app's own keys, which they replaced: BuiltinStore.global and BuiltinStore.session from @opencode/plugin/gui/builtin take { key, sessions?, pick }, and builtinMainContext(ctx).storage takes main's older homes.
Review imports one session's slice of an app key that holds every session:
// The mode, selected file and open files of each session.
session: BuiltinStore.session(
SessionState,
{ open: [] },
{
key: "layout",
sessions: "sessionView",
// An entry that holds only other fields, such as its scroll, holds no review state.
pick: (entry: { reviewMode?: unknown; reviewFile?: unknown; reviewOpen?: unknown } | undefined) =>
entry && [entry.reviewMode, entry.reviewFile, entry.reviewOpen].some((field) => field !== undefined)
? { mode: entry.reviewMode, file: entry.reviewFile, open: entry.reviewOpen }
: undefined,
},
),
SSH keeps a main store, imported from a key of the desktop settings file. { state: [namespace, key] } names a key of another main storage namespace instead, and file another settings file:
// The saved hosts; stored before in the desktop settings file.
const saved = builtinMainContext(ctx).storage.store("servers", {
schema: Schema.Array(SshConfig),
initial: [],
from: { settings: "ssh.servers" },
})
- Keys live in your namespace:
plugin.<id>.<name>in the window, the store's name underplugin.<id>in main. The host first imports each built-in store once from its earlierextension.key by itself, so do not list that key infrom. Other plugins never stored values there. fromimports an older value once, while the store holds none. A list names older homes, newest first. Withpick, the older key stays for its other owners; a pick returns undefined when that key holds none of its fields, never an object of undefined fields, so the next home is read.update(mutate)edits a deep-mutable draft and must return nothing orundefined. Returning the next value is a compile error and throws at runtime in both processes; useset(next)to replace the complete value, including main primitives and lists. PlainSchema.Structfields work withoutmutableKey. Window calls wait for load and apply in call order with each other; main writes reach disk immediately. Runtime-key stores have the same contract.Storage.remove(key, { from })reads asinitialagain and never importsfromagain.- Renaming a built-in that has shipped moves three things: stored keys (
from), command ids (the keybind rename map inpackages/app/src/settings/keybinds/migration.ts), and panel keys (BuiltinPanel.legacy). Never drop user data. The enable setting starts again under the new id.
A built-in always runs, whatever setting is saved under its id, so Extensions › Plugins does not list it, unless it failed: then its row shows the error and Reload, without a switch. For any other plugin, the setting saved under its id wins; with none, the plugin is enabled. A plugin from the config whose os leaves out this system never runs; Extensions › Plugins keeps its row, without a switch, with "Not available on this system." Both desktop hosts use this rule (pluginEnabled). A failed startup snapshot is unknown, so the window waits for the manager list rather than running disabled plugins.
Runtime rules
- Derive, don't sync. A value computed from other state is a
createMemoor a plain function. Never an effect that calls a setter. - Effects only for external sync.
createKeyedsyncs with something outside Solid: the DOM, a widget, an embed, an Ipc subscription. Logic a user action causes goes in the handler. - Key an object source by its fields. A
createKeyedsource that builds an object, such as{ directory, tab }, is a new key on every read. Passequalsto compare the fields that should run it again. - The lint bans raw effects.
createEffect,createRenderEffectandcreateComputedmay not be imported inpackages/gui-plugins/src; the SDK (packages/plugin/src/gui) wraps them. An escape hatch needs anoxlint-disablecomment with a reason. - Suspense only on first load. Wrap each
lazy()component in its own<Suspense>, read async data through.latestorcreateLatest, and never read a refetching resource in render: it blanks the nearest boundary. - Stable objects. Return the same object from
Panel.listand reactive contributions while nothing changed, so the host never remounts. - No timeline work.
session.headeris the only timeline surface, besidesCodeBlockandCodeBlockActioncontent, which the host renders only for a complete fenced block of a language you list. - No module state. Keep state inside setup; module state survives a reload and leaks across windows.
Testing
| Gate | Where | Catches |
|---|---|---|
| SDK compile checks | plugin/src/gui/context.typecheck.ts, plugin.typecheck.ts |
Async window setups, readonly drafts, panel and slot inputs, no-input Ipc options, declared store types |
| Registry compile checks | plugin/src/gui/registries.typecheck.ts |
A MenuItem field its menu ignores |
| Graph matrix | packages/app/component-tests/plugin-graph.spec.ts |
A built-in that fails when another built-in is disabled |
| Keeper suites | packages/app/e2e/regression/ |
What the user sees, per product area |
| Unit tests | *.test.ts beside the code |
Pure logic with a contract: paths, migrations, protocols |
| Lint gate | bun run lint (oxlint, ast-grep, script/sdk-docs.ts); bun run lint:changed |
Raw effects, app imports, module state, undocumented SDK, a guide block that differs from its shipping source |
- Test a main entry through its
Ipccontract with real inputs, not Electron mocks. - The hosts own their contracts: main storage in
packages/desktop/src/main/plugin/storage.test.ts, the real window host inpackages/app/component-tests/plugin-host.spec.ts. A plugin does not test them again. - Drive the race the user hits: a reload during async work, a store that loads late, an Ipc that goes away and returns.
Build your first plugin
Pairing connects another device to this machine's server from anywhere, and keeps the display awake. It is small and uses both processes: a main entry that turns on remote access through the CLI and owns a power-save blocker, and a window entry that adds a settings page and a command. The window asks the local server for its remote address and pairing codes through ctx.servers: that server's ServerRef is already authenticated in the window, so its credentials never leave main. Each block below is its shipping source.
- Define the contract.
contract.tsholds the tokens other code may import, here the Ipc between pairing's main and window entries. It carries only what needs the main process: remote access, a setting of the background service, and the display sleep blocker. The schemas type both sides and encode every value.
import { Schema } from "effect"
import { Ipc } from "@opencode/plugin/gui"
/**
* Turns on remote access and keeps this machine's display awake while another device uses it. The window asks the
* local server for its remote address and pairing codes through its own `ServerRef`; remote access, which changes
* the background service's settings through the CLI, and the display sleep blocker need the main process.
*/
export const Pairing = Ipc.define({
id: "pairing",
methods: {
/** Whether remote access is on: the background service serves an OpenTunnel address. */
remote: { output: Schema.Boolean },
/**
* Turns remote access on or off, then starts the background service again. Turning it on the first time creates
* the tunnel, which can take a minute.
*/
setRemote: { input: Schema.Boolean },
/** Whether main holds the display sleep blocker. */
screenActive: { output: Schema.Boolean },
/** Holds or releases the display sleep blocker, and remembers the choice. */
setScreenActive: { input: Schema.Boolean },
},
})
- Write the definition. Pairing declares only its copy: its Ipc is provided at runtime, and its state lives in main.
import { Plugin } from "@opencode/plugin/gui"
import en from "./i18n/en"
export default Plugin.define({ i18n: { en } })
- Provide it from main. Whether main keeps the display awake is a main store, read synchronously and imported once from the key the desktop kept it under before; a built-in names that older home through
builtinMainContext. Remote access runsopencode servicethroughctx.cli, one command at a time through the machine inmain-machine.ts, with the samecreateDispatchloop as the settings page: a read stops when its caller goes away, a change goes on when the window closes, and every command has a timeout, so a hung one never holds the queue.ctx.onCleanupdisposes the machine, which releases the blocker.
import { execFile } from "node:child_process"
import { powerSaveBlocker } from "electron"
import { Schema } from "effect"
import { builtinMainContext } from "@opencode/plugin/gui/builtin"
import type { Plugin } from "@opencode/plugin/gui/main"
import { Pairing } from "./contract"
import { createDispatch } from "./dispatch"
import {
createPairingMainState,
pairingMainEffectFailed,
selectBlocker,
transitionPairingMain,
type PairingMainEffect,
type ServiceRequest,
} from "./main-machine"
type Waiter = { resolve: (value: boolean | undefined) => void; reject: (error: Error) => void }
const setup: Plugin.MainSetup = (ctx) => {
// Whether main keeps the display awake; stored before in the desktop's own settings namespace.
const stored = builtinMainContext(ctx).storage.store("keepScreenActive", {
schema: Schema.Boolean,
initial: false,
from: { state: ["opencode.settings", "keepScreenActive"] },
})
const cli = ctx.cli
const log = ctx.log
const current = { state: createPairingMainState(), waiter: 0 }
// Each running command's abort controller, and each caller waiting on a command.
const commands = new Map<number, AbortController>()
const waiters = new Map<number, Waiter>()
const dispatch = createDispatch({
read: () => current.state,
write: (state) => {
current.state = state
},
transition: transitionPairingMain,
run: (effect: PairingMainEffect) => run(effect),
failed: pairingMainEffectFailed,
})
// The OS can drop the blocker, so whether main holds it is asked each time.
const screenActive = () => {
const id = selectBlocker(current.state)
return id !== undefined && powerSaveBlocker.isStarted(id)
}
const run = (effect: PairingMainEffect) => {
if (effect.type === "blocker.start")
return dispatch({ type: "blocker.started", id: powerSaveBlocker.start("prevent-display-sleep") })
if (effect.type === "blocker.stop") {
if (powerSaveBlocker.isStarted(effect.id)) powerSaveBlocker.stop(effect.id)
return
}
if (effect.type === "persist.screen") return stored.set(effect.enabled)
if (effect.type === "service.abort") {
commands.get(effect.attempt)?.abort()
commands.delete(effect.attempt)
return
}
if (effect.type === "waiter.resolve" || effect.type === "waiter.reject") {
const waiter = waiters.get(effect.waiter)
waiters.delete(effect.waiter)
if (effect.type === "waiter.resolve") return waiter?.resolve(effect.value)
return waiter?.reject(new Error(effect.message))
}
// Remote access is a setting of the background service, so it changes through the CLI the app runs, as with
// `opencode pair --remote`. A hung command ends at its timeout, so the next one always runs.
const attempt = effect.attempt
const controller = new AbortController()
commands.set(attempt, controller)
execFile(
cli.command[0] ?? "opencode",
[...cli.command.slice(1), "service", ...effect.args],
{ signal: AbortSignal.any([ctx.signal, controller.signal]), timeout: effect.timeoutMs, windowsHide: true },
(error, stdout, stderr) => {
commands.delete(attempt)
if (!error) return dispatch({ type: "service.succeeded", attempt, stdout: stdout.trim() })
log.write("error", "pairing service command failed", { args: effect.args, error: error.message, stderr })
dispatch({ type: "service.failed", attempt, message: error.message })
},
)
}
// Queues a command. A read stops when its caller goes away; a change goes on.
const service = (request: ServiceRequest, signal?: AbortSignal) => {
current.waiter += 1
const waiter = current.waiter
const cancel = () => dispatch({ type: "waiter.cancelled", waiter })
return new Promise<boolean | undefined>((resolve, reject) => {
waiters.set(waiter, { resolve, reject })
signal?.addEventListener("abort", cancel, { once: true })
dispatch({ type: "service.requested", waiter, request })
if (signal?.aborted) cancel()
}).finally(() => signal?.removeEventListener("abort", cancel))
}
if (stored.value) dispatch({ type: "screen.set", enabled: true, started: false })
ctx.onCleanup(() => dispatch({ type: "disposed" }))
ctx.provide(Pairing, {
remote: async (_, caller) => (await service({ kind: "get-remote" }, caller.signal)) === true,
// Changing the setting stops the service, and starting it again brings it back. A window that closes meanwhile
// does not cancel the change.
setRemote: async (enabled) => {
await service({ kind: "set-remote", enabled })
},
screenActive,
setScreenActive: (enabled) => dispatch({ type: "screen.set", enabled, started: screenActive() }),
})
}
export default setup
- Write the window entry. Pairing exists only on desktop, so setup returns at once on the web. The settings page is heavy, so it loads behind
lazy()and compiles while the app idles; its search entries are indexed without mounting it. The page receives the desktop's own server, found throughctx.serversbybuiltin, andctx.use(Pairing). The search entries for remote access and the display setting, like the settings themselves, exist only while main answers (seemainunder Live and failure handling).
import { lazy, onCleanup, Suspense } from "solid-js"
import { Command, SettingsPage, type Plugin } from "@opencode/plugin/gui"
import { onIdle } from "@opencode/plugin/gui/builtin"
import { Pairing } from "./contract"
import type definition from "./index"
const setup: Plugin.Setup<typeof definition> = (ctx) => {
if (!ctx.desktop) return
const layout = ctx.layout
const servers = ctx.servers
const pairing = ctx.use(Pairing)
const Page = lazy(() => import("./page"))
// Settings rows are small; load them while idle so settings opens without a blank row.
onCleanup(onIdle(() => void Page.preload()))
// The desktop's own server. Its ref is already authenticated in this window, so codes need no main process.
const local = () =>
servers
.list()
.map((id) => servers.get(id))
.find((server) => server?.builtin)
ctx.add(SettingsPage, {
id: "pairing",
icon: "outline-pair",
get title() {
return ctx.t("title")
},
get entries() {
const pairingEntry = { id: "pairing", title: ctx.t("title"), keywords: "pair device qr phone remote" }
// Remote access and the display setting exist only while pairing's main side answers.
if (pairing().status !== "active") return [pairingEntry]
return [
pairingEntry,
{
id: "settings-remote-access",
title: ctx.t("remote.title"),
description: ctx.t("remote.description"),
keywords: "internet tunnel opentunnel",
},
{
id: "settings-keep-screen-active",
title: ctx.t("screenActive.title"),
description: ctx.t("screenActive.description"),
keywords: "display sleep awake local",
},
]
},
render: () => (
<Suspense>
<Page server={local} pairing={pairing} />
</Suspense>
),
})
ctx.add(Command, () => ({
id: "open",
title: ctx.t("command.title"),
group: ctx.t("command.category.server"),
run: () => layout.settings("pairing"),
}))
}
export default setup
- Register it. A built-in joins both lists under its id, each composed with
compose. The main list names every built-in, so their ids stay reserved, and adds pairing's main entry:
/** Built-in plugins with their main entries, by id. Lists every built-in so their ids stay reserved. */
export const builtins = compose({
context,
btw,
debug: { ...debug, mainEntry: () => import("./debug/main") },
terminal,
file,
review,
details,
browser: { ...browser, mainEntry: () => import("./browser/main") },
pairing: { ...pairing, mainEntry: () => import("./pairing/main") },
updater: { ...updater, mainEntry: () => import("./updater/main") },
ssh: { ...ssh, mainEntry: () => import("./ssh/main") },
wsl: { ...wsl, mainEntry: () => import("./wsl/main") },
"microsoft-office": microsoftOffice,
sources,
goal,
})
The window list adds each window entry, loaded with the app:
/** Built-in plugins with their window entries, by id. The only place host builds name built-ins. */
export const builtins = compose({
context: { ...context, windowEntry: eager(contextRenderer) },
btw: { ...btw, windowEntry: eager(btwRenderer) },
debug: { ...debug, windowEntry: eager(debugRenderer) },
terminal: { ...terminal, windowEntry: eager(terminalRenderer) },
file: { ...file, windowEntry: eager(fileRenderer) },
review: { ...review, windowEntry: eager(reviewRenderer) },
details: { ...details, windowEntry: eager(detailsRenderer) },
browser: { ...browser, windowEntry: eager(browserRenderer) },
pairing: { ...pairing, windowEntry: eager(pairingRenderer) },
updater: { ...updater, windowEntry: eager(updaterRenderer) },
ssh: { ...ssh, windowEntry: eager(sshRenderer) },
wsl: { ...wsl, windowEntry: eager(wslRenderer) },
"microsoft-office": { ...microsoftOffice, windowEntry: eager(microsoftOfficeRenderer) },
sources: { ...sources, windowEntry: eager(sourcesRenderer) },
goal: { ...goal, windowEntry: eager(goalRenderer) },
})
Your own plugin takes the same steps:
- Pick the id. It is your folder name and your key in both built-in lists, and it prefixes your commands, panel keys, stored keys and Ipc ids. A later rename needs the migrations under Stored state.
- Define it. Create
src/<id>/index.tswithPlugin.define({ i18n: { en } }), andsrc/<id>/i18n/en.tswith your copy. Once other locales arrive, load them lazily withbuiltinCatalog({ en, de: () => import("./i18n/de") }). - Declare the tokens. Put any token another plugin or your main entry needs in
contract.ts: aContract, aRegistryor anIpc, each with an id under your plugin's id. - Write the window entry. In
renderer.tsx, aPlugin.Setup<typeof definition>contributes withctx.add, branches each action onctx.use(token)(), runs side work increateKeyed, and keeps heavy UI behindlazy()with anonIdlepreload. - Write the main entry, if you need Node or Electron. In
main.ts, aPlugin.MainSetupprovides your Ipc withctx.provideand tears down withctx.onCleanup. - Declare your state. Use
Store.globalorStore.sessionin the window andctx.storage.storein main, with afromfor every older home of the value. - Register both entries in
src/renderer.tsandsrc/main.ts, as above. The graph matrix then boots the app with your plugin disabled. - Test it. Unit-test pure logic beside the code. Prove what the user sees with a case in the area's keeper suite in
packages/app/e2e/regression/. - Check it. Run
bun run lint, which includesscript/sdk-docs.ts, thenbun run lint:changed, andbun typecheckinpackages/gui-pluginsandpackages/plugin.