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: words a player reads are translated in the project's translation table, through services.localization, and read back in the game through game.locale.
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.localization
The project's translation keys, read-only, and a place to offer the plugin's own words for translation.
const keys = app.services.localization.listKeys(); // [{ name, sourceText }]
const off = app.services.localization.registerWords({
list: () => store.entries().map(entry => ({
id: `entry.${entry.id}.name`,
text: entry.name,
context: entry.group,
})),
subscribe: listener => store.subscribe(listener),
});| Member | Signature | Notes |
|---|---|---|
listKeys | () => PluginLocalizationKeyEntry[] | Each key's name and its sourceText. Empty until the keys have loaded. |
registerWords | (source: PluginWordsSource) => PluginCleanup | Offers the words the plugin puts in front of a player to the translation table and exports. |
A plugin that puts words in front of a player either names one of the project's translation keys, which is translated as the key, or writes the words directly and offers them with registerWords. PluginWordsSource is { list(), subscribe? }: list() returns PluginWordsEntry objects, and subscribe(listener) returns an unsubscribe function; call the listener whenever list() would answer differently.
PluginWordsEntry | Notes |
|---|---|
id | The plugin's own id for the words: letters and digits, with ., _ or - between them. Keep it the same while the words are the same thing; their translations are kept against it. |
text | The words, in the project's source language. |
context | Optional. Where a player meets the words, for a translator, such as File › Save. The plugin's name when absent. |
Each entry with a letter in it has a row in the translation table, under Interface text and the plugin's name. The translation unit is plugin:<pluginId>/<id>. The runtime entry reads the words back in the player's language with app.game.locale.words(id, text). ui.WordsField gives the author the same choice of Direct or Translation key that Studio's own labels offer.
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. A module's logicApi declares the widget's blueprint events; see widget events.
services.story.actions
Scene-editor palette actions that insert story blocks. A block is either a standard story block, which the document keeps without the plugin, or the plugin's own row (action: "plugin"), which the runtime entry runs through game.storyActions.
app.services.story.actions.register({
id: `${PLUGIN_ID}.insertNote`,
label: "Insert Note",
createBlock: () => ({ /* a story block */ }),
});register and registerMany return cleanups. createBlock receives the text the author typed after the command as initialText, and an optional describe(params) sets the text a plugin row shows. keywords are command words that reach the action in every interface language, and params lists the fields the Properties panel shows on a plugin row (both read by Studio 1.6.0 and later). See story.
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. A definition is aPluginBlueprintNodeDef; see blueprintNodes for its fields.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, ui.WordsField, and the ui.Panel.* layout primitives (Root, Header, Toolbar, Section, Row, EmptyState).
ui.WordsField is the field for words a player reads, with the choice, key list and in-place key editing of Studio's own text and button labels. It is controlled: value is { text, key }, with key null for words written directly, and onChange receives the next value. Words written directly are translated when the plugin offers them with registerWords; words read from a key are translated as the key.
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 six members that are always present (blueprintNodes, widgets, storyActions, data, config, 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 PluginBlueprintNodeDef[] 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" };
}execute returns { nextPort?, nextPorts?, outputValues? } or nothing; see Return value.
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.storyActions
app.game.storyActions.register({ id, run });
app.game.storyActions.registerMany(defs);Registers what runs when the story reaches a row one of the plugin's story actions inserted. id is the id the studio entry registered the action under. Returns void.
run(ctx) receives:
| Field | Type | What |
|---|---|---|
ctx.params | Record<string, unknown> | The row's params: what createBlock wrote, with the author's edits in the Properties panel. A fresh copy on every run. |
ctx.signal | AbortSignal | Aborted when the player rolls back past the row, or loads a save, while run is still going. |
ctx.game | RuntimePluginGame | The same object setup(app) received as app.game. |
app.game.storyActions.register({
id: `${PLUGIN_ID}.play`,
run: async ({ params, signal, game }) => {
// Show something, wait for the player, write the result.
game.state?.set?.("saved", String(params.variable), "win");
},
});- The story waits. The next row starts when
runhas returned, or when the promise it returns has settled. Clicks, the advance keys, auto mode and skipping do not move past the row. - A save taken while
runis going runs the row again when it is loaded.runhas to be able to start from the beginning. - When
signalaborts, take down whateverrunput on screen and stop. Nothing it does afterwards reaches the story. - A throw is written to the game log and the story continues.
- The id must start with the plugin id. No capability is needed: a run only happens on a row the author placed, and only on the plugin's own rows.
- In the editor
app.game.storyActionsthrows, because the editor does not run the story.
A complete example is Example: Rock, Paper, Scissors.
game.widgets
app.game.widgets.register({ type, render, logicApi });
app.game.widgets.registerMany(defs);Registers the game-side renderer for a widget type. render receives RuntimeWidgetRendererProps, the same props the studio widget module's render receives; see render props. Pass the same logicApi the studio module declares, or the widget's events reach no graph in Dev Mode or in the game. 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.menu | menu | Runtime API |
game.diagnostics | diagnostics | Runtime API |
game.process | process.memory | Runtime API |
game.sidecar | none — a non-empty contributes.sidecars | sidecar |
game.story | story.compile | Runtime API |
game.navigation | none — a non-empty contributes.externalLinks | Runtime API |
The player's language
The runtime entry runs in the game — in Production there is no editor and no editor locale, so the runtime surface has no i18n. With the locale capability, app.game.locale follows the player's language and reads the project's translated words in it:
const title = app.game.locale?.text("menu.title"); // a translation key; null when the project has none
const name = app.game.locale?.words("entry.cg01.name", "Sunset"); // words offered with registerWords| Member | Signature | Notes |
|---|---|---|
current | readonly string | The language the game is running in. |
onChange | (listener: (locale: string) => void) => RuntimePluginCleanup | Fires on a language switch. |
text | (key: string) => string | null | One of the project's translation keys, in that language. null when the project declares no such key. |
words | (id: string, text: string) => string | Words this plugin offered with registerWords from its studio entry: the translation of the unit id names, along the language's fallback chain, else text. The game carries translations and not source words, so the plugin passes the words as written. A plugin reads only its own words. |
See assets & locale.
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 in Dev Mode and the game the graph stops at that node with an error.
- 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.