Plugin Development
StudioBrain supports a plugin system that lets developers extend the application with custom UI panels, entity processing logic, and backend routes. Plugins run inside sandboxed iframes and communicate with the host via a typed postMessage protocol.
Plugin Manifest
Every plugin requires a plugin.json manifest file at the root of the plugin directory.
{
"id": "my-analytics-plugin",
"name": "Entity Analytics",
"version": "1.0.0",
"description": "Visualize entity relationship networks and statistics",
"author": "Your Name",
"license": "MIT",
"icon": "BarChart3",
"homepage": "https://github.com/you/entity-analytics",
"capabilities": {
"frontend": {
"panels": [
{
"id": "network-graph",
"title": "Relationship Network",
"location": "entity-sidebar",
"entity_types": ["character", "faction", "location"],
"url": "/panels/network.html",
"icon": "Network"
},
{
"id": "stats-tab",
"title": "Statistics",
"location": "entity-tab",
"url": "/panels/stats.html"
}
]
},
"backend": {
"routes": "routes.py",
"event_handlers": "events.py"
},
"settings": {
"global": [
{
"key": "default_graph_depth",
"label": "Default Graph Depth",
"type": "number",
"default": 3
},
{
"key": "color_scheme",
"label": "Color Scheme",
"type": "select",
"options": ["default", "monochrome", "faction-based"],
"default": "default"
}
],
"user": [
{
"key": "auto_expand",
"label": "Auto-expand graph on load",
"type": "boolean",
"default": true
}
]
}
}
}Manifest Fields
| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Unique plugin identifier (lowercase, hyphens allowed) |
name | string | Yes | Display name |
version | string | Yes | Semver version |
description | string | Yes | Brief description |
author | string | No | Author name or organization |
license | string | No | SPDX license identifier |
icon | string | No | Lucide icon name for plugin list |
homepage | string | No | Plugin homepage URL |
capabilities.frontend.panels | array | No | UI panels to register |
capabilities.backend.routes | string | No | Python file with FastAPI routes |
capabilities.backend.event_handlers | string | No | Python file with event handlers |
capabilities.settings.global | array | No | Admin-configurable settings |
capabilities.settings.user | array | No | Per-user settings |
Panel Locations
| Location | Renders As | Description |
|---|---|---|
entity-sidebar | Collapsible section | Shown alongside the Visual Editor |
entity-tab | Full tab | Auto-injected into the entity edit tabs |
entity-footer | Below editor | Reserved for future use |
Component Block Integration
Plugin panels can also be placed as component blocks in the Layout Designer. When an admin drags a plugin panel into a layout, the block ID uses the format:
plugin:{pluginId}:{panelId}For example: plugin:my-analytics-plugin:network-graph
These blocks are rendered by ComponentBlockRenderer, which delegates to PluginBlockIframe for any component ID matching the plugin:*:* pattern.
Iframe Sandbox
Plugin panels render inside sandboxed iframes with the following attributes:
<iframe
sandbox="allow-scripts allow-same-origin allow-forms"
src="/api/plugins/panel/{pluginId}/{panelId}"
/>Permissions granted:
allow-scripts: JavaScript executionallow-same-origin: Access to same-origin storage (localStorage, cookies)allow-forms: Form submission
Blocked by default:
- Top-level navigation (plugins cannot redirect the host)
- Popups and new windows
- Access to parent DOM
Content Security Policy
Plugin content is served through the backend (/api/plugins/panel/{id}/{panel_id}), which injects appropriate CSP headers. Plugins should not attempt to load external scripts unless the CSP is configured to allow it.
PostMessage Protocol
All communication between the host and plugin iframes uses the browser postMessage API with typed messages. Types are defined in src/lib/plugin-message-protocol.ts.
Host to Plugin Messages
These messages are sent by StudioBrain to the plugin iframe.
entity-context
Sent when the iframe first loads. Provides the entity data and environment.
interface EntityContextMessage {
type: 'entity-context';
entityType: string; // e.g., "character"
entityId: string; // e.g., "rex_marshall"
data: Record<string, any>; // Full entity fields + markdown_body
}theme-change
Sent when the user toggles between light and dark mode.
interface ThemeChangeMessage {
type: 'theme-change';
theme: 'light' | 'dark';
}entity-updated
Sent when entity data changes (after save or field update by another component).
interface EntityUpdatedMessage {
type: 'entity-updated';
data: Record<string, any>;
}Plugin to Host Messages
These messages are sent by the plugin to StudioBrain.
request-entity-data
Request the current entity data. The host responds with an entity-context message.
interface RequestEntityDataMessage {
type: 'request-entity-data';
}entity-modified
Request field updates. The host merges these changes into the entity.
interface EntityModifiedMessage {
type: 'entity-modified';
changes: Record<string, any>; // e.g., { description: "Updated text" }
}navigate
Navigate the host app to a different route.
interface NavigateMessage {
type: 'navigate';
path: string; // e.g., "/characters/rex_marshall"
}toast
Display a toast notification in the host UI.
interface ToastMessage {
type: 'toast';
level: 'success' | 'error' | 'info';
message: string;
}resize
Resize the iframe container to fit content.
interface ResizeMessage {
type: 'resize';
height: number; // pixels
}Type Guards
Both sides should validate incoming messages using the provided type guards:
import { isPluginMessage, isHostMessage } from '@/lib/plugin-message-protocol';
// In host code:
window.addEventListener('message', (event) => {
if (isPluginMessage(event.data)) {
// Safe to handle as PluginToHostMessage
}
});
// In plugin code:
window.addEventListener('message', (event) => {
if (isHostMessage(event.data)) {
// Safe to handle as HostToPluginMessage
}
});Origin Validation
- The host validates
event.originbefore processing incoming plugin messages. - Plugins should validate that messages come from the expected host origin (provided in the initial
entity-contextmessage if needed, or by checking againstwindow.location.origin).
Plugin Theme Compliance
This section is mandatory reading for all plugin developers. Plugins that use hardcoded colors will break when users customize their themes.
StudioBrain uses a semantic surface system with 12 customizable surfaces. Users can change every color in the system through Settings. If your plugin uses hardcoded hex values or Tailwind color utilities like bg-blue-500, it will look wrong (or unreadable) for any user who has customized their theme.
Receiving Theme Context
Plugins receive theme information through two messages:
- On load: The
entity-contextmessage provides the initial theme state. - On change: The
theme-changemessage fires when the user toggles light/dark mode.
Applying the Host Theme in Your Plugin
The recommended approach is to read the host’s CSS custom properties and apply them in your plugin’s stylesheet. Since plugins run in iframes, they do not inherit the host’s CSS variables automatically.
Option 1: Inject CSS variables from the host
Request theme data via postMessage and set CSS variables in the plugin:
// In your plugin's JavaScript
window.addEventListener('message', (event) => {
if (event.data?.type === 'entity-context' || event.data?.type === 'theme-change') {
applyHostTheme(event.data.theme);
}
});
function applyHostTheme(theme) {
// Apply the light/dark class to your root element
document.documentElement.classList.toggle('dark', theme === 'dark');
}Option 2: Define your own surface variables that match the host patterns
Create a CSS file in your plugin that mirrors the host’s surface variable naming:
/* plugin-theme.css */
:root {
--surface-base-bg: #ffffff;
--surface-base-text: #1a1a2e;
--surface-base-text-secondary: #6b7280;
--surface-base-border: #e5e7eb;
--surface-elevated-bg: #f9fafb;
--surface-elevated-text: #1a1a2e;
--surface-elevated-border: #e5e7eb;
--surface-primary-bg: #3b82f6;
--surface-primary-text: #ffffff;
--surface-error-bg: #fef2f2;
--surface-error-text: #991b1b;
--surface-success-bg: #f0fdf4;
--surface-success-text: #166534;
}
.dark {
--surface-base-bg: #0f0f1a;
--surface-base-text: #e5e5e5;
--surface-base-text-secondary: #9ca3af;
--surface-base-border: #374151;
--surface-elevated-bg: #1a1a2e;
--surface-elevated-text: #e5e5e5;
--surface-elevated-border: #374151;
--surface-primary-bg: #3b82f6;
--surface-primary-text: #ffffff;
--surface-error-bg: #450a0a;
--surface-error-text: #fca5a5;
--surface-success-bg: #052e16;
--surface-success-text: #86efac;
}Then use these variables in your plugin styles:
.plugin-card {
background-color: var(--surface-elevated-bg);
color: var(--surface-elevated-text);
border: 1px solid var(--surface-elevated-border);
border-radius: 8px;
padding: 16px;
}
.plugin-button-primary {
background-color: var(--surface-primary-bg);
color: var(--surface-primary-text);
border: none;
border-radius: 6px;
padding: 8px 16px;
cursor: pointer;
}
.plugin-error {
background-color: var(--surface-error-bg);
color: var(--surface-error-text);
}What NOT to Do
/* WRONG: Hardcoded colors break user themes */
.plugin-card {
background-color: #1e293b;
color: white;
}
.plugin-button {
background-color: #3b82f6;
color: white;
}
.error-text {
color: #ef4444;
}<!-- WRONG: Tailwind color utilities are hardcoded -->
<div class="bg-slate-800 text-white">
<button class="bg-blue-500 text-white">Click</button>
</div>Surface Reference for Plugin Developers
| Surface | Use For | Light Default | Dark Default |
|---|---|---|---|
base | Main backgrounds | White bg, dark text | Dark bg, light text |
elevated | Cards, panels | Slightly tinted bg | Slightly lighter bg |
primary | Buttons, CTAs | Blue bg, white text | Blue bg, white text |
secondary | Secondary actions | Purple bg, white text | Purple bg, white text |
accent | Badges, highlights | Green bg, white text | Green bg, white text |
success | Success states | Green-tinted bg, dark text | Dark green bg, light text |
warning | Warning states | Yellow-tinted bg, dark text | Dark yellow bg, light text |
error | Error states | Red-tinted bg, dark text | Dark red bg, light text |
info | Info states | Blue-tinted bg, dark text | Dark blue bg, light text |
Lifecycle Hooks
Plugins respond to lifecycle events via the postMessage protocol:
On Load
When the iframe loads, the host sends entity-context. Initialize your plugin UI here:
window.addEventListener('message', (event) => {
if (event.data?.type === 'entity-context') {
const { entityType, entityId, data } = event.data;
initializePlugin(entityType, entityId, data);
}
});On Entity Change
When the entity is updated by any source (user edit, AI generation, sync):
window.addEventListener('message', (event) => {
if (event.data?.type === 'entity-updated') {
refreshPluginUI(event.data.data);
}
});On Theme Change
When the user toggles light/dark mode:
window.addEventListener('message', (event) => {
if (event.data?.type === 'theme-change') {
applyTheme(event.data.theme);
}
});Plugin State Persistence
Plugins can persist data using the backend data storage API. This is scoped per-plugin and per-tenant.
CRUD Operations
const PLUGIN_ID = 'my-analytics-plugin';
const API_BASE = '/api/plugins';
// Create a record
async function saveAnalysis(data) {
const response = await fetch(`${API_BASE}/${PLUGIN_ID}/data/analysis`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
return response.json();
}
// List records
async function listAnalyses() {
const response = await fetch(`${API_BASE}/${PLUGIN_ID}/data/analysis`);
return response.json();
}
// Get single record
async function getAnalysis(id) {
const response = await fetch(`${API_BASE}/${PLUGIN_ID}/data/analysis/${id}`);
return response.json();
}
// Update record
async function updateAnalysis(id, data) {
const response = await fetch(`${API_BASE}/${PLUGIN_ID}/data/analysis/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
return response.json();
}
// Delete record
async function deleteAnalysis(id) {
await fetch(`${API_BASE}/${PLUGIN_ID}/data/analysis/${id}`, {
method: 'DELETE'
});
}The record_type path segment (e.g., analysis) is arbitrary and acts as a collection name. Each plugin gets its own namespace.
Plugin Settings
Settings declared in plugin.json are managed through two API endpoints:
Global Settings (admin-only)
GET /api/plugins/{id}/settings
PUT /api/plugins/{id}/settingsUser-Specific Settings
GET /api/plugins/{id}/settings/user
PUT /api/plugins/{id}/settings/userAccess settings from the plugin:
async function loadSettings() {
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 };
}Backend Routes
Plugins can register FastAPI routes that load at startup. Create a routes.py file:
# routes.py
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from database import get_db
router = APIRouter(prefix="/api/plugins/my-analytics-plugin")
@router.get("/custom-analysis/{entity_type}")
async def run_analysis(entity_type: str, db: Session = Depends(get_db)):
"""Custom endpoint added by the plugin."""
# Your analysis logic here
return {"entity_type": entity_type, "result": "..."}Reference this file in plugin.json:
{
"capabilities": {
"backend": {
"routes": "routes.py"
}
}
}The router is automatically included in the FastAPI app at startup.
Backend Event Handlers
Plugins can subscribe to backend events (entity create, update, delete, sync):
# 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):
# React to new entities
print(f"New entity: {event.entity_type}/{event.entity_id}")
@event_bus.on("entity_updated")
async def on_entity_updated(event: EntityEvent):
# React to entity changes
passAuto-Resize
Plugins should report their content height so the host can size the iframe container:
// Watch for content size changes
const observer = new ResizeObserver(() => {
window.parent.postMessage({
type: 'resize',
height: document.body.scrollHeight
}, '*');
});
observer.observe(document.body);Local Development
During development, you can serve your plugin from localhost:
- Start your plugin dev server (e.g., on port 5173).
- Install the plugin using the local URL.
- The plugin iframe will load from your dev server.
- Hot reload works as the iframe fetches from your local server.
Plugin Directory Structure
my-analytics-plugin/
plugin.json # Manifest (required)
panels/
network.html # Panel HTML files served to iframes
stats.html
routes.py # Backend routes (optional)
events.py # Event handlers (optional)
assets/
icon.svg # Plugin icon
screenshot.png # Marketplace screenshotMarketplace Submission
To submit your plugin to the StudioBrain marketplace:
- Ensure
plugin.jsonis complete with description, author, version, and license. - Include at least one screenshot in
assets/. - Test your plugin with both light and dark themes.
- Verify theme compliance: no hardcoded colors.
- Package as a
.ziparchive withplugin.jsonat the root. - Submit via
POST /api/marketplace/registrywith the archive.
The marketplace displays plugins with their manifested metadata. Tenants can install, enable, and configure plugins through the StudioBrain Settings page.