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 plugins (retired)WASM Plugins
PlatformsNot loaded. No current host starts a Python pluginWeb, 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

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 PatternWASM 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-entities query-entities. One record is host-entities read-entity(entity-type, entity-id).
  • Bytes go through host-storage write-file / read-file. The core host checks read_entities for read and query, write_entities for write and delete, read_data for read-file and list-files, and write_data for write-file and delete-file (crates/sb-plugin-wasmtime/src/host_state.rs). A missing flag returns permission-denied.
  • Imports are synchronous from the guest. The host runs the async work.
  • hooks returns hook-result, not Result<(), PluginError>. Match the result from 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.html

After:

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-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