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 as plugin). Write sdkVersion = "2.0.0" in the manifest: without it the window treats the module as SDK 1.0.0 and refuses it.
  • id must match witwin.toml. A mismatch refuses the module.
  • inject lists 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 (status failed, with the rule that failed). Without inject, services.get throws when the service is missing.
  • activate(host) may be async. Everything registered through host (panels, commands, slot entries, provided services) is withdrawn when the Plugin is disabled or unloads. deactivate() is for anything else.
  • dependencies lists the Plugins this one needs. For a third-party Plugin the window always uses pluginDependencies from witwin.toml and ignores this field; a system Plugin, which has no manifest in the window, lists them here.
  • slots names 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.
  • replaces and hosts mean what they mean in witwin.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 to definePlugin otherwise; the server reads only the manifest, so put them there. A module whose hosts lack this window's host (desktop or web) 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):

StatusMeaning
waitingA required dependency is not active yet, or an injected service does not exist yet. It activates by itself when they arrive.
activatingactivate is running.
activeRunning.
failedactivate threw, or an injected service is not allowed.
disabledTurned 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/host itself is a bare import, so bundle it (it is a few small functions) or skip it and export 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 and instanceof. Read Studio's copies at run time instead: getSharedModules() from @witwin/host returns the record that Studio publishes on globalThis.__witwin (react, reactDom, three, fiber, drei, zustand).
  • Compile JSX with the classic runtime (React.createElement), using the React you read from getSharedModules().
  • @witwin/host/preset has SHARED_MODULE_IDS, rollupExternalConfig() and viewportPluginViteConfig() (a Vite config for one viewport.js with the classic runtime). Marking the shared ids external leaves import ... from 'react' in the output, which a blob module cannot resolve, so read the singletons with getSharedModules() rather than importing them. The comments in preset.ts still show the SDK 1 module shape (export function activate); write export default definePlugin.
  • Do not import Studio source. host.ui holds 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:

ServiceUse it to
sceneread the current Scene's objects and selection, change a field or a transform (one undo step each)
commandsrun any command, register one run in this window
contextKeysread and set the keys that when expressions test
notificationsshow a toast that also lands in the notification panel
openershow a thing by its witwin:// URI, or handle a kind of URI (Linking panels)
eventssend or hear "something happened" notices (Linking panels)
resourcesload bytes by ResourceRef: fetch, objectUrl, signedUrl (Files and the network)
rpccall 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, a local_paths.json), and only the names listed in enabledApiProposals in its manifest. An installed release and an environment Plugin cannot use them; services.get throws 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.

FunctionAdds
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, registerKeybindingmenu rows and shortcuts for a command
registerTopBarIteman item in the top bar (there is no status bar: Studio keeps it hidden, and the 2.0 SDK has no status bar items)
registerSettingsPagea 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.
registerWidgetDecoratorthe values your Plugin owns inside a built-in widget
registerVisualizer, registerGizmo, registerOverlay, registerPass, registerTool3D content in the Scene view (React Three Fiber)
registerChatLinkhover, 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, registerAgentContextAgent 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.state is per-Plugin state with two scopes, global and workspace (this Project). Each has async get, set, delete, keys and clear. The server stores it, in the same files as the Python half's ctx.global_state and ctx.workspace_state.
  • host.theme.tokens() gives the theme's CSS tokens for canvas code, and host.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 the resources service.
  • host.playback reads a Scene's displayed clock (getTime(sceneId), subscribe(sceneId, cb)).
  • host.assistant.openSession(sessionId) drives one Agent Session.
  • host.interaction tells the Scene view about gizmo drags (below).
  • host.ui holds Studio's Button, Select, DraggableNumberInput, Checkbox and renderWidget.

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:

RoleFor
emitterwhat injects energy: sources, exciting ports, transmitters
sensorwhat records: monitors, probes, receivers
boundarydomain limits, absorbers, boundary conditions
regiondesign, material and optimization regions
warningsomething the solve will not use as drawn
accenthandles and selection
text, edge, background3D 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.

ShapeHoldsExample
singleone value; a second registration is a conflictworkbench.primary
listmany values, ordered by order then registrationconsole.channels
keyedone value per key; a second value for a key is a conflictworkbench.panels (by id)
chainhandlers asked in order until one takes the inputopener.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 slots list in its definePlugin.
  • A keyed entry another Plugin owns can be taken over only by a trusted Plugin that names it in replaces as "<slot>:<key>"; the window then registers it with replaces: '*' 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/workbench for workbench slots, the owner Plugin's api otherwise) 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/workbench and files inside the own folder; npm packages such as react and lucide-react stay allowed. No fetch(, new WebSocket, window.witwin, http://, localhost or getActiveProjectHttpBase either, and no platform sniffing (navigator.userAgent, navigator.platform and the like, isDesktop): ask platform.has(<capability>) instead. npm run lint:architecture fails on each of these, and on kernel code that imports src/plugins/ other than the list src/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 between src/plugins/<a>/ and src/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_PLUGINS in studio/src/plugins/index.ts.