Python API

The Python half of a Plugin: activate(ctx), the three API layers, components and facets, entry points and the import rule.

A Plugin's Python code may import three layers and nothing else. Each layer has its own place to run.

LayerImportRuns inHolds
A. SDKwitwin.studioanywhere: Run children, headless scripts, the witwin CLI, the ProjectServerfile formats (.wtscene, Results), the solver SDK (witwin.studio.solver), extension points that must work without Studio (entry points below)
B. Server runtimewitwin_server.api, witwin_server.api.testingthe ProjectServer process onlythe names in Python stable API and the ctx object in activate(ctx) (PluginContext (ctx))
C. Between Plugins<package>.api of a Plugin you list in pluginDependencies (or its top package, for its import-time registrations); witwin_server.plugins_builtin.<name>.api for a listed studio.<name>wherever the provider's code can runtypes, protocols, pure functions. Live objects (registries, connections, caches) go through ctx.capabilities, not imports

Every other module (witwin_server.features.*, witwin_server.core.*, witwin_server.tools.*, another Plugin's private modules) is internal and may change without notice. server/scripts/plugin_api_boundary.py enforces this; each Plugin repository keeps an identical copy as tests/test_plugin_api_boundary.py (Testing a Plugin). The check recognises another Plugin's package only by the wt_ prefix or its PACKAGE_EXCEPTIONS list, so a third-party package with another name is not checked as a Plugin; the rule still applies to it.

A name in layer B is a promise: server/tests/test_stable_api_snapshot.py fails when one goes away or its signature changes. A new name needs the owner's sign-off; add it to STABLE_EXPORTS in server/witwin_server/api/stable.py and rerun python -m witwin_server.api.stable --write.

If a layer-A extension point exists for what you add (a Study kind, an optimizer), use it, not a ctx registration: the Run child and headless scripts have no ctx. Layer B may read a layer-A registry to show it in the UI; it never starts a second registry.

The Python half#

This is the example Plugin's Python half (server/tests/fixtures/plugins/witwin_example_notes/__init__.py). The logic sits in a plain function so a test can call it without a server.

# __init__.py
from __future__ import annotations
 
from pydantic import BaseModel
 
from witwin_server.api import PluginContext, ProjectFs, ProjectPathError, RpcError, get_logger
 
logger = get_logger("ExampleNotes")
READ_METHOD = "witwin.example_notes.read"   # third-party names live under "<plugin id>."
 
 
class ReadParams(BaseModel):
    path: str = "notes.txt"                  # a Project path, never an OS path
 
 
class Notes(BaseModel):
    lines: list[str]
 
 
def read_notes(fs: ProjectFs, params: ReadParams) -> Notes:
    try:
        text = fs.read_text(params.path) if fs.is_file(params.path) else ""
    except ProjectPathError as error:
        raise RpcError("invalid_params", str(error)) from error
    return Notes(lines=text.splitlines())
 
 
def activate(ctx: PluginContext) -> None:
    def read(_rpc_ctx, params: ReadParams) -> Notes:
        return read_notes(ctx.fs, params)
 
    ctx.rpc.register(READ_METHOD, read, scope="project.read", lock="none", idempotent=True)
  • activate(ctx) runs once per load. A lazy Plugin (activationEvents) activates on the first matching event. deactivate(ctx) (or deactivate()) runs on unload, after which the loader disposes everything the Plugin registered through ctx.
  • @component registers when its module is imported. A class imported while Studio loads your Plugin belongs to your Plugin and goes away when it is disabled. Any other import-time registration needs track_disposable, or it outlives the Plugin.
  • Required dependencies and required capability imports are active before activate runs. Optional ones may be missing: check, do not assume.

Lifecycle and failures#

The loader runs these steps in order when the Python half activates (plugins/loader.py _run_activation_hooks):

  1. activate(ctx), when the module defines it.
  2. contributes.tools: imports each module and calls its register(ctx).
  3. contributes.views entries with a provider (module.ClassName).
  4. health_check(ctx), when the module defines it. Returning False or {"ok": False, "message": ...} fails the activation with that message.
  5. A module-level on_startup(ctx) is queued like ctx.on_startup: it runs once the server has started, or at once when the server is already up.

When any step raises, the Plugin keeps the error (shown on the Plugins page), everything it registered so far is disposed, and its modules are removed from sys.modules. The Plugin then counts as loaded but not activated.

Other states you may see on the Plugins page: needs_trust (an installed or environment Plugin waits for the user), waiting_capabilities (a required capability has no provider yet) and a resolved_status of blocked (a missing, disabled or out-of-range dependency, or a dependency cycle).

Unload runs in this order: Plugins that require this one are unloaded first and marked blocked; then deactivate (an exception there is logged and ignored), then everything registered at activation, then everything registered at manifest load, then the Plugin's modules leave sys.modules.

To reload your code, disable and enable the Plugin on the Plugins page: enabling loads it again from disk. Only the Plugin's own package is re-imported; pip dependencies already imported stay as they are until the ProjectServer restarts. The Agent Tool plugin.reload does the same for a Plugin in the Project's plugins/ folder, after an import check in a separate process; a hosted ProjectServer refuses it.

ctx, member by member#

ctx is a PluginContext. Every registration through it is tracked and undone on unload. The generated table with every method is PluginContext (ctx).

Identity. ctx.plugin_id (the manifest id), ctx.path (the Plugin's root folder, for reading its own data files), ctx.system (true for a system Plugin).

Project files. ctx.fs is a ProjectFs: read_text, read_bytes, write_text, write_bytes, list, exists, is_file, is_dir, mkdir, rename, delete, resolve, relative, root, watch and ignore_next_change. It takes Project paths (data/a.npz); a leading / means the Project root. It raises ProjectPathError, whose code is invalid (not a string, or a NUL byte), absolute (a drive letter or UNC share), outside (.. or a symlink that leaves the Project; a/../b is fine), runtime (anything under .witwin/) or root (a write, read or delete of the Project root itself). list leaves .witwin out. watch(callback, *, predicate=None) calls callback(event) for changes on disk (event.kind is created, modified or deleted, plus rel_path and is_dir, debounced 0.15 s); it returns a Disposable that ctx does not track, so pass it to ctx.track. Call ignore_next_change(path) right before you write path yourself, so your own write does not come back as a change. ctx.fs is the only way to Project files (Files and the network).

Plugin-private data. ctx.storage is JSON state for this Plugin in this Project, kept in .witwin/plugin-state/<plugin id>/workspace.json: get, get_strict, set, delete, keys, clear, update_strict. It is the same file as ctx.workspace_state. ctx.global_state is the per-user copy in ~/.witwin/plugin-state/<plugin id>/global.json. ctx.state holds both (for_scope("global" | "workspace")).

RPC to your web half. Details in the RPC section below. ctx.rpc.register(name, fn, *, scope, lock="scene", stream=None, idempotent=False) adds a method and ctx.rpc.topic(name, mode, events) declares a topic. ctx.rpc.publish(topic, name, data) sends one event from anywhere. Flat WebSocket messages are retired (plan phase P9): add none.

Agent Tools and hooks. ctx.tools.register(tool) adds a Tool; ctx.agent adds hooks to the Agent loop and asks the model one question. Both are in the Agent section below.

Components. Define a class with @component(name=...) and the field helpers (float_field, int_field, bool_field, string_field, vector2_field, vector3_field, vector4_field, color_field, list_field, component_field, image_field, data_source_ref_field; values in SI units), plus button, figure and the group helpers define_group, tab_group, foldout_group. Import its module from your Python half so it registers during your load. ctx.components.register(MyComponent) moves ownership of a class that was imported earlier, outside your load (system Plugins use it). ctx.components.add_facet(...) adds fields to a component you do not own (next section); ctx.components.facets(name) lists the facets on one. ctx.components.catalog(include_abstract=False) lists every registered component type, whoever registered it, as ComponentTypeInfo records with their ComponentFieldInfo fields (each with the facet that added it, if any).

Parameters. ctx.parameters (ParametersApi) reads and edits the current Scene's Formula Graph: list(), get(name), and the async edits set(name, value) (a number in SI units or a formula; creates the parameter when missing), set_values({name: number}) (one edit), rename(name, new_name) (formulas and bindings follow) and delete(name, force=False). Each edit is one scene op, the same path as the Parameters panel: the op log records it, every window gets the change, and a timeline replay refuses it (RpcError with invalid_params, conflict, permission_denied or not_found). Call the edits from a lock="scene" RPC method or a Tool, which hold the scene lock.

Server lifecycle. ctx.on_startup(fn, *, late=False) runs fn (no arguments or one StartupEvent: server, tools, shared) once the server has started; a Plugin activated later runs it at once. ctx.startup_hooks lists them (the loader uses it). ctx.track(disposable) hands any other resource to the unload cleanup; ctx.subscriptions is the store it goes into; ctx.dispose() is what the loader calls.

HTTP. ctx.http.add_routes(router, *, prefix="") mounts a FastAPI APIRouter under ctx.http.base_path (/v1/plugins/<plugin id>) plus prefix, which must start with /. Use HTTP only for large bytes; everything else is RPC. The web half's resources service does not reach these routes; a third-party web half calls its Python half through RPC. Long operations become tasks (Files and the network).

Runs and solvers. ctx.runs.register_executor(executor) adds a Run executor; ctx.runs.emit(event) sends one run_* event; ctx.runs.on_event(listener) hears them; ctx.runs.label(run_id) names a Run as the Runs panel does. ctx.solvers (solve, call, query, status, cancel, read_result_ref) drives the Plugin's own out-of-process solvers declared in contributes.solvers. A short id is prefixed with the Plugin id (fdtd becomes <plugin id>.fdtd), so declare solver ids as <plugin id>.<name>. It is a kernel facade that the system Plugin studio.solvers runs: with it disabled, cancel returns False and the other calls raise RuntimeError.

Between Plugins. ctx.capabilities.provide(id, *, version="1.0.0", schema=None, factory=...) registers a provider; factory is required. ctx.capabilities.require(id, version="*", *, optional=False) returns the live object. require works only for a capability the manifest imports (imports.capabilities or a dependency's capabilities); any other id raises CapabilityAccessError (The manifest). ctx.events.publish(name, payload) and ctx.events.subscribe(name, handler) are server-side notices between Python halves. A Plugin may publish only names that start with <plugin id>.; payload must be JSON-serialisable; handlers run synchronously in the publisher's thread, with the payload as their only argument.

Scene and time. ctx.scene is the current Scene (read it; write through Tools or ops so undo, locks and the op stream see the change). ctx.scene_lifecycle.on(event, handler) hears scene_cleared, scene_loaded and scene_saving; it calls handler(scene, **payload). ctx.scene_time answers the per-Scene mode and time. ctx.viewport pushes a render spec for an object (set_render_spec). Inline GLSL in a spec needs the shader grant for an installed Plugin; without it the material falls back to its catalog shaderId or a plain material, and a warning is logged.

Streams and data sources. ctx.streams (open, publish, error, close) sends live data; ctx.data_sources (register, ref, register_timeline_dataset, ...) makes data a field can bind to.

User feedback. ctx.console (log, warn, error, create_output_channel) and ctx.output_channel (the Plugin's own channel). ctx.cancellation is the shared cancellation registry.

Older surfaces. ctx.commands (register, execute) registers server commands; prefer a web-half host.registerCommand for UI commands. ctx.views (register_provider, register_tree_view) serves tree views. ctx.walkthroughs.complete(event) and ctx.complete_walkthrough(event) finish Getting Started steps. ctx.handlers.add(HandlerClass) adds a WebSocket Handler class and is for system Plugins only; a third-party Plugin gets PermissionError and uses ctx.rpc. Raw flat-message handlers were removed in P9. ctx.api is the whole server object: it is not stable, and every ctx.api.server.<attr> reach is a gap to report, not a pattern to copy.

RPC#

Names. A third-party Plugin registers only <plugin id>.<name>. Every segment is lowercase snake_case (witwin.example_notes.read); anything else raises ValueError at registration. Methods of a third-party Plugin are not exported to the typed schema.

Handlers. fn(rpc_ctx, params) takes exactly two arguments. Annotate params with a pydantic model and the return value with a model or None; the registry reads both from the annotations. An async def handler runs on the event loop. A plain def handler runs in a worker thread: poll rpc_ctx.cancelled or call rpc_ctx.raise_if_cancelled() (or wait on rpc_ctx.cancel_token, a threading.Event) to honour a cancel. A call times out after 30 s unless the caller asks for longer (up to 24 h); long work should return an id and report on a topic.

Arguments of register.

ArgumentMeaning
scopeRequired. The permission a remote caller needs: project.read, project.write, scene.read, scene.write, and so on. A method that runs code on the host must use one of the execution scopes (run.execute, script.execute, project.command.run, plugin.write, plugin.reload, terminal.open, git.write, process.spawn, code.execute), so a restricted device cannot call it. A local desktop connection is not checked.
lockscene (default: wait for Scene writes) or none.
streamA chunk model. The handler sends chunks with await rpc_ctx.emit(chunk) before it returns.
idempotentTrue when calling twice does no harm; only those may be resent after a dropped connection.

rpc_ctx (RpcContext): client_id (the caller), emit, publish (same as ctx.rpc.publish), cancelled, raise_if_cancelled, cancel_token, and call_client(client_id, method, params) for a reverse call into a window.

Errors. Raise RpcError(code, message, *, retryable=False, data=None). code is one of invalid_params, method_not_found, not_found, conflict, stale_epoch, scene_locked, permission_denied, cancelled, timeout, disconnected, client_unavailable, agent_busy, internal. Any other exception reaches the caller as internal.

Topics. ctx.rpc.topic(name, mode, events, *, capacity=1000) declares a topic. mode is log (every event, with a sequence number; the last capacity events are kept for a client that resumes) or state (only the latest event of each name, replayed to a new subscriber). events maps event names to pydantic models. A name ending in :* covers instances: witwin.acme.job:* matches witwin.acme.job:42.

ctx.rpc.publish(topic, name, data) checks data against the event model (ValueError when it does not fit), may publish only to the Plugin's own topics (PermissionError otherwise), is thread-safe, and sends nothing before the server is bound. ctx.rpc.forget(topic, name) drops the cached latest event of a state topic. ctx.rpc.topic_head(topic) returns the last sequence number of a log topic (0 before the first event).

ctx.rpc.register_domain and ctx.rpc.include are for system Plugins (core domains such as git.*). The rpc and topic decorators in the stable API declare core methods that no Plugin owns and that unload does not remove; a third-party Plugin always uses ctx.rpc.register and ctx.rpc.topic. The types (RpcContext, the WireModel base, PluginRpc) are stable names.

The Agent: Tools, skills, profiles and hooks#

What the Agent knows about your Plugin. The [agent] table (The manifest) gives a one-line summary for every Session's plugin index and a guide file that the Tool plugin.describe returns together with the Plugin's Tools, models and skills.

Tools. Build a Tool with the tool decorator and register it with ctx.tools.register(tool, *, replace=True), or declare the module in contributes.tools. The function takes the argument dict and returns a dict (it may be async). The name becomes <plugin id>.<name>, unless it already starts with the Plugin id or with a namespaces prefix the Plugin declared. ctx.tools.unregister(name) removes one early.

Fields of tool(name, description, input_schema, output_schema=None, *, ...) (server/witwin_server/tools/base.py):

FieldMeaning
side_effectsThe Tool changes something. Without permission_tier it then counts as soft_write, else read.
permission_tierread, soft_write, scene_write, file_write, execute or destructive. Decides confirmation and which profiles may call it.
permission_scopeDefault default. Must be listed in permissions.tool_scopes when that list is not empty.
requires_confirmationAlways ask the user.
timeoutSeconds (30). Clients derive their deadline from it. An execute or destructive Tool that leaves it at 30 logs a warning.
required_capabilitiesHost capabilities it needs; each must be in requestedCapabilities.
sensitive_fieldsArgument names masked in the audit log and the confirmation summary.
tagsSelection hints, below.
idempotent, cancellable, streamingSafe to repeat; honours cancel; sends partial output.
worker_threadA CPU-heavy synchronous Tool runs in a worker thread. A running thread cannot be killed, so it finishes even after a timeout.
survives_disconnectThe operation may outlive the connection that started it.
durable_confirmationIts exact arguments may be stored for a confirmation that survives a restart. Set it only after checking the schema holds no secrets.
reads_sceneDefault True: the call waits for the scene lock. Set False only for Tools that never touch Scene state.
approval_scopefn(args) -> str: what the user's "Always allow" covers for one call ("": cannot be remembered).
concurrency_safebool or fn(args) -> bool: the call may run beside other calls of the same model step. Only for calls that change nothing.

Tier enforcement. For a third-party Plugin, registration checks the Tool against the manifest (plugins/permissions.py). A Plugin without permissions.tools cannot register any Tool. A Tool whose tier ranks above the highest tier listed there, whose scope is not in a non-empty tool_scopes, or whose required_capabilities are not in requestedCapabilities fails with PluginPermissionError. Each Tool call then passes the ProjectServer's authorization and confirmation gate (tools/invoker.py).

Tool tags. The Agent does not see every Tool on every turn; plugins_builtin/agent/tool_selection.py picks them. Tags steer the pick: pack:<id> groups Tools into a pack, priority:<n> ranks the pack (-20 to 20), intent:, word-intent:, explicit-intent:, all-of:a|b and tag: say which requests select the pack, and hint-term:, hint-explicit-intent:, hint-boost:<n>, hint-requires-pack and hint-explicit-only tune one Tool. The tag AGENT_HIDDEN_TAG (agent-hidden) keeps a Tool registered for windows and Plugins but never offers it to the Agent. Selection never authorizes a call.

Skills. contributes.skills names folders that hold SKILL.md files (default skills/); studio.agent reads them from the loaded Plugins each time it lists skills.

Agent profiles. contributes.agents declares subagents and primary agents (The manifest). ctx.agent.register_profile(id, tools) registers a read-only profile in code: an exact allowlist of Tool names.

Hooks. ctx.agent adds steering to the Agent loop. Product routing belongs in the Plugin that owns the Tools, never in core.

HookCalled asEffect
register_system_policy(id, text, when=())not calledAdds text to the system prompt of turns whose Tools include a name that starts with one of the when prefixes (always, when when is empty).
register_request_policy(id, fn)fn(user_text, history, candidates)Returns a RequestScope (exclusive, denied, schemas, retrieval_text, reason) or None. A scope only narrows the Tools.
register_follow_up(source_tools, resolver)resolver(tool_result, history)After a source Tool succeeds, returns {"name", "arguments"} of the next call, or None.
register_argument_policy(prefix, fn)fn(name, args, context)Runs before authorization for Tools whose name starts with prefix; returns the arguments to use. Raise ToolArgumentRejected(message) to refuse the call; the model sees the message.

await ctx.agent.complete(prompt, *, system=None, origin_client_id=None) asks the model one question outside any turn, with no Tools, and returns the reply text. It raises RuntimeError when no Studio window can answer or the model reports an error (studio.git writes commit messages with it).

Component facets#

A facet is a named group of fields that one Plugin adds to a component another Plugin (or the kernel) owns. The values are stored in the same component, so files do not change shape. studio.em adds the "em" fields to Material:

ctx.components.add_facet(
    "Material", "em",
    fields=em_fields(),            # ordinary fields: types, units, show_if, formulas
    groups=em_groups(),
    display_name="Electromagnetics",
    asset_paths=ASSET_PATHS,       # where each field sits in a .wtmat file
    normalize=normalize,           # rewrite old stored values on load
    before_set=before_set,         # react to an interactive edit
)
  • Stored keys are the field names. When the facet's Plugin is disabled, its keys stay in the file untouched and come back when it is enabled again; witwin.studio reads them as plain data either way.
  • The Inspector draws each facet as its own section; a web half can replace one section with a properties.inspector entry keyed Material#em.
  • Use a facet only for fields that belong to the same thing the component describes. A new concept is a new component.

Entry points#

GroupWhereWhat it registers
witwin.pluginspyproject.tomlThe Plugin itself: "<manifest id>" = "<import name>". Discovery reads it without importing code.
witwin.optimizerswitwin.studio.optimizeAn optimization method.
witwin.objectiveswitwin.studio.optimizeAn objective function.
witwin.study_kindswitwin.studio.studiesA Study kind (StudyKindDef); the entry name equals the kind.
witwin.cliwitwin.studio CLIA witwin <name> subcommand: a callable that takes the remaining arguments as a list and returns an exit code. Built-in command names win.
[project.entry-points."witwin.plugins"]
"witwin.example_notes" = "witwin_example_notes"

Planned, not built: witwin.workflow_steps (Workflow step kinds).

Solver children#

Heavy work runs in a solver child on the Project interpreter, not in the ProjectServer. Children import witwin.studio.solver (serve, query) and never witwin_server. Declare the solver in contributes.solvers with python = "${project:python}" (The manifest).

serve(solve_fn, *, threaded_solve=True, scene="live") runs the child's loop. Pass scene="document" so the child gets the scene document and needs no witwin_server; the default live still rebuilds the server's Scene inside the child, which only works when witwin_server is importable there. @query("<op>") registers a handler for queries on a result handle. See dev_docs/witwin-result-v1.md for the Result record a solve writes.

Logging#

Use get_logger("<Name>") from witwin_server.api, never print().