How the Plugin system works

The Plugin system as a whole: its terms, where Plugin code runs, the API layers and versions, loading and unloading, security and state.

This page describes the whole system: the words it uses, where Plugin code runs, which APIs a Plugin may use, and what happens between "a folder on disk" and "an active Plugin". The other pages give the details of each part; this one tells you how the parts fit.

Words#

WordMeaning
PluginA self-contained package that registers into Studio. It has a manifest (witwin.toml) and may have a Python half, a web half, or both. Everything else on this page is part of a Plugin.
System PluginA Plugin that ships inside Studio: id studio.<name>, source = "builtin". Trusted in advance, may use proposed APIs, can be turned off per Project but not uninstalled. Every Studio panel is one (decision D16).
Third-party PluginAny other Plugin: developer-made, installed from the catalog, or generated by the Agent. Same API, stricter policy.
Python halfactivate(ctx) in the Plugin's Python package. Runs in the ProjectServer.
Web halfAn ES module that default-exports definePlugin({...}). Runs in the Studio window.
Manifestwitwin.toml. Studio reads it without running any Plugin code.
ContributionSomething a manifest declares under contributes.* (commands, settings, templates, solvers, skills ...). Declarations need no code.
SlotA named list that one Plugin owns and others add entries to (console.channels, studies.kind.editor). See Slots, events and URIs.
FacetExtra fields one Plugin adds to a component another Plugin owns, stored in the same component (ctx.components.add_facet).
CapabilityA named service one Plugin provides and another requires. A Plugin that requires one waits until a provider is active.
ToolA function the Agent can call. Registered with ctx.tools, named <plugin id>.<name>.
SkillA SKILL.md folder the Agent reads (contributes.skills).
TemplateA directory copied into a new Project, with a hashed content manifest (contributes.templates).
Solver childA separate process on the Project interpreter that does heavy computation (contributes.solvers).
Project environmentThe Project's own Python environment (.venv), described by its pyproject.toml.

Do not use "workspace", "artifact" or "extension" for any of these (dev_docs/CONCEPTS.md). One legacy name remains in the API: ctx.workspace_state means per-Project state.

Where Plugin code runs#

PlaceWhat runs thereWhat it may import
ProjectServer (one process per open Project)The Python half: activate(ctx), RPC handlers, Tools, HTTP routes, startup stepswitwin.studio, witwin_server.api, the api module of a Plugin it depends on
Studio window (desktop or browser)The web half: panels, commands, slot entries, viewport content@witwin/host (and /proposed when allowed), @witwin/ui, @witwin/workbench, its own files
Solver child (one process per solve)Heavy computation on the Project interpreterwitwin.studio.solver (serve, query) and the Plugin's own packages; never witwin_server
Headless Python (witwin CLI, scripts, uv run)Code that opens a Project without Studiowitwin.studio only

Electron Main runs no Plugin code. On desktop it reads a web half's bytes from disk, behind the trust gate, and hands them to the window.

The two halves never share memory. They talk through:

  • RPC methods and topics under <plugin id>.* (ctx.rpc in Python, host.services.get('rpc') on the web);
  • Project files (ctx.fs, the resources service);
  • the shared services selection, opener and events (Linking panels).

The API layers#

