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.
| Layer | Import | Runs in | Holds |
|---|---|---|---|
| A. SDK | witwin.studio | anywhere: Run children, headless scripts, the witwin CLI, the ProjectServer | file formats (.wtscene, Results), the solver SDK (witwin.studio.solver), extension points that must work without Studio (entry points below) |
| B. Server runtime | witwin_server.api, witwin_server.api.testing | the ProjectServer process only | the 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 run | types, 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)(ordeactivate()) runs on unload, after which the loader disposes everything the Plugin registered throughctx.@componentregisters 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 needstrack_disposable, or it outlives the Plugin.- Required dependencies and required capability imports are active before
activateruns. 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):
activate(ctx), when the module defines it.contributes.tools: imports each module and calls itsregister(ctx).contributes.viewsentries with aprovider(module.ClassName).health_check(ctx), when the module defines it. ReturningFalseor{"ok": False, "message": ...}fails the activation with that message.- A module-level
on_startup(ctx)is queued likectx.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.
| Argument | Meaning |
|---|---|
scope | Required. 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. |
lock | scene (default: wait for Scene writes) or none. |
stream | A chunk model. The handler sends chunks with await rpc_ctx.emit(chunk) before it returns. |
idempotent | True 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):
| Field | Meaning |
|---|---|
side_effects | The Tool changes something. Without permission_tier it then counts as soft_write, else read. |
permission_tier | read, soft_write, scene_write, file_write, execute or destructive. Decides confirmation and which profiles may call it. |
permission_scope | Default default. Must be listed in permissions.tool_scopes when that list is not empty. |
requires_confirmation | Always ask the user. |
timeout | Seconds (30). Clients derive their deadline from it. An execute or destructive Tool that leaves it at 30 logs a warning. |
required_capabilities | Host capabilities it needs; each must be in requestedCapabilities. |
sensitive_fields | Argument names masked in the audit log and the confirmation summary. |
tags | Selection hints, below. |
idempotent, cancellable, streaming | Safe to repeat; honours cancel; sends partial output. |
worker_thread | A CPU-heavy synchronous Tool runs in a worker thread. A running thread cannot be killed, so it finishes even after a timeout. |
survives_disconnect | The operation may outlive the connection that started it. |
durable_confirmation | Its exact arguments may be stored for a confirmation that survives a restart. Set it only after checking the schema holds no secrets. |
reads_scene | Default True: the call waits for the scene lock. Set False only for Tools that never touch Scene state. |
approval_scope | fn(args) -> str: what the user's "Always allow" covers for one call ("": cannot be remembered). |
concurrency_safe | bool 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.
| Hook | Called as | Effect |
|---|---|---|
register_system_policy(id, text, when=()) | not called | Adds 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.studioreads 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.inspectorentry keyedMaterial#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#
| Group | Where | What it registers |
|---|---|---|
witwin.plugins | pyproject.toml | The Plugin itself: "<manifest id>" = "<import name>". Discovery reads it without importing code. |
witwin.optimizers | witwin.studio.optimize | An optimization method. |
witwin.objectives | witwin.studio.optimize | An objective function. |
witwin.study_kinds | witwin.studio.studies | A Study kind (StudyKindDef); the entry name equals the kind. |
witwin.cli | witwin.studio CLI | A 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().