API Reference
The studio and runtime plugin surfaces, method by method, with examples and gotchas.
Two entry points, imported from the types-only narraleaf-studio package:
narraleaf-studio/plugin— the editor surface, used by thestudioentry.narraleaf-studio/runtime— the game surface, used by theruntimeentry.
Conventions
These hold across the whole studio surface:
- Registrations return a cleanup. Every
register/registerManyreturns aPluginCleanupthat removes exactly what it registered. The host also tracks each registration, so unloading the plugin reclaims everything even if you never call the cleanup. Cleanups are idempotent. - Ids are namespaced. Any id or type you register must be prefixed with your plugin id (
yourname.plugin.thing). Registering an unprefixed id throws. - Imperative calls return their value.
editors.open,notifications.*,i18n.format*, andblueprintNodes.notifyDynamicSelectOptionsChangedare actions, not registrations — they do not return a cleanup. - One exception:
blueprintNodes.register/registerManyreturnvoid. Node definitions are session-persistent — removing one would orphan nodes in open documents — so there is nothing to dispose.
The runtime surface has no cleanup lifecycle at all: a game process loads plugins once and never unloads them, so its register calls return void.
narraleaf-studio/plugin
definePlugin
import { definePlugin } from "narraleaf-studio/plugin";
export default definePlugin({
setup(app) {
// register things
return () => { /* optional cleanup */ };
},
});setup(app) runs when the workspace window loads the plugin. It may be async. Return a cleanup function to run on unload; returning nothing is fine, since the host tracks each registration anyway.
app carries:
| Property | What |
|---|---|
app.plugin | Identity: id, name, version, publisher. |
app.manifest | The normalized manifest. |
app.services | The curated API surface (below). |
app.privileged | Elevated capabilities (filesystem, bash) gated by manifest permissions. |
services.i18n
Read access to the editor's language, for localizing your own strings.
const i18n = app.services.i18n.createTranslator({
messages: { en: { hi: "Hi" }, zh: { hi: "你好" } },
fallbackLocale: "en",
});
i18n.t("hi"); // follows the editor locale
const stop = app.services.i18n.onLocaleChange(locale => {
// re-render your UI for the new locale
});| Member | What |
|---|---|
locale | The active editor locale code. |
onLocaleChange(fn) | Fires with the new locale on a language switch. Returns a cleanup. |
createTranslator(bundle) | A translator over your { locale: { key: string } } tables. t(key, params?) resolves active → fallback → key, filling {placeholders}. |
formatNumber / formatDate / formatList | Intl formatters bound to the editor locale. |
This is the editor's UI language. It is not a game's player-facing language — the runtime surface has no i18n; a game localizes through its own system.
services.storage
Per-plugin JSON storage, scoped to the project.
await app.services.storage.writeJson("state", { count: 1 });
const data = await app.services.storage.readJson<{ count: number }>("state");
// readJson returns null when nothing is stored yet.services.assets
Read the project's assets and turn them into object URLs for previews.
const images = app.services.assets.list(AssetType.Image);
const url = await app.services.assets.createObjectUrl(images[0]);
// ... use url ...
app.services.assets.revokeObjectUrl(url);getMap(), list(type), get(type, id), fetch(asset), createObjectUrl(asset), revokeObjectUrl(url). Revoke object URLs you create to avoid leaks.
services.ui.panels
Sidebar and bottom panels.
const off = app.services.ui.panels.register({
id: `${PLUGIN_ID}.panel`,
title: "My Panel",
position: PanelPosition.Left,
component: () => <MyPanel />,
});
// off() removes it; unload removes it too.register(panel) and registerMany(panels) return cleanups.
services.ui.actions
Toolbar and menu actions, and action groups (dropdowns / native menu entries).
app.services.ui.actions.register({
id: `${PLUGIN_ID}.doThing`,
label: "Do Thing",
onClick: workspace => { /* ... */ },
});
app.services.ui.actions.registerGroup({ id: `${PLUGIN_ID}.menu`, label: "My Menu", actions: [/* ... */] });register, registerMany, and registerGroup return cleanups. A plugin group is confined to a menu of its own — it cannot merge into Studio's native Edit menu or claim standard command roles.
services.ui.editors
Open and close editor tabs. Imperative — tabs are user-visible, so they are never force-closed on unload.
app.services.ui.editors.open({ id: `${PLUGIN_ID}.doc`, title: "Doc", component: MyEditor });
app.services.ui.editors.close(`${PLUGIN_ID}.doc`);services.ui.keybindings
const off = app.services.ui.keybindings.register({
id: `${PLUGIN_ID}.save`,
key: "mod+s",
handler: () => { /* ... */ },
});register and registerMany return cleanups. mod is ⌘ on macOS and Ctrl elsewhere.
services.ui.notifications
Fire-and-forget toasts. info, success, warning, error.
app.services.ui.notifications.success("Saved");services.widgets
Register UI-editor widget modules (editor-side). The game-side renderer for the same widget type is registered from the runtime entry.
const off = app.services.widgets.register(myWidgetModule);
app.services.widgets.get(type);
app.services.widgets.list();
app.services.widgets.has(type);register / registerMany return cleanups. The widget type must be declared in contributes.widgets.
services.story.actions
Scene-editor palette actions that insert story blocks. The blocks are standard story blocks — the document does not depend on the plugin after insertion.
app.services.story.actions.register({
id: `${PLUGIN_ID}.insertNote`,
label: "Insert Note",
createBlock: () => ({ /* a story block */ }),
});register and registerMany return cleanups.
services.blueprintNodes
app.services.blueprintNodes.register(def); // void — session-persistent
app.services.blueprintNodes.registerMany(defs); // void
const off = app.services.blueprintNodes.registerDynamicSelectOptionsSource(
`${PLUGIN_ID}.items`,
() => [{ value: "a", label: "A" }],
);
app.services.blueprintNodes.notifyDynamicSelectOptionsChanged();register/registerManyadd editor definitions (and their in-editor-previewexecute). They returnvoid: node defs cannot be removed once registered. Each type must be declared incontributes.blueprintNodes.registerDynamicSelectOptionsSource(id, provider)backs akind: "select"inspector param whose options come from live plugin state; it returns a cleanup. CallnotifyDynamicSelectOptionsChanged()when that state changes so open node cards refresh.
The ui kit
import { ui } from "narraleaf-studio/plugin";Studio's own components, so panels match the editor theme: ui.Button, ui.IconButton, ui.Input, ui.TextArea, ui.Select, ui.Switch, ui.Card (+ parts), ui.Modal (+ parts), ui.AssetSelector, and the ui.Panel.* layout primitives (Root, Header, Toolbar, Section, Row, EmptyState).
narraleaf-studio/runtime
defineRuntimePlugin
import { defineRuntimePlugin } from "narraleaf-studio/runtime";
export default defineRuntimePlugin({
setup(app) {
app.game.blueprintNodes.registerMany(createNodes());
},
});setup(app) runs once per game process (Dev Mode, Preview, Production). It may be async. There is no cleanup return — game processes do not unload plugins.
app carries app.plugin, app.manifest, and app.game.
app.game has four members that are always present — blueprintNodes, widgets, data, log — and a set of capability-gated namespaces that exist only when contributes.runtimeCapabilities declared them. An undeclared namespace is undefined, not a thrower. See the Runtime API for the full model.
game.blueprintNodes
app.game.blueprintNodes.register({ type, execute });
app.game.blueprintNodes.registerMany(defs);Registers the game-side execute for each node type. Pass the same BlueprintNodeDef[] you register on the studio side (a shared module) — only type, displayName, and execute are used. Each type must be declared in contributes.blueprintNodes. Returns void.
The node context
execute(ctx) receives exactly this, and nothing else:
| Field | Type | What |
|---|---|---|
ctx.params | Record<string, unknown> | Static parameter values authored on the node. |
ctx.resolveInput? | (pinId: string) => unknown | Reads one of the node's declared data input pins, following the wired edge. Lazy; undefined for an unwired or undeclared pin. |
ctx.eventName? | string | The event slot being handled, when the node runs inside an event graph. |
ctx.eventPayload? | Record<string, unknown> | That event's payload. |
ctx.signal? | AbortSignal | Aborted when the execution is cancelled. Honour it in long-running nodes. |
ctx.game | RuntimePluginGame | The very same object setup(app) received as app.game. |
execute: async ctx => {
const message = String(ctx.params.message ?? "");
const wired = ctx.resolveInput?.("value");
// Undeclared or unavailable here: the namespace is absent, so skip the work.
await ctx.game.store?.set("lastMessage", message);
return { nextPort: "next" };
}There is no ctx.hostAdapter. Earlier builds leaked the host's full internal API through ctx.hostAdapter.blueprintRuntime.hostApi — saves, localization, quitting the app — with nothing declared in the manifest and nothing shown to the author at install. That path is gone. Whatever a node touches, it touches through ctx.game, which is exactly the capability set contributes declared.
game.widgets
app.game.widgets.register({ type, render });
app.game.widgets.registerMany(defs);Registers the game-side renderer for a widget type. render receives the same props Studio passes built-in element renderers. Each type must be declared in contributes.widgets. Returns void.
game.data
const catalog = app.game.data.readJson<Catalog>("yourname.plugin.catalog");Read-only access to plugin storage published with the game, for the namespaces declared in contributes.runtimeData. Synchronous — the data travels with the pack, so there is nothing to await. Returns null when the namespace was not declared, the project never wrote it, or the game predates the data being published.
game.log
app.game.log("info", "loaded"); // "info" | "warning" | "error"Writes to the game host log with a [plugin:{id}] prefix. Dev Mode sends it to the window console; Preview and Production send it to the game process log.
The capability-gated namespaces
Each of these exists only when contributes.runtimeCapabilities declared it and this environment can back it. Full reference: Runtime API.
| Namespace | Capability | Reference |
|---|---|---|
game.store | store | store |
game.events | events | events |
game.state | state.read (set needs state.write) | state |
game.saves | saves.read (write / load need saves.write) | saves |
game.ui.overlay | ui.overlay | ui.overlay |
game.assets | assets | assets & locale |
game.locale | locale | assets & locale |
game.sidecar | none — a non-empty contributes.sidecars | sidecar |
No i18n on the runtime surface
The runtime entry runs in the game — in Production there is no editor and no editor locale, so exposing one would not be portable. A game localizes its own player-facing content through NarraLeaf's game localization system, not through the plugin runtime API.
Gotchas
- Mark host modules external.
narraleaf-studio/plugin,narraleaf-studio/runtime, and the React packages are provided by the host. Bundling your own copies breaks loading. The template's esbuild config lists them. - A blueprint node needs both entries to work in a shipped game — studio for the palette, runtime for execution. Studio-only registration runs in the editor preview and then does nothing once exported.
- Check for a namespace, do not catch. An undeclared or unavailable capability is missing from
app.game, soapp.game.store?.set(...)is the guard. There is nothing totry. - The editor cannot back any capability. The same
executeruns on the editor canvas, where every gated namespace is absent. A node that assumes a game will do nothing there — write it so that is fine. - Node defs are permanent for the session.
blueprintNodes.registerreturnsvoidby design; you cannot unregister a node type. - Never hand-write a derived permission.
runtime,sidecar, andbuildDependencypermissions come fromcontributes; writing one intopermissions[]fails the manifest.