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/characterPOST /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
- Getting Started — Plugin system overview and quick start
- Migrating from Python to WASM — Migration guide
- Plugin Iframe Protocol — Full protocol reference