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 | WASM Plugins |
|---|---|---|
| Platforms | Web + cloud only (requires Python runtime) | 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
When to keep Python:
- Prototyping (faster iteration)
- Heavy use of Python-only libraries with no WASM equivalent
- Server-only plugin that will never run on desktop/mobile
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") | #[export] fn on_entity_created(entity: Entity) |
@event_bus.on("entity_updated") | #[export] fn on_entity_updated(entity: Entity) |
@event_bus.on("entity_deleted") | #[export] fn on_entity_deleted(entity_type: str, entity_id: str) |
router = APIRouter(prefix=...) | #[export] fn register_routes() -> Vec<Route> |
@router.get("/path") | Route handler via register_routes() |
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 WASM):
// src/lib.rs
use studiobrain_plugin_sdk::*;
#[export]
fn on_entity_created(entity: Entity) -> Result<(), PluginError> {
let entity_id = entity.id();
// Host function replaces direct DB access
let characters = host::entity_list("entity_type=character")?;
// Host function replaces PluginDataService
host::storage_set(
&format!("analysis/{}", entity_id),
&serde_json::json!({
"related_count": characters.len()
}).to_string()
)?;
Ok(())
}Key differences:
- No direct database access — use
host::entity_list()andhost::entity_read()instead - No
PluginDataServiceimport — usehost::storage_set()/host::storage_get() - Synchronous API (WASM host functions are blocking from the plugin’s perspective)
- Explicit error handling via
Result
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 WASM):
// src/lib.rs
use studiobrain_plugin_sdk::*;
#[export]
fn register_routes() -> Vec<Route> {
vec![
Route {
method: "GET",
path: "/stats/{entity_type}",
handler: "handle_stats",
},
Route {
method: "POST",
path: "/analyze",
handler: "handle_analyze",
},
]
}
#[export]
fn handle_stats(request: Request) -> Response {
let entity_type = request.param("entity_type");
let entities = host::entity_list(
&format!("entity_type={}", entity_type)
).unwrap_or_default();
Response::json(200, &serde_json::json!({
"entity_type": entity_type,
"count": entities.len()
}))
}
#[export]
fn handle_analyze(request: Request) -> Response {
let body: serde_json::Value = request.json().unwrap();
let entity_type = body["entity_type"].as_str().unwrap_or("character");
let entities = host::entity_list(
&format!("entity_type={}", entity_type)
).unwrap_or_default();
for entity in &entities {
let _ = on_entity_created(entity.clone());
}
Response::json(200, &serde_json::json!({
"analyzed": entities.len()
}))
}Routes are automatically mounted at /api/plugins/{plugin-id}/ — the prefix is implicit.
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 WASM):
fn get_depth() -> usize {
host::setting_get("analysis_depth")
.unwrap_or("3".to_string())
.parse()
.unwrap_or(3)
}The settings schema in plugin.json stays the same — no changes needed to the capabilities.settings section.
5. Update plugin.json
Add the wasm section and update capabilities.backend:
Before:
{
"id": "my-plugin",
"capabilities": {
"backend": {
"routes": "routes.py",
"event_handlers": "events.py"
}
}
}After:
{
"id": "my-plugin",
"wasm": {
"module": "target/wasm32-wasi/release/my_plugin.wasm"
},
"capabilities": {
"host_functions": [
"entity_read",
"entity_list",
"storage_get",
"storage_set",
"setting_get",
"log"
]
}
}The backend.routes and backend.event_handlers fields are replaced by the WASM module, which exports both route handlers and event handlers.
6. Update Directory Structure
Before:
my-plugin/
plugin.json
backend/
routes.py
events.py
frontend/
panels/
main-panel.htmlAfter:
my-plugin/
plugin.json
Cargo.toml # (or package.json / pyproject.toml)
src/
lib.rs # WASM module source
target/
wasm32-wasi/release/
my_plugin.wasm # Built WASM module
panels/
main-panel.html # Frontend panels (unchanged)7. Test Across Platforms
After migration, test your plugin on all target platforms:
# Web (development server)
npm run dev
# Install plugin via Settings > Plugins > Install Local
# Desktop (Tauri dev mode)
cd packages/desktop
cargo tauri dev
# Plugin loads via wasmtime in the Tauri backend
# Mobile (Capacitor)
# Plugin WASM runs in the mobile app's embedded runtime
npx cap run iosFrontend 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()// WASM (sync from plugin's view)
fn on_entity_created(entity: Entity) -> Result<(), PluginError> {
let entities = host::entity_list("entity_type=character")?;
Ok(())
}2. No Direct Filesystem Access
Python plugins could read/write files via os / pathlib. WASM plugins use host::storage_* functions instead.
3. No Direct Database Access
Python plugins could import get_db() and run raw SQL. WASM plugins use host::entity_* functions which go through the standard API layer.
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
- Plugin Development — Legacy Python plugin reference (still supported)