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?

AspectPython PluginsWASM Plugins
PlatformsWeb + cloud only (requires Python runtime)Web, desktop, mobile (universal)
SandboxingProcess-level (filesystem access possible)Memory-safe sandbox (no host access unless granted)
PerformanceInterpreted, GIL-limitedNear-native, parallel execution
DistributionRequires Python + pip on targetSingle .wasm binary, no runtime deps
Cold start100-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 PatternWASM 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() and host::entity_read() instead
  • No PluginDataService import — use host::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.html

After:

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 ios

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()
// 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-opt to 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_widgets in their manifest (alongside panels)
  • 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:

  1. Add field_widgets to your plugin.json manifest
  2. Create a standalone HTML file for the widget
  3. Implement the sb-field-widget-* postMessage protocol
  4. Remove the panel-based workaround

See Building a Custom Field Widget for the complete tutorial.

See Also