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#
| Word | Meaning |
|---|---|
| Plugin | A 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 Plugin | A 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 Plugin | Any other Plugin: developer-made, installed from the catalog, or generated by the Agent. Same API, stricter policy. |
| Python half | activate(ctx) in the Plugin's Python package. Runs in the ProjectServer. |
| Web half | An ES module that default-exports definePlugin({...}). Runs in the Studio window. |
| Manifest | witwin.toml. Studio reads it without running any Plugin code. |
| Contribution | Something a manifest declares under contributes.* (commands, settings, templates, solvers, skills ...). Declarations need no code. |
| Slot | A named list that one Plugin owns and others add entries to (console.channels, studies.kind.editor). See Slots, events and URIs. |
| Facet | Extra fields one Plugin adds to a component another Plugin owns, stored in the same component (ctx.components.add_facet). |
| Capability | A named service one Plugin provides and another requires. A Plugin that requires one waits until a provider is active. |
| Tool | A function the Agent can call. Registered with ctx.tools, named <plugin id>.<name>. |
| Skill | A SKILL.md folder the Agent reads (contributes.skills). |
| Template | A directory copied into a new Project, with a hashed content manifest (contributes.templates). |
| Solver child | A separate process on the Project interpreter that does heavy computation (contributes.solvers). |
| Project environment | The 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#
| Place | What runs there | What it may import |
|---|---|---|
| ProjectServer (one process per open Project) | The Python half: activate(ctx), RPC handlers, Tools, HTTP routes, startup steps | witwin.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 interpreter | witwin.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 Studio | witwin.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.rpcin Python,host.services.get('rpc')on the web); - Project files (
ctx.fs, theresourcesservice); - the shared services
selection,openerandevents(Linking panels).
The API layers#
| Layer | Package | Who uses it | Stability |
|---|---|---|---|
| A. SDK | witwin.studio (distribution witwin-studio) | everyone: solver children, scripts, the CLI, the ProjectServer | public, SemVer of witwin-studio |
| B. Plugin API | witwin_server.api | Python halves | every name in api/stable.py is stable; tests/snapshots/stable_api.json fails when one changes |
| Web host | @witwin/host 2.0 | web halves | stable services; @witwin/host/proposed is for system Plugins and local development only (Web API) |
| Shared web | @witwin/workbench/slots, @witwin/workbench/services, @witwin/ui | web halves | slot ids and services listed in Slots, events and URIs |
| Internal | everything else (witwin_server.features.*, witwin_server.plugins.*, another Plugin's private modules, studio/src/**) | Studio only | may 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 declaressdkVersionunder[contributes.viewport.host]. The window refuses a web half whose major differs. A web half withoutsdkVersioncounts as1.0.0and 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.
- Discover. Read the manifests from every source: explicit local
paths, the Project environment,
lock.jsoncloud 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. - 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. - Disabled? A Plugin listed in
.witwin/plugins/state.jsonis not loaded. Plugins that require it are blocked with "disabled because required dependency ... is disabled". - 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, noactivationEvents, onlycolors,themes,menus,keybindings,configuration,skillsand code-freeentityTypesundercontributes, and no.pyfile anywhere in its folder (plugins/trust.py). - API version.
engines.witwinmust includeWITWIN_API_VERSION, otherwise the reason is "Incompatible: ...". - 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. - 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 whoseentryis outside the Plugin, fails the whole load. A declarative Plugin stops here, fully working. - Import. Without
activationEventsthe 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>, oronStartupFinishedand*, which both fire once the server has started. - Wait for capabilities. If a required capability has no active provider yet, the Plugin waits. It continues when the provider activates.
- Activate.
activate(ctx)runs. Then the code parts ofcontributesare bound:contributes.toolsmodules are imported and theirregister(ctx)called, and view providers are attached. A lazy Plugin's Tools therefore exist only after its activation event. Next the module-levelhealth_check(ctx)runs if defined; returningFalseor{"ok": False}fails the activation.on_startup(ctx)runs once the server has started. Everything registered throughctxbelongs 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#
- The ProjectServer advertises each active Plugin's web half in the
plugin.host_modulesresult and thehost_modules.updatedevent. - The window checks
sdkVersion(same major as@witwin/host; a missingsdkVersioncounts as1.0.0and is refused). - 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.mdsection 10). Any other module shows "Open this Plugin in the desktop app"; the rest of the Plugin (Python half, settings) still works.
- Desktop: Electron Main reads them from the Plugin folder. A
runtime-installed Plugin needs a separate "Trust frontend code" grant
first (offered when
- 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. Ifactivatethrows, its registrations are undone, the status becomesfailed, a notification appears, and the same version is not retried until it changes. System web halves come fromstudio/src/plugins/index.tsand take the same path.
Unload, reload and turning off#
Unloading a Plugin (unload_plugin):
- Plugins that require it are unloaded first.
deactivate(ctx)runs, if defined. An exception is logged; unloading continues.- Every registration is disposed: RPC methods, topics, Tools, routes,
commands, components, slot entries,
sys.pathentries. - 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, andshader(inline GLSL also needs the user's shader grant).requestedCapabilitiesmust list every capability a Tool declares.networkis 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
enabledApiProposalsget 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#
| Path | What |
|---|---|
<project>/.witwin/plugins/state.json | Plugins turned off in this Project |
<project>/.witwin/plugin-consent.json | Trust granted in this Project |
<project>/.witwin/plugins/lock.json | Selected 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.json | Local development paths for this Project |
<project>/.witwin/plugin-state/<id>/workspace.json | ctx.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.toml | Plugin 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.