Migrating Plugins from Python to WASM
This guide helps you migrate existing StudioBrain Python plugins to the new WASM plugin system. WASM plugins run on all platforms (web, desktop, mobile) with better performance and stronger sandboxing.
Why Migrate?
| Aspect | Python plugins (retired) | WASM Plugins |
|---|---|---|
| Platforms | Not loaded. No current host starts a Python plugin | Web, desktop, mobile (universal) |
| Sandboxing | Process-level (filesystem access possible) | Memory-safe sandbox (no host access unless granted) |
| Performance | Interpreted, GIL-limited | Near-native, parallel execution |
| Distribution | Requires Python + pip on target | Single .wasm binary, no runtime deps |
| Cold start | 100-500ms (module import) | 5-20ms (WASM instantiation) |
When to migrate:
- Your plugin should work on desktop or mobile
- Performance matters (search, analysis, bulk operations)
- You want a single build for all platforms
Do not keep a native Python plugin. Prototyping, a Python-only library, and a server-only plugin are not exceptions. Core sb-server has no Python plugin host. The desktop host matches PluginRuntime::Python, logs Python runtime not supported in Rust host yet, and stores the plugin with no engine in the error state (clients/desktop/rust-backend/sb-client-plugins/src/registry.rs). Rebuild the guest as a WIT Component. Start at Getting Started.
Step-by-Step Migration
1. Map Python Hooks to WASM Exports
Python plugins use Python functions registered with the event bus. WASM plugins use exported functions.
| Python Pattern | WASM Equivalent |
|---|---|
@event_bus.on("entity_created") | hooks export on-entity-create |
@event_bus.on("entity_updated") | hooks export on-entity-save (create or update) |
@event_bus.on("entity_deleted") | hooks export on-entity-delete |
router = APIRouter(prefix=...) | routes export list-routes |
@router.get("/path") | routes export handle-request |
The world is studiobrain:plugin@0.1.0 (packages/plugin-sdk/wit/world.wit in studiobrain-core). A guest imports host-entities, host-storage, host-settings, host-log, host-http, and host-ai, and exports metadata (required), hooks, and routes. There is no #[export] fn on_entity_created and no host:: module. The guest that the core host loads is a cargo-component crate, the same shape as crates/sb-plugin-wasmtime/tests/fixtures/hello-world-plugin.
2. Migrate Event Handlers
Before (Python):
# events.py
from services.event_bus import EventBus, EntityEvent
def register(event_bus: EventBus):
@event_bus.on("entity_created")
async def on_entity_created(event: EntityEvent):
entity_type = event.entity_type
entity_id = event.entity_id
data = event.data
# Look up related entities
from database import get_db
db = next(get_db())
characters = db.query(Entity).filter(
Entity.entity_type == "character"
).all()
# Store analysis
from services.plugin_data import PluginDataService
pds = PluginDataService(db, "my-plugin")
pds.save("analysis", entity_id, {
"related_count": len(characters)
})After (Rust Component):
// src/lib.rs — cargo-component guest. `mod bindings` is generated.
use bindings::exports::studiobrain::plugin::hooks::{Guest as HooksGuest, HookEvent, HookResult};
use bindings::studiobrain::plugin::host_entities;
use bindings::studiobrain::plugin::host_storage;
use bindings::studiobrain::plugin::types::EntityQueryFilter;
struct MyPlugin;
// The hooks Guest trait also requires on_entity_save, on_entity_delete,
// on_entity_validate, and on_project_init. See the hello-world fixture.
impl HooksGuest for MyPlugin {
fn on_entity_create(event: HookEvent) -> HookResult {
let page = host_entities::query_entities(&EntityQueryFilter {
entity_type: Some("character"),
filter_json: None,
limit: None,
offset: None,
order_by: None,
order_desc: None,
});
let count = page.map(|page| page.records.len()).unwrap_or(0);
let body = format!(r#"{{"related_count":{count}}}"#);
// host-storage write-file. Not a key/value plugin-data API.
let _stored = host_storage::write_file(
&format!("analysis/{}.json", event.entity_id),
body.as_bytes(),
);
HookResult {
data: None,
messages: vec![],
abort: false,
abort_reason: None,
}
}
}query-entities is host-entities. write-file is host-storage. plugin.wit does not define host::entity_list, host::entity_read, host::storage_set, or host::storage_get.
Key differences:
- No direct database access. Listing is
host-entitiesquery-entities. One record ishost-entitiesread-entity(entity-type, entity-id). - Bytes go through
host-storagewrite-file/read-file. The core host checksread_entitiesfor read and query,write_entitiesfor write and delete,read_datafor read-file and list-files, andwrite_datafor write-file and delete-file (crates/sb-plugin-wasmtime/src/host_state.rs). A missing flag returnspermission-denied. - Imports are synchronous from the guest. The host runs the async work.
hooksreturnshook-result, notResult<(), PluginError>. Match theresultfrom each import yourself.
3. Migrate Routes
Before (Python):
# routes.py
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from database import get_db
router = APIRouter(prefix="/api/plugins/my-plugin")
@router.get("/stats/{entity_type}")
async def get_stats(entity_type: str, db: Session = Depends(get_db)):
count = db.query(Entity).filter(
Entity.entity_type == entity_type
).count()
return {"entity_type": entity_type, "count": count}
@router.post("/analyze")
async def run_analysis(request: AnalyzeRequest, db: Session = Depends(get_db)):
entities = db.query(Entity).filter(
Entity.entity_type == request.entity_type
).all()
# ... process entities
return {"analyzed": len(entities)}After (Rust Component):
use bindings::exports::studiobrain::plugin::routes::{
Guest as RoutesGuest, HttpRequest, HttpResponse, RouteDescriptor,
};
use bindings::studiobrain::plugin::host_entities;
use bindings::studiobrain::plugin::types::{EntityQueryFilter, KvPair};
struct MyPlugin;
impl RoutesGuest for MyPlugin {
fn list_routes() -> Vec<RouteDescriptor> {
vec![RouteDescriptor {
method: "GET".into(),
path: "/stats".into(),
description: "Count entities of one type".into(),
}]
}
fn handle_request(request: HttpRequest) -> HttpResponse {
let entity_type = request
.query_params
.iter()
.find(|kv| kv.key == "entity_type")
.map(|kv| kv.value.as_str())
.unwrap_or("character");
let count = host_entities::query_entities(&EntityQueryFilter {
entity_type: Some(entity_type),
filter_json: None,
limit: None,
offset: None,
order_by: None,
order_desc: None,
})
.map(|page| page.records.len())
.unwrap_or(0);
HttpResponse {
status: 200,
headers: vec![KvPair {
key: "content-type".into(),
value: "application/json".into(),
}],
// Serialize, never format: `entity_type` comes from the query string.
body: Some(
serde_json::to_vec(&serde_json::json!({
"entity_type": entity_type,
"count": count,
}))
.unwrap_or_default(),
),
}
}
}Build response JSON with a serializer (serde_json = "1" in the component’s [dependencies]). Never interpolate request values into a JSON string: a " in entity_type would break the body or inject fields.
There is one handle-request export. The guest matches request.path. There is no per-route handler string and no request.param.
The core host mounts routes under /api/ext/{plugin-id}/ only when permissions includes serve_routes (crates/sb-server/src/services/plugin_ext_routes.rs). The prefix is not /api/plugins/{plugin-id}/.
4. Migrate Settings
Before (Python):
# In routes.py or events.py
from services.plugin_settings import plugin_settings_service
def get_depth():
settings = plugin_settings_service.get_global("my-plugin")
return settings.get("analysis_depth", 3)After (Rust Component):
use bindings::studiobrain::plugin::host_settings;
fn get_depth() -> u32 {
// get-setting returns result<option<string>, host-error>.
// The core host checks read_settings before it reads the backend.
host_settings::get_setting("analysis_depth")
.ok()
.flatten()
.and_then(|value| value.parse().ok())
.unwrap_or(3)
}host-settings get-setting is the import. There is no host::setting_get. Put read_settings in permissions. host-log log needs no permission. An existing settings_schema object can stay; it is not a capabilities.host_functions list.
5. Update plugin.json
Replace the Python entry points with capabilities.backend.wasm_entry and a permissions array. The installer allowlist is ALLOWED_PLUGIN_PERMISSIONS in crates/sb-server/src/routes/plugins.rs. sandbox_from_manifest copies those strings onto the flags the host checks. Do not invent other names for the same calls.
Before:
{
"id": "my-plugin",
"capabilities": {
"backend": {
"routes": "routes.py",
"event_handlers": "events.py"
}
}
}After:
{
"id": "my-plugin",
"type": "full",
"capabilities": {
"backend": {
"wasm_entry": "plugin.wasm",
"hooks": [
"on-entity-create",
"on-entity-save",
"on-entity-delete",
"on-entity-validate",
"on-project-init"
],
"routes": true
}
},
"permissions": [
"read_entities",
"write_data",
"read_settings",
"serve_routes"
]
}permissions for this example are only the flags the snippets above exercise, plus serve_routes for the route export. There is no capabilities.host_functions array. entity_read, entity_list, storage_get, storage_set, setting_get, and log are not installer permissions. entity:read and filesystem:read are not either. The core installer rejects unknown strings.
read_entities covers read-entity and query-entities. write_data covers write-file. read_settings covers get-setting. log is not in that list because the host does not check a flag for it.
6. Update Directory Structure
Before:
my-plugin/
plugin.json
backend/
routes.py
events.py
frontend/
panels/
main-panel.htmlAfter:
my-plugin/
plugin.json
plugin.wasm # path named by capabilities.backend.wasm_entry
wit/ # copy of packages/plugin-sdk/wit (world: plugin)
Cargo.toml # cargo-component, wit-bindgen-rt
src/
lib.rs
panels/
main-panel.html # Frontend panels (unchanged)Build with cargo component build --release and copy the Component to plugin.wasm. The target triple in the official guest fixture is wasm32-wasip1. Do not point wasm_entry at target/wasm32-wasi/. The installer checks that the declared .wasm file exists in the plugin directory.
7. Test Across Platforms
After migration, test your plugin on all target platforms. Desktop and mobile are Tauri 2 apps under clients/desktop and clients/mobile. Capacitor is not used. Do not run npx cap, and do not cd packages/desktop — that tree was removed.
# Web (development server)
npm run dev
# Install plugin via Settings > Plugins > Install Local
# Desktop (Tauri 2). `npm run tauri` runs `cargo tauri dev`.
cd clients/desktop
npm run tauri
# Plugin host today: sb-client-plugins (extism). Target: WIT Component in wasmtime Cranelift (SBAI-7665).
# Mobile (Tauri Mobile, not Capacitor).
# `dev:ios` is `tauri ios dev`. `dev:android` is `tauri android dev`.
cd clients/mobile
npm run dev:ios
npm run dev:android
# Plugin host today: sb-client-plugins (extism). Target: same Component in wasmtime Pulley (SBAI-7745).Frontend Panels: No Changes Needed
Frontend panels (HTML/CSS/JS in iframes) work identically in both Python and WASM plugins. The postMessage protocol is the same. No migration needed for panel code.
Common Pitfalls
1. Async to Sync
Python plugins use async/await. WASM host functions are synchronous from the plugin’s perspective (the host handles async internally).
# Python (async)
async def on_entity_created(event):
entities = await db.query(Entity).all()// Guest import. Synchronous from the plugin's side.
let page = host_entities::query_entities(&EntityQueryFilter {
entity_type: Some("character"),
filter_json: None,
limit: None,
offset: None,
order_by: None,
order_desc: None,
});2. No Direct Filesystem Access
Python plugins could read and write files via os / pathlib. A Component calls host-storage: read-file, write-file, list-files, and delete-file.
3. No Direct Database Access
Python plugins could import get_db() and run SQL. A Component calls host-entities: read-entity, write-entity, query-entities, and delete-entity.
4. Binary Size
Rust WASM modules are typically 1-5 MB. If your plugin depends on large crates, consider:
- Using
wasm-optto optimize the binary - Splitting into multiple smaller modules
- Moving heavy computation to host functions
Field Widgets
The plugin system now supports field widgets — custom form inputs that render inline in entity editors.
What’s New
- Plugins can declare
field_widgetsin their manifest (alongsidepanels) - Widget ID format:
plugin:{pluginId}:{widgetId} - Widgets render in sandboxed iframes on web, WASM on desktop
- Available in the Layout Designer widget type dropdown
- Can be referenced in template
field_config
Migration Steps
If you previously built custom field rendering as a panel, you can now register it as a proper field widget:
- Add
field_widgetsto yourplugin.jsonmanifest - Create a standalone HTML file for the widget
- Implement the
sb-field-widget-*postMessage protocol - Remove the panel-based workaround
See Building a Custom Field Widget for the complete tutorial.
See Also
- Getting Started — WASM plugin system overview
- Plugin Examples — Complete plugin walkthroughs