Developer ReferencePlugin Development

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

FieldTypeRequiredDescription
idstringYesUnique plugin identifier (lowercase, hyphens allowed)
namestringYesDisplay name
versionstringYesSemver version
descriptionstringYesBrief description
authorstringNoAuthor name or organization
licensestringNoSPDX license identifier
iconstringNoLucide icon name for plugin list
homepagestringNoPlugin homepage URL
capabilities.frontend.panelsarrayNoUI panels to register
capabilities.backend.routesstringNoPython file with FastAPI routes
capabilities.backend.event_handlersstringNoPython file with event handlers
capabilities.settings.globalarrayNoAdmin-configurable settings
capabilities.settings.userarrayNoPer-user settings

Panel Locations

LocationRenders AsDescription
entity-sidebarCollapsible sectionShown alongside the Visual Editor
entity-tabFull tabAuto-injected into the entity edit tabs
entity-footerBelow editorReserved 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 execution
  • allow-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 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.origin before processing incoming plugin messages.
  • Plugins should validate that messages come from the expected host origin (provided in the initial entity-context message if needed, or by checking against window.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:

  1. On load: The entity-context message provides the initial theme state.
  2. On change: The theme-change message 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

SurfaceUse ForLight DefaultDark Default
baseMain backgroundsWhite bg, dark textDark bg, light text
elevatedCards, panelsSlightly tinted bgSlightly lighter bg
primaryButtons, CTAsBlue bg, white textBlue bg, white text
secondarySecondary actionsPurple bg, white textPurple bg, white text
accentBadges, highlightsGreen bg, white textGreen bg, white text
successSuccess statesGreen-tinted bg, dark textDark green bg, light text
warningWarning statesYellow-tinted bg, dark textDark yellow bg, light text
errorError statesRed-tinted bg, dark textDark red bg, light text
infoInfo statesBlue-tinted bg, dark textDark 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}/settings

User-Specific Settings

GET  /api/plugins/{id}/settings/user
PUT  /api/plugins/{id}/settings/user

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

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

  1. Start your plugin dev server (e.g., on port 5173).
  2. Install the plugin using the local URL.
  3. The plugin iframe will load from your dev server.
  4. 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 screenshot

Marketplace Submission

To submit your plugin to the StudioBrain marketplace:

  1. Ensure plugin.json is complete with description, author, version, and license.
  2. Include at least one screenshot in assets/.
  3. Test your plugin with both light and dark themes.
  4. Verify theme compliance: no hardcoded colors.
  5. Package as a .zip archive with plugin.json at the root.
  6. Submit via POST /api/marketplace/registry with the archive.

The marketplace displays plugins with their manifested metadata. Tenants can install, enable, and configure plugins through the StudioBrain Settings page.