LayerPackageWho uses itStability
A. SDKwitwin.studio (distribution witwin-studio)everyone: solver children, scripts, the CLI, the ProjectServerpublic, SemVer of witwin-studio
B. Plugin APIwitwin_server.apiPython halvesevery name in api/stable.py is stable; tests/snapshots/stable_api.json fails when one changes
Web host@witwin/host 2.0web halvesstable services; @witwin/host/proposed is for system Plugins and local development only (Web API)
Shared web@witwin/workbench/slots, @witwin/workbench/services, @witwin/uiweb halvesslot ids and services listed in Slots, events and URIs
Internaleverything else (witwin_server.features.*, witwin_server.plugins.*, another Plugin's private modules, studio/src/**)Studio onlymay change in any release; the boundary checks refuse imports

Two version numbers gate compatibility:

  • WITWIN_API_VERSION (2.0.0) is the Python Plugin API. A manifest declares the range it supports in [engines] witwin = "^2.0.0". The loader refuses a Plugin outside the range.
  • WITWIN_HOST_SDK_VERSION (2.0.0) is @witwin/host. A web half declares sdkVersion under [contributes.viewport.host]. The window refuses a web half whose major differs. A web half without sdkVersion counts as 1.0.0 and is refused.

From folder to active Plugin: the Python half#

The ProjectServer starts the system Plugins first, after the kernel, in dependency order (plugins/builtin.py). Then it runs these steps for each third-party Plugin (plugins/loader.py load_plugin). A Plugin that stops at a step keeps a reason, which the Plugins page shows.

  1. Discover. Read the manifests from every source: explicit local paths, the Project environment, lock.json cloud packages, vendored packages (Publishing, installing and trust). Nothing is imported. A manifest that does not parse or validate is skipped with a log line only; it does not appear on the Plugins page. A third-party Plugin that uses a system Plugin's id is dropped. A hosted (cloud) ProjectServer reads fewer sources; see Publishing, installing and trust.
  2. Resolve. Pick one copy per id and order Plugins by [[pluginDependencies]] (plugins/resolver.py). When two sources have the same id, source precedence breaks the tie, but a copy that breaks a required version range loses to one that fits. A cycle blocks every Plugin in it. A missing required dependency blocks the Plugin; it does not wait, so it loads only after a reload once the dependency exists. A missing optional dependency is fine, but a present one whose version is outside the declared range blocks the Plugin too.
  3. Disabled? A Plugin listed in .witwin/plugins/state.json is not loaded. Plugins that require it are blocked with "disabled because required dependency ... is disabled".
  4. Trust. Executable code from an installed, cloud or environment source waits until the user trusts it ("Awaiting trust"). System Plugins and local paths need no trust step: the developer added them. A purely declarative Plugin skips the step too; that means no main, no activationEvents, only colors, themes, menus, keybindings, configuration, skills and code-free entityTypes under contributes, and no .py file anywhere in its folder (plugins/trust.py).
  5. API version. engines.witwin must include WITWIN_API_VERSION, otherwise the reason is "Incompatible: ...".
  6. Python requirements. An installed (cloud) Plugin gets its missing requirements installed into .witwin/plugins/envs/<id>/<version>/. A local or environment Plugin never triggers pip; its reason is "Missing dependencies: [...]" and it loads no code.
  7. Declarations. The code-free contributes.* entries are registered: commands, menus, keybindings, configuration, colors, themes, view and webview metadata, viewport passes, solvers, models, entity types (plugins/contribution_stubs.py). A bad entry here, such as a solver whose entry is outside the Plugin, fails the whole load. A declarative Plugin stops here, fully working.
  8. Import. Without activationEvents the module is imported now. With them it is imported on the first matching event: onCommand:<id>, onView:<id>, onScene:<token> (an entity or component id in a loaded Scene), workspaceContains:<glob>, or onStartupFinished and *, which both fire once the server has started.
  9. Wait for capabilities. If a required capability has no active provider yet, the Plugin waits. It continues when the provider activates.
  10. Activate. activate(ctx) runs. Then the code parts of contributes are bound: contributes.tools modules are imported and their register(ctx) called, and view providers are attached. A lazy Plugin's Tools therefore exist only after its activation event. Next the module-level health_check(ctx) runs if defined; returning False or {"ok": False} fails the activation. on_startup(ctx) runs once the server has started. Everything registered through ctx belongs to the Plugin.

If import, activate(ctx) or health_check raises or fails, the Plugin keeps the error text, is not active, and everything it registered up to that point is released and its modules are forgotten. A system Plugin shows as failed. A failure never stops other Plugins from loading.

From module to active Plugin: the web half#

  1. The ProjectServer advertises each active Plugin's web half in the plugin.host_modules result and the host_modules.updated event.
  2. The window checks sdkVersion (same major as @witwin/host; a missing sdkVersion counts as 1.0.0 and is refused).
  3. The window gets the module's bytes:
    • Desktop: Electron Main reads them from the Plugin folder. A runtime-installed Plugin needs a separate "Trust frontend code" grant first (offered when permissions.frontend = true); local and environment web halves need none.
    • Web: only a module with a valid official signature loads (witwin-signature.json, Ed25519, dev_docs/HOST_CONTRACTS.md section 10). Any other module shows "Open this Plugin in the desktop app"; the rest of the Plugin (Python half, settings) still works.
  4. The runtime orders web halves by their dependencies (required and optional; it does not check version ranges) and activates each one after the Plugins it requires are active and the services it injects exist (app/pluginHost/pluginRuntime.ts). Statuses: waiting, activating, active, failed, disabled, each with a reason. Injecting a proposed service the Plugin may not use fails it at once. If activate throws, its registrations are undone, the status becomes failed, a notification appears, and the same version is not retried until it changes. System web halves come from studio/src/plugins/index.ts and take the same path.

Unload, reload and turning off#

Unloading a Plugin (unload_plugin):

  1. Plugins that require it are unloaded first.
  2. deactivate(ctx) runs, if defined. An exception is logged; unloading continues.
  3. Every registration is disposed: RPC methods, topics, Tools, routes, commands, components, slot entries, sys.path entries.
  4. The Plugin's modules are removed from sys.modules.

Reload is unload followed by load, so an edited local Plugin picks up its new code. A system Plugin is turned off through the Project's state ledger; see Disabling, replacing and profiles for what changes at once and what changes on the next start.

Only the Plugin's own package leaves sys.modules; its pip requirements stay loaded, so a reload does not pick up a changed dependency. A local Plugin is reloaded by the Agent Tool plugin.reload or by turning it off and on in the Plugins page; the web half is fetched again when the server publishes host_modules.updated.

On the web side, unloading a Plugin first unloads the Plugins that require it, then calls its deactivate(), then disposes everything it registered through host. A slot entry from a Plugin whose slot owner is off waits and appears again when the owner turns back on.

Security model#

  • No sandbox. A Python half runs inside the ProjectServer with its full privileges. A web half runs inside the Studio window. The manifest's [permissions] table is mostly a disclosure shown to the user. Three parts are enforced: tools (a Tool above the highest declared tier, or any Tool when no tier is declared, fails to register), tool_scopes, and shader (inline GLSL also needs the user's shader grant). requestedCapabilities must list every capability a Tool declares. network is shown to the user only; nothing limits traffic by it.
  • Trust before code. Installed, cloud and environment Plugins run no code until the user trusts them. Studio asks once per Project for environment Plugins. The CLI does not ask. A runtime-installed web half needs its own frontend grant.
  • Proposed APIs. Only system Plugins and local-path Plugins that list them in enabledApiProposals get proposed web services. Installed third-party Plugins never get them.
  • Web delivery. In a browser, only officially signed web halves load.
  • Out of process. isolation = "out_of_process" is accepted but not yet honoured: until the PluginHost ships, such Plugins also run in-process after trust.

Where Plugin state lives#

PathWhat
<project>/.witwin/plugins/state.jsonPlugins turned off in this Project
<project>/.witwin/plugin-consent.jsonTrust granted in this Project
<project>/.witwin/plugins/lock.jsonSelected cloud package versions
<project>/.witwin/plugins/installed/Project-vendored cloud packages
<project>/.witwin/plugins/envs/<id>/<version>/Python requirements of installed Plugins
<project>/.witwin/plugins/local_paths.jsonLocal development paths for this Project
<project>/.witwin/plugin-state/<id>/workspace.jsonctx.workspace_state, which is also ctx.storage and the web half's host.state.workspace
~/.witwin/plugin-state/<id>/global.json (or WITWIN_GLOBAL_STATE_DIR)ctx.global_state and host.state.global
~/.witwin/plugins/ (or WITWIN_GLOBAL_PLUGINS_DIR)Machine-wide package cache and local_paths.json
<project>/pyproject.tomlPlugin dependencies of a Project that is a Python codebase (witwin add)

None of these live inside a Plugin's source folder, and a Plugin must not write its own runtime data there either.