Authoring overview
What a VSDK plugin actually is, the lifecycle hooks, the PluginContext, the difference between in-project and published plugins, and how to package one if you go that route.
A VSDK plugin is two things wrapped into one factory function:
- A small plugin definition —
id,name,version, and three optional lifecycle hooks. - Whatever React components you want to expose (panels, inspector sections, etc.) — typically
loaded with
React.lazyso they don't ship until the user actually opens them.
That's it. The SDK runs your onRegister once, you call ctx.register* methods to contribute
extension points, and your stuff appears in the editor.
Two paths, same code
The plugin code itself doesn't care whether it lives inside your app or as its own npm package. The difference is purely about distribution and packaging:
In-project plugins
The folder lives in src/vsdk-plugins/ inside your app. No separate build, no peer-dep gymnastics, no publishing. Three escalating tiers — simple, medium, advanced — for everything from a 5-minute panel to a full adapter-backed surface.
Published plugins
Standalone npm package, peer-dep declarations, semantic versioning, testing coverage, submission to the official catalog. Same plugin code — different repository shape.
Most plugins start in-project and stay there forever. Promote to a package only when another team / product / external user needs to install it.
The plugin definition
import { createPlugin } from '@studio-dev/vsdk/plugins'
export function myPlugin() {
return createPlugin({
id: 'mycompany:my-plugin', // unique, namespace it
name: 'My Plugin',
version: '0.1.0',
onRegister(ctx) {
// wire up extension points here
},
// optional
onActivate(ctx) {
// returns a cleanup function (optional)
},
// optional
onDeactivate(ctx) {
// teardown when the plugin is removed
},
})
}createPlugin is a pass-through identity function — it only exists to give you type inference on
the return shape. You can equally well export const myPlugin = (): PluginDefinition => ({ ... }).
Lifecycle
provider mounts
↓
for each plugin:
registry.register(plugin)
await plugin.onRegister(ctx)
const cleanup = plugin.onActivate?.(ctx)
↓
…editor runs…
↓
provider unmounts
↓
for each plugin:
cleanup?.()
plugin.onDeactivate?.(ctx)onRegister(ctx)
Called once per plugin, when the provider mounts. This is where you register every extension
point — panels, inspector sections, toolbar actions, context menu items, hotkeys, icons. Can be
async if you need to lazy-load configuration; the SDK awaits it before rendering plugin UI.
async onRegister(ctx) {
const { setConfig } = await import('./config-store')
setConfig({ apiKey: options.apiKey })
ctx.registerPanel({ /* ... */ })
}onActivate(ctx)
Called after onRegister for plugin-wide side effects (event subscriptions, store subscriptions,
analytics setup). Return a cleanup function and the SDK will call it on unmount. Most plugins do
not need this — extension-point registration happens in onRegister.
onActivate(ctx) {
const unsub = ctx.store.subscribe((state, prev) => {
if (state.player.currentFrame !== prev.player.currentFrame) { /* … */ }
})
return unsub
}onDeactivate(ctx)
Called on unmount as a last chance to release resources (e.g. close a websocket). The cleanup
returned from onActivate runs first.
The PluginContext
Every lifecycle hook receives the same PluginContext:
interface PluginContext {
store: StudioStore // full Zustand store
icons: StudioIconMap // resolved icons (defaults + user)
registerPanel: (p: PanelRegistration) => void
registerInspectorSection: (s: InspectorSectionConfig) => void
registerToolbarAction: (a: ToolbarAction) => void
registerContextMenuAction: (a: ContextMenuAction) => void
registerHotkey: (h: HotkeyRegistration) => void
registerIcons: (i: Record<string, StudioIconComponent>) => void
}ctx.store— the same store everything reads from. Usectx.store.getState()for snapshots,ctx.store.subscribe()for reactive work outside React. Inside your React components, preferuseStudioStore/useStudioStoreApifrom@studio-dev/vsdk.ctx.icons— the resolved icon map at the time of registration (defaults + user overrides). Use these for sidebar/panel icons so they react to user theming.
What you can contribute
| Extension point | Where it shows up |
|---|---|
| Panel | Left sidebar (desktop) / bottom sheet (mobile) |
| Inspector section | Right-hand properties panel when an item is selected |
| Toolbar action | Top toolbar |
| Context menu action | Right-click menu on timeline items |
| Hotkey | Global / timeline-focused / player-focused keyboard shortcut |
| Icons | Added to the global icon map; reachable via useIconMap |
Each has its own deep-dive page under Extension points. The minimum-viable plugin uses just a panel.
Sorting and uniqueness
The registry de-duplicates by id for panels / toolbar / context menu / hotkeys; for inspector
sections, by (itemType, label). Re-registering with the same key is a no-op. Within a category,
items are sorted by order ascending — pick numbers with gaps (10, 20, 30) so other plugins can
slot between you. Built-ins occupy 1–10; community plugins typically use 50, 60, 70, …
Pick your starting point
Simple tier
Five-minute, single-panel plugin. Lives in src/vsdk-plugins/. Click to insert items at the playhead.
Medium tier
Panel + inspector section + hotkey + host-app config options. ~80% of plugins stabilise here.
Advanced tier
Adapter pattern for I/O, drag & drop, custom commands, event subscriptions, custom icons, license gating.
Extension points
Reference for every registration — panel, inspector, toolbar, context menu, hotkey, icons.
Recipes
Cross-cutting patterns — drag & drop, batching, store subscriptions, autosave, attribution, license gating.
Publishing a plugin
From an in-project plugin to a published npm package listed in the official VSDK catalog. Structure, peer-deps, testing coverage, validation, submission.