NarraLeaf

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 the studio entry.
  • narraleaf-studio/runtime — the game surface, used by the runtime entry.

Conventions

These hold across the whole studio surface:

  • Registrations return a cleanup. Every register / registerMany returns a PluginCleanup that 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*, and blueprintNodes.notifyDynamicSelectOptionsChanged are actions, not registrations — they do not return a cleanup.
  • One exception: blueprintNodes.register / registerMany return void. 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:

PropertyWhat
app.pluginIdentity: id, name, version, publisher.
app.manifestThe normalized manifest.
app.servicesThe curated API surface (below).
app.privilegedElevated 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
});
MemberWhat
localeThe 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 / formatListIntl 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),
});
MemberSignatureNotes
listKeys() => PluginLocalizationKeyEntry[]Each key's name and its sourceText. Empty until the keys have loaded.
registerWords(source: PluginWordsSource) => PluginCleanupOffers 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.

PluginWordsEntryNotes
idThe 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.
textThe words, in the project's source language.
contextOptional. 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 / registerMany add editor definitions (and their in-editor-preview execute). They return void: node defs cannot be removed once registered. Each type must be declared in contributes.blueprintNodes. A definition is a PluginBlueprintNodeDef; see blueprintNodes for its fields.
  • registerDynamicSelectOptionsSource(id, provider) backs a kind: "select" inspector param whose options come from live plugin state; it returns a cleanup. Call notifyDynamicSelectOptionsChanged() 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:

FieldTypeWhat
ctx.paramsRecord<string, unknown>Static parameter values authored on the node.
ctx.resolveInput?(pinId: string) => unknownReads one of the node's declared data input pins, following the wired edge. Lazy; undefined for an unwired or undeclared pin.
ctx.eventName?stringThe event slot being handled, when the node runs inside an event graph.
ctx.eventPayload?Record<string, unknown>That event's payload.
ctx.signal?AbortSignalAborted when the execution is cancelled. Honour it in long-running nodes.
ctx.gameRuntimePluginGameThe 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:

FieldTypeWhat
ctx.paramsRecord<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.signalAbortSignalAborted when the player rolls back past the row, or loads a save, while run is still going.
ctx.gameRuntimePluginGameThe 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 run has 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 run is going runs the row again when it is loaded. run has to be able to start from the beginning.
  • When signal aborts, take down whatever run put 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.storyActions throws, 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.

NamespaceCapabilityReference
game.storestorestore
game.eventseventsevents
game.statestate.read (set needs state.write)state
game.savessaves.read (write / load need saves.write)saves
game.ui.overlayui.overlayui.overlay
game.assetsassetsassets & locale
game.localelocaleassets & locale
game.menumenuRuntime API
game.diagnosticsdiagnosticsRuntime API
game.processprocess.memoryRuntime API
game.sidecarnone — a non-empty contributes.sidecarssidecar
game.storystory.compileRuntime API
game.navigationnone — a non-empty contributes.externalLinksRuntime 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
MemberSignatureNotes
currentreadonly stringThe language the game is running in.
onChange(listener: (locale: string) => void) => RuntimePluginCleanupFires on a language switch.
text(key: string) => string | nullOne of the project's translation keys, in that language. null when the project declares no such key.
words(id: string, text: string) => stringWords 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, so app.game.store?.set(...) is the guard. There is nothing to try.
  • The editor cannot back any capability. The same execute runs 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.register returns void by design; you cannot unregister a node type.
  • Never hand-write a derived permission. runtime, sidecar, and buildDependency permissions come from contributes; writing one into permissions[] fails the manifest.

On this page