Plugin Examples

Practical walkthroughs of complete StudioBrain plugins, from hello-world to production patterns.

Hello World

The simplest possible plugin: a sidebar panel that displays the current entity’s name and type.

plugin.json

{
  "id": "hello-world",
  "name": "Hello World",
  "version": "1.0.0",
  "description": "Displays the current entity name",
  "author": "StudioBrain Team",
  "license": "MIT",
  "icon": "Hand",
  "capabilities": {
    "frontend": {
      "panels": [
        {
          "id": "greeting",
          "title": "Hello",
          "location": "entity-sidebar",
          "url": "/panels/greeting.html"
        }
      ]
    }
  }
}

panels/greeting.html

<!DOCTYPE html>
<html>
<head>
  <style>
    :root {
      --surface-base-bg: #ffffff;
      --surface-base-text: #1a1a2e;
    }
    .dark {
      --surface-base-bg: #0f0f1a;
      --surface-base-text: #e5e5e5;
    }
    body {
      font-family: system-ui, sans-serif;
      background: var(--surface-base-bg);
      color: var(--surface-base-text);
      padding: 16px;
      margin: 0;
    }
    .greeting {
      font-size: 1.25rem;
      font-weight: 600;
    }
    .meta {
      font-size: 0.875rem;
      opacity: 0.7;
      margin-top: 4px;
    }
  </style>
</head>
<body>
  <div class="greeting" id="greeting">Loading...</div>
  <div class="meta" id="meta"></div>
 
  <script>
    window.addEventListener('message', (event) => {
      if (event.data?.type === 'entity-context') {
        const { entityType, entityId, data } = event.data;
        document.getElementById('greeting').textContent =
          `Hello, ${data.name || entityId}!`;
        document.getElementById('meta').textContent =
          `Type: ${entityType}`;
 
        // Apply theme
        if (event.data.theme === 'dark') {
          document.documentElement.classList.add('dark');
        }
      }
 
      if (event.data?.type === 'theme-change') {
        document.documentElement.classList.toggle(
          'dark', event.data.theme === 'dark'
        );
      }
    });
 
    // Auto-resize to fit content
    const observer = new ResizeObserver(() => {
      window.parent.postMessage({
        type: 'resize',
        height: document.body.scrollHeight
      }, '*');
    });
    observer.observe(document.body);
  </script>
</body>
</html>

Installation

Copy the hello-world/ directory to _Plugins/hello-world/ in your project. The plugin will appear in the entity editor sidebar for all entity types.

Calling Host Functions

This example shows a WASM plugin (Rust) that reads related entities and stores analysis results.

src/lib.rs

use studiobrain_plugin_sdk::*;
 
/// Called when any entity is created or updated.
/// Finds all related characters and stores a summary.
#[export]
fn on_entity_created(entity: Entity) -> Result<(), PluginError> {
    let entity_id = entity.id();
    let entity_type = entity.entity_type();
 
    // Read related characters using host function
    let characters = host::entity_list(
        "entity_type=character"
    )?;
 
    // Find relationships to this entity
    let related: Vec<_> = characters.iter()
        .filter(|c| {
            c.field("primary_location")
                .map_or(false, |loc| loc == entity_id)
        })
        .map(|c| c.field("name").unwrap_or_default())
        .collect();
 
    // Store results in plugin-scoped storage
    let summary = serde_json::json!({
        "entity": entity_id,
        "related_characters": related,
        "count": related.len(),
        "analyzed_at": chrono::Utc::now().to_rfc3339()
    });
 
    host::storage_set(
        &format!("analysis/{}/{}", entity_type, entity_id),
        &summary.to_string()
    )?;
 
    host::log("info", &format!(
        "Analyzed {}/{}: {} related characters",
        entity_type, entity_id, related.len()
    ));
 
    Ok(())
}

plugin.json

{
  "id": "entity-analyzer",
  "name": "Entity Analyzer",
  "version": "1.0.0",
  "description": "Analyzes entity relationships automatically",
  "wasm": {
    "module": "target/wasm32-wasi/release/entity_analyzer.wasm"
  },
  "capabilities": {
    "host_functions": [
      "entity_list",
      "storage_get",
      "storage_set",
      "log"
    ]
  }
}

Adding Custom Routes

Plugins can register REST API endpoints. These are defined in the WASM module and mounted under /api/plugins/{plugin-id}/.

src/lib.rs

use studiobrain_plugin_sdk::*;
 
/// Register custom routes
#[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");
 
    // List all stored analyses for this type
    let keys = host::storage_list(
        &format!("analysis/{}/", entity_type)
    ).unwrap_or_default();
 
    Response::json(200, &serde_json::json!({
        "entity_type": entity_type,
        "analyzed_count": keys.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()
    }))
}

These routes are accessible at:

  • GET /api/plugins/entity-analyzer/stats/character
  • POST /api/plugins/entity-analyzer/analyze

Using Settings

Plugins can define settings that users configure through the StudioBrain Settings page.

plugin.json (settings section)

{
  "capabilities": {
    "settings": {
      "global": [
        {
          "key": "analysis_depth",
          "label": "Analysis Depth",
          "type": "number",
          "default": 3,
          "description": "How many relationship levels to traverse"
        },
        {
          "key": "auto_analyze",
          "label": "Auto-Analyze on Save",
          "type": "boolean",
          "default": true
        }
      ],
      "user": [
        {
          "key": "show_notifications",
          "label": "Show Analysis Notifications",
          "type": "boolean",
          "default": true
        }
      ]
    }
  }
}

Reading Settings in WASM

#[export]
fn on_entity_created(entity: Entity) -> Result<(), PluginError> {
    // Check if auto-analyze is enabled
    let auto_analyze = host::setting_get("auto_analyze")
        .unwrap_or("true".to_string());
 
    if auto_analyze != "true" {
        return Ok(());
    }
 
    let depth: usize = host::setting_get("analysis_depth")
        .unwrap_or("3".to_string())
        .parse()
        .unwrap_or(3);
 
    // Use depth in analysis...
    analyze_entity(entity, depth)
}

Reading Settings in Frontend Panels

async function loadSettings() {
  const PLUGIN_ID = 'entity-analyzer';
  const [globalRes, userRes] = await Promise.all([
    fetch(`/api/plugins/${PLUGIN_ID}/settings`),
    fetch(`/api/plugins/${PLUGIN_ID}/settings/user`)
  ]);
 
  const globalSettings = await globalRes.json();
  const userSettings = await userRes.json();
  return { ...globalSettings, ...userSettings };
}
 
// Use settings to control UI behavior
const settings = await loadSettings();
if (settings.show_notifications) {
  showNotificationBadge();
}

Frontend Panel with Theme Sync

A complete panel that syncs with the host theme and displays plugin data.

panels/analysis-panel.html

<!DOCTYPE html>
<html>
<head>
  <style>
    :root {
      --surface-base-bg: #ffffff;
      --surface-base-text: #1a1a2e;
      --surface-elevated-bg: #f9fafb;
      --surface-elevated-border: #e5e7eb;
      --surface-primary-bg: #3b82f6;
      --surface-primary-text: #ffffff;
    }
    .dark {
      --surface-base-bg: #0f0f1a;
      --surface-base-text: #e5e5e5;
      --surface-elevated-bg: #1a1a2e;
      --surface-elevated-border: #374151;
      --surface-primary-bg: #3b82f6;
      --surface-primary-text: #ffffff;
    }
    body {
      font-family: system-ui, sans-serif;
      background: var(--surface-base-bg);
      color: var(--surface-base-text);
      padding: 16px;
      margin: 0;
    }
    .card {
      background: var(--surface-elevated-bg);
      border: 1px solid var(--surface-elevated-border);
      border-radius: 8px;
      padding: 12px;
      margin-bottom: 8px;
    }
    .card-title {
      font-weight: 600;
      margin-bottom: 4px;
    }
    .card-value {
      font-size: 1.5rem;
      font-weight: 700;
    }
    .btn {
      background: var(--surface-primary-bg);
      color: var(--surface-primary-text);
      border: none;
      border-radius: 6px;
      padding: 8px 16px;
      cursor: pointer;
      font-size: 0.875rem;
    }
    .btn:hover { opacity: 0.9; }
  </style>
</head>
<body>
  <div class="card">
    <div class="card-title">Related Characters</div>
    <div class="card-value" id="count">--</div>
  </div>
  <div class="card">
    <div class="card-title">Last Analyzed</div>
    <div class="card-value" id="timestamp">Never</div>
  </div>
  <button class="btn" id="refresh">Refresh Analysis</button>
 
  <script>
    const PLUGIN_ID = 'entity-analyzer';
    let currentEntity = null;
 
    window.addEventListener('message', (event) => {
      if (event.data?.type === 'entity-context') {
        currentEntity = event.data;
        applyTheme(event.data.theme);
        loadAnalysis(event.data.entityType, event.data.entityId);
      }
      if (event.data?.type === 'theme-change') {
        applyTheme(event.data.theme);
      }
      if (event.data?.type === 'entity-updated' && currentEntity) {
        loadAnalysis(currentEntity.entityType, currentEntity.entityId);
      }
    });
 
    function applyTheme(theme) {
      document.documentElement.classList.toggle('dark', theme === 'dark');
    }
 
    async function loadAnalysis(entityType, entityId) {
      try {
        const res = await fetch(
          `/api/plugins/${PLUGIN_ID}/data/analysis/${entityType}/${entityId}`
        );
        if (res.ok) {
          const data = await res.json();
          document.getElementById('count').textContent =
            data.count ?? '--';
          document.getElementById('timestamp').textContent =
            data.analyzed_at
              ? new Date(data.analyzed_at).toLocaleDateString()
              : 'Never';
        }
      } catch (e) {
        console.error('Failed to load analysis:', e);
      }
    }
 
    document.getElementById('refresh').addEventListener('click', async () => {
      if (!currentEntity) return;
      await fetch(`/api/plugins/${PLUGIN_ID}/analyze`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          entity_type: currentEntity.entityType
        })
      });
      loadAnalysis(currentEntity.entityType, currentEntity.entityId);
      window.parent.postMessage({
        type: 'toast',
        message: 'Analysis refreshed',
        toastType: 'success'
      }, '*');
    });
 
    // Auto-resize
    const observer = new ResizeObserver(() => {
      window.parent.postMessage({
        type: 'resize',
        height: document.body.scrollHeight
      }, '*');
    });
    observer.observe(document.body);
  </script>
</body>
</html>

See Also