Web API
The web half of a Plugin: definePlugin from @witwin/host 2.0, injected services, stable and proposed APIs, registration and slots.
Unstable: proposed services (
@witwin/host/proposed) are for system Plugins and for Plugins loaded from a local path while you develop. They can change in any release and an installed Plugin cannot use them, so build a Plugin you release on the stable services only.
A web half is one ES module that the Studio window imports. It gets a host
object scoped to its Plugin and reaches everything else through services.
The generated list of every service and host member is
@witwin/host services; every slot is in
Slots, events and URIs.
The module#
import { definePlugin } from '@witwin/host';
export const sdkVersion = '2.0.0'; // optional; the manifest's sdkVersion is checked too
export default definePlugin({
id: 'witwin.example_notes', // must equal the manifest id
inject: ['rpc', 'commands'], // services that must exist before activate
activate(host) {
const rpc = host.services.get('rpc');
host.registerCommand(
{ id: 'witwin.example_notes.reload', title: 'Reload notes', category: 'Notes' },
() => rpc.call('witwin.example_notes.read', {}),
);
},
});- The module exports the definition as
default(or asplugin). WritesdkVersion = "2.0.0"in the manifest: without it the window treats the module as SDK 1.0.0 and refuses it. idmust matchwitwin.toml. A mismatch refuses the module.injectlists services the module needs. The window activates the module only once every injected service exists. Injecting a proposed service the Plugin may not use fails the Plugin at once (statusfailed, with the rule that failed). Withoutinject,services.getthrows when the service is missing.activate(host)may beasync. Everything registered throughhost(panels, commands, slot entries, provided services) is withdrawn when the Plugin is disabled or unloads.deactivate()is for anything else.dependencieslists the Plugins this one needs. For a third-party Plugin the window always usespluginDependenciesfromwitwin.tomland ignores this field; a system Plugin, which has no manifest in the window, lists them here.slotsnames the slots this Plugin declares. The window uses it to tell that an entry waiting for one of those slots belongs to an optional dependency, so the entry waits quietly instead of being reported.replacesandhostsmean what they mean inwitwin.toml(Disabling, replacing and profiles). For a third-party Plugin the window takes them from the manifest when the manifest lists them, and falls back todefinePluginotherwise; the server reads only the manifest, so put them there. A module whosehostslack this window's host (desktoporweb) stays off with the reason on the Plugins page.- The old SDK 1 form (
export function activate(host)) and a module built against another major version are refused.
When a module cannot load or its activate throws, the window shows an
error toast ("Plugin failed to load") and keeps the Plugin off. It does not
try the same version again; a changed module (new content hash or manifest
fields) is tried again.
Lifecycle in the window#
A web half has one of these statuses (studio/src/app/pluginHost/pluginRuntime.ts):
| Status | Meaning |
|---|---|
waiting | A required dependency is not active yet, or an injected service does not exist yet. It activates by itself when they arrive. |
activating | activate is running. |
active | Running. |
failed | activate threw, or an injected service is not allowed. |
disabled | Turned off, replaced, or not available on this host. |
Turning a Plugin off first sends the Plugins that require it back to
waiting. Then the window calls its deactivate() (with no arguments) and
last withdraws everything it registered through host.
The server tells the window which web halves to run (plugin.host_modules
and the event host_modules.updated). When a Plugin's module file changes,
its content hash changes and the window unloads the old module and loads the
new one; you do not need to reload the window.
Building the module#
A web half is one file. The window does not serve it as a URL: it reads the
bytes and imports them from a blob: URL (desktop: through Electron Main;
Studio Web: only after the official signature checks out, see
Publishing, installing and trust). So:
- Bundle everything into one ES module. A blob module cannot resolve a
relative import (
./util.js) or a bare one (lodash); Studio has no import map.@witwin/hostitself is a bare import, so bundle it (it is a few small functions) or skip it andexport default { id, inject, activate }in plain JavaScript. - Do not bundle Studio's singletons:
react,react-dom,three,@react-three/fiber,@react-three/drei,zustand. Two copies of React or three break hooks andinstanceof. Read Studio's copies at run time instead:getSharedModules()from@witwin/hostreturns the record that Studio publishes onglobalThis.__witwin(react,reactDom,three,fiber,drei,zustand). - Compile JSX with the classic runtime (
React.createElement), using theReactyou read fromgetSharedModules(). @witwin/host/presethasSHARED_MODULE_IDS,rollupExternalConfig()andviewportPluginViteConfig()(a Vite config for oneviewport.jswith the classic runtime). Marking the shared idsexternalleavesimport ... from 'react'in the output, which a blob module cannot resolve, so read the singletons withgetSharedModules()rather than importing them. The comments inpreset.tsstill show the SDK 1 module shape (export function activate); writeexport default definePlugin.- Do not import Studio source.
host.uiholds live Studio widgets that share Studio's React.
Services#
host.services.get(name) returns a service. host.services.has(name) tells
whether it exists and is allowed. host.services.provide(name, impl) offers
a service of your own for other Plugins to inject (a kernel service name
cannot be taken over).
Stable services keep their shape for all of 2.x:
| Service | Use it to |
|---|---|
scene | read the current Scene's objects and selection, change a field or a transform (one undo step each) |
commands | run any command, register one run in this window |
contextKeys | read and set the keys that when expressions test |
notifications | show a toast that also lands in the notification panel |
opener | show a thing by its witwin:// URI, or handle a kind of URI (Linking panels) |
events | send or hear "something happened" notices (Linking panels) |
resources | load bytes by ResourceRef: fetch, objectUrl, signedUrl (Files and the network) |
rpc | call your Python half (call) and subscribe to its topics (subscribe). A third-party Plugin may use only its own <pluginId>.* methods and topics; other names throw. |
Proposed services may still change; their types come from
@witwin/host/proposed. The current list, with what each one does and what
must happen before it becomes stable, is in
@witwin/host services. Who may use them:
- A system Plugin: always.
- A third-party Plugin: only while it is loaded from a local path (the
Plugins panel,
--plugin-path,WITWIN_LOCAL_PLUGIN_PATHS, alocal_paths.json), and only the names listed inenabledApiProposalsin its manifest. An installed release and an environment Plugin cannot use them;services.getthrows a message that says which rule failed. - A kernel service that is in neither list is refused to every third-party Plugin: the gate is closed by default.
Each proposed service has a row in dev_docs/frontend/HOST_API_PROPOSALS.md:
who uses it and what must be true before it becomes stable. Adding a
proposed service means adding that row; npm run check:plugin-docs and
studio/src/app/pluginHost/hostApiProposals.test.ts both fail without it.
Registration functions#
host.register* functions put the common things in place without the
slots service, so they work for every Plugin.
| Function | Adds |
|---|---|
registerPanel({ id, title, icon, component, track, family, when, ... }) | a dockable panel (workbench.panels). A third-party id must equal the plugin id or start with <pluginId>., or the call throws (system Plugins keep their old ids for saved layouts). component gets { panelId, params }. More fields: shortTitle, tooltip, autoOpen, autoClose, multiInstance, rail, order, keybinding; the full list is PanelConfig in @witwin/host services. |
registerCommand(config, handler) | a command for the palette, menus, keybindings and the Agent |
registerMenuItem, registerKeybinding | menu rows and shortcuts for a command |
registerTopBarItem | an item in the top bar (there is no status bar: Studio keeps it hidden, and the 2.0 SDK has no status bar items) |
registerSettingsPage | a page in the Settings dialog (settings.pages) |
registerFileType({ extensions, icon, label, openCommand }) | an icon and a name for file extensions in the Project panel (project.fileTypes); openCommand runs on double-click with { path }. It adds no viewer and no opener handler. |
registerInspector(componentName, component) | the Properties body of one component. The first registration for a name keeps it: Studio's own inspectors (Transform, Material) register at startup, and a later one waits behind them and is reported. |
registerWidgetDecorator | the values your Plugin owns inside a built-in widget |
registerVisualizer, registerGizmo, registerOverlay, registerPass, registerTool | 3D content in the Scene view (React Three Fiber) |
registerChatLink | hover, embed and existence check for your witwin://<pluginId>.<kind>/ links. A third-party kind must start with <pluginId>.; any other kind is ignored with a console warning. |
registerAssistantCard, registerToolResultEffect, registerToolArgumentPolicy, registerAgentContext | Agent chat cards and hooks around Tool calls |
The Agent registrations (registerChatLink and the last row) go into slots
that studio.agent owns. While that Plugin is off they wait quietly and
attach when it comes back.
A page that must work with no Project open (there is then no stage to dock
a panel in) is an entry { id, component } of the kernel slot
workbench.pages; the proposed panels.openPage(id) shows it over the
editor area, like the Plugins page, and component gets onClose. Both
need proposed services (slots and panels), so a third-party Plugin can
use them only from a local path. The Gallery (studio.gallery) registers
its page both ways.
Other members of host:
host.stateis per-Plugin state with two scopes,globalandworkspace(this Project). Each has asyncget,set,delete,keysandclear. The server stores it, in the same files as the Python half'sctx.global_stateandctx.workspace_state.host.theme.tokens()gives the theme's CSS tokens for canvas code, andhost.theme.onChange(cb)tells you when they change. Use them, not color literals.host.assets.signedUrl(ref)gives a URL a 3D loader can open without headers. For Project files and Plugin files use theresourcesservice.host.playbackreads a Scene's displayed clock (getTime(sceneId),subscribe(sceneId, cb)).host.assistant.openSession(sessionId)drives one Agent Session.host.interactiontells the Scene view about gizmo drags (below).host.uiholds Studio'sButton,Select,DraggableNumberInput,CheckboxandrenderWidget.
3D content: props and the stage palette#
A visualizer or gizmo component gets ViewportObjectProps: object,
selected, hovered, and stage. A gizmo also gets tool, the active
viewport tool (select, translate, rotate, scale, bounds), while
its object is selected. Show resize handles only under bounds (T), as the
Field box does. Hosts older than a prop omit it, so give each a fallback.
stage says where the object is drawn: kind (world or bench),
xray (Bench X-ray is on), dark, and palette. Colour 3D annotations by
role from stage.palette instead of choosing hues:
| Role | For |
|---|---|
emitter | what injects energy: sources, exciting ports, transmitters |
sensor | what records: monitors, probes, receivers |
boundary | domain limits, absorbers, boundary conditions |
region | design, material and optimization regions |
warning | something the solve will not use as drawn |
accent | handles and selection |
text, edge, background | 3D labels, Bench part outlines, the clear colour |
The palette is Studio's single source for these colours: the Bench reads its own background and part outlines from it, so annotations of every Plugin match the stage in both themes.
In this sketch, hasComponent and FALLBACK_SENSOR stand for your own
helper and constant:
host.registerVisualizer({
id: 'acme.probe',
match: (object) => hasComponent(object, 'Probe'),
skipStandardGeometry: true,
component: ({ selected, stage }) => {
const color = stage?.palette.sensor ?? FALLBACK_SENSOR; /* allow-color: 3D fallback for older hosts */
return <mesh><octahedronGeometry args={[0.05]} /><meshBasicMaterial color={color} /></mesh>;
},
});A mesh with an invisible material (transparent, opacity 0) is a soft
click target: it selects the object only when nothing solid is under the
pointer, so a thin annotation can have a larger hit area. Handles set
userData.isGizmo = true on their meshes so a click on them does not
select, and call host.interaction.setGizmoDragging(true) while dragging
so the camera and selection stand down.
Slots#
A slot is a named place where Plugins add entries: the Console's channels,
the Tasks panel's step editors, the panels of the window. The Plugin that
owns the UI declares the slot; others register into it. The slots service
is proposed, so a third-party Plugin uses it only from a local path.
| Shape | Holds | Example |
|---|---|---|
single | one value; a second registration is a conflict | workbench.primary |
list | many values, ordered by order then registration | console.channels |
keyed | one value per key; a second value for a key is a conflict | workbench.panels (by id) |
chain | handlers asked in order until one takes the input | opener.handlers |
// Owner (a system Plugin, or a Plugin with "slots" allowed):
const slots = host.services.get('slots');
slots.declare<MyEntry>({ id: 'acme.solver.presets', shape: 'keyed', keyOf: (entry) => entry.id });
// Contributor:
slots.register('acme.solver.presets', { id: 'fast', label: 'Fast' });Rules that keep slots safe to use between Plugins:
- An entry for a slot nobody has declared yet waits and attaches when the
slot appears. When loading ends, waiting entries are reported as problems,
except entries into a slot owned by one of your optional
dependencies: those wait quietly until the user enables that Plugin, and
go back to waiting when it is disabled. The owner is known from the
slotslist in itsdefinePlugin. - A keyed entry another Plugin owns can be taken over only by a trusted
Plugin that names it in
replacesas"<slot>:<key>"; the window then registers it withreplaces: '*'for you. Otherwise the second entry for a key is a conflict problem. - By convention, slot ids of a third-party Plugin start with
<pluginId>., and slot ids of a system Plugin start with the area it owns (console.,tasks.). The window does not check the prefix. - An entry's value is data plus callbacks. It must not hold a reference to the owner's internal stores; if the owner gives entries something, it passes it in the callback's arguments.
- Name the slot's entry type in the owner's public types
(
@witwin/workbenchfor workbench slots, the owner Plugin'sapiotherwise) so contributors do not import the owner's source.
A slot entry with an optional dependency#
The pattern for "add something inside another Plugin's UI": list the owner as an optional dependency, register the entry, do nothing else.
# witwin.toml
enabledApiProposals = ["slots"]
[[pluginDependencies]]
id = "studio.console"
optional = true// web/viewport.js
export default {
id: 'witwin.example_notes',
inject: ['rpc', 'slots', 'commands'],
activate(host) {
const rpc = host.services.get('rpc');
const listeners = new Set();
let entries = [];
const reload = async () => {
const { lines } = await rpc.call('witwin.example_notes.read', {});
const now = new Date().toISOString();
entries = lines.map((text, index) => ({ id: `note-${index}`, timestamp: now, level: 'info', source: 'notes', text }));
listeners.forEach((listener) => listener());
};
host.services.get('slots').register('console.channels', {
channel: { id: 'witwin.example_notes', label: 'Notes' },
entries: () => entries,
subscribe: (listener) => { listeners.add(listener); return () => listeners.delete(listener); },
});
host.registerCommand({ id: 'witwin.example_notes.reload', title: 'Reload notes', category: 'Notes' }, reload);
// The first load must not fail activation while the server is still starting.
return reload().catch(() => undefined);
},
};With the Console off, this Plugin still activates and its entry waits; with
the Console on, a "Notes" channel appears. The full version, with tests, is
the example Plugin in server/tests/fixtures/plugins/witwin_example_notes/.
Rules for system Plugin web halves#
System Plugins live in studio/src/plugins/<dir>/ and follow the same rules
as third-party ones, plus:
- Import Studio code only from
@witwin/host,@witwin/ui,@witwin/workbenchand files inside the own folder; npm packages such asreactandlucide-reactstay allowed. Nofetch(,new WebSocket,window.witwin,http://,localhostorgetActiveProjectHttpBaseeither, and no platform sniffing (navigator.userAgent,navigator.platformand the like,isDesktop): askplatform.has(<capability>)instead.npm run lint:architecturefails on each of these, and on kernel code that importssrc/plugins/other than the listsrc/plugins/index.ts. - A type that two system Plugins share goes into
@witwin/workbench, not into one Plugin's folder: there is no cross-Plugin import betweensrc/plugins/<a>/andsrc/plugins/<b>/. - Do not reach for Studio stores (
useSceneStore,useConnectionStore): if a service is missing, add it as a proposed service with a ledger row. - Add the Plugin to
SYSTEM_PLUGINSinstudio/src/plugins/index.ts.