Plugin API
Toybox plugins are small JavaScript modules that add import and export formats. A plugin reads the selected assets, gets the host's glTF/GLB primitives, and writes files — optionally shipping its own React UI that renders with the app's own design system. This page is the complete contract.
Overview
There are two kinds of plugin. An exporter turns the current selection
into files on disk — a merged .glb, a loose copy, a
project-specific layout. An importer brings new assets into the
library from some outside source. Both are authored against one package:
npm install @ldlework/toybox-sdk
The package is the plugin contract — manifest types, the ctx
shapes your code receives, and the define* authoring helpers. You
install it for types; at runtime the host redirects the import to its own bundled copy, so
your plugin shares the host's exact instances (one three, one React)
rather than bundling its own.
Plugin anatomy
A plugin is a folder under the app's data directory. Each folder's name must equal the
plugin id in its manifest.
com.acme.myplugin/
├─ manifest.json # required — id, kind, permissions, entry/ui
├─ dist/
│ ├─ index.js # exporter entry module (default-exports run)
│ └─ ui/
│ └─ Export.js # optional UI panel (default-exports a component)
└─ ...your source
On Windows the data directory is %APPDATA%\com.toybox.app\plugins\;
on macOS/Linux it's the platform's app-data equivalent. The app discovers plugins at
startup — a folder with no valid manifest is skipped, never fatal.
The manifest
manifest.json declares what the plugin is and what it may do.
{
"id": "com.acme.myplugin",
"name": "My Exporter",
"version": "1.0.0",
"kind": "exporter",
"entry": "dist/index.js",
"description": "What this plugin does.",
"permissions": {
"rustExport": true,
"fsWrite": true,
"fsRead": false
},
"ui": {
"exportPanel": "dist/ui/Export.js"
}
} id: string kind: "exporter" | "importer" entry: string permissions: {…} fields?: FieldSpec[] ui?: { exportPanel?, importPanel? } Exporters
An exporter's entry module default-exports the result of defineExporter.
Its run receives the ctx and the selected
assets, does its work through the host primitives, and returns a report.
import { defineExporter } from "@ldlework/toybox-sdk";
export default defineExporter({
async run(ctx, assets) {
const { host, fs, config, report } = ctx;
for (const asset of assets) {
const glb = await host.assembleGlb(asset.id); // Uint8Array
await fs.writeBytes(config.targetDir, asset.name + ".glb", glb);
report.write(asset.name + ".glb");
}
return report.done();
},
}); run is called once per export, with the live selection. Throw to
fail the run; the host surfaces the message. Respect ctx.signal for
cancellation on long runs.
run(ctx: ExportCtx, assets: AssetView[]): Promise<ExportReport> Importers
Importers are panel-only — there is no run. The work
lives in an importPanel component that picks a source, builds
SeedEntryInput[], and hands them to the host via
ctx.commit. The host merges them into the catalog and refreshes the
grid.
import { defineImportPanel, type ImportPanelCtx, type SeedEntryInput } from "@ldlework/toybox-sdk";
import { Stack, Button } from "@ldlework/toybox-sdk/ui";
export default defineImportPanel(function ImportPanel({ ctx }: { ctx: ImportPanelCtx }) {
async function go() {
const dir = await ctx.host.pickDirectory();
if (!dir) return;
const entries: SeedEntryInput[] = scanForEntries(dir); // your logic
await ctx.commit(entries); // host merges + refreshes
ctx.close();
}
return (
<Stack gap={12}>
<Button variant="primary" onClick={go}>Import from folder…</Button>
</Stack>
);
});
The manifest sets "kind": "importer" and
"ui": { "importPanel": "dist/ui/Import.js" }; no
entry is needed.
UI panels
A panel is a React component that the host mounts in its export or import drawer. It runs on
the host's exact React instance and renders with the app's design-system primitives
(@ldlework/toybox-sdk/ui), so it looks native and hooks work across the boundary.
Each panel receives a single typed ctx prop.
Export panel — ExportPanelCtx
An export panel collects config and pushes it up; it does not write files. The
host runs the exporter's run later with whatever config the panel
reported.
host: SlotHost shared: { targetDir, preserveStructure } setConfig(values, ready): void import { defineExportPanel, type ExportPanelCtx } from "@ldlework/toybox-sdk";
import { Stack, TextInput } from "@ldlework/toybox-sdk/ui";
import { useEffect, useState } from "react";
export default defineExportPanel(function Panel({ ctx }: { ctx: ExportPanelCtx }) {
const [subDir, setSubDir] = useState("exported");
useEffect(() => ctx.setConfig({ subDir }, subDir.length > 0), [ctx, subDir]);
return (
<Stack gap={12}>
<TextInput value={subDir} onChange={(e) => setSubDir(e.currentTarget.value)} />
</Stack>
);
}); Import panel — ImportPanelCtx
host: SlotHost commit(entries): Promise<void> close(): void The render-time host — SlotHost
Both panel contexts expose the same narrow host: reads and pickers
only. The gated write/export primitives are deliberately absent during render — they fire at
run/commit, host-driven.
getSelectedAssets(): AssetView[] getAsset(id): AssetView | undefined pickDirectory(): Promise<string | null> pickSaveFile(defaultName): Promise<string | null> Declarative fields (no-code path)
If a plugin needs only simple inputs, it can skip a custom panel and declare
fields in the manifest. The host renders each with the matching
primitive and passes the collected values into config.
"fields": [
{ "key": "subDir", "type": "text", "label": "Subfolder", "default": "exported" },
{ "key": "merge", "type": "checkbox", "label": "Merge into one file", "default": true },
{ "key": "target", "type": "directory", "label": "Output folder", "required": true }
]
Field types: text, checkbox,
select, directory,
saveFile. A custom panel always wins over fields when both are present.
Host API reference
ctx.host (an HostApi) is what an exporter's
run uses to read the catalog and invoke the host's native glTF/GLB
primitives. The primitive half is gated by the rustExport permission;
calling a gated method without the grant throws.
getSelectedAssets(): AssetView[] getAsset(id): AssetView | undefined getParsedGltf(id): Promise<unknown> getAssetUrls(id): Promise<{ gltf, bin, textures }> assembleGlb(id): Promise<Uint8Array> rustExport performCopy(id, targetDir, stem, preserveStructure): Promise<string[]> rustExport transcodeImage(fileName, bytes): Promise<{ mime, bytes }> rustExport placerMerge(libraryJsonPath, subDirRes, assets): Promise<void> rustExport Filesystem API
ctx.fs (an FsApi) is the jailed write
surface. Writes go through an authorizedRoot — the user-picked target
directory or save-file the run is scoped to — and any path escaping that root is rejected by the
host. Writes are gated by the fsWrite permission.
writeBytes(authorizedRoot, path, bytes): Promise<void> fsWrite writeText(authorizedRoot, path, text): Promise<void> fsWrite writeJson(authorizedRoot, path, value): Promise<void> fsWrite pickDirectory(): Promise<string | null> pickSaveFile(defaultName): Promise<string | null> Reports
ctx.report accumulates the run's outcome. Record each written file,
skip, or warning as you go, then return report.done() — the host shows
the summary.
write(path): void skip(message): void warn(message): void done(): ExportReport Asset & data types
The read-only view of an asset a plugin sees:
interface AssetView {
id: string;
name: string;
fileName: string;
relPath: string;
pack: string;
category: string;
fileset: { gltf: string; bin: string; textures: string[] };
user: { favorite: boolean; tags: string[] };
animation: { clipCount: number; clipNames: string[] };
} What an importer produces, and an export run returns:
interface SeedEntryInput { id: string; pack: string; category: string; file: string; }
interface ExportReport { written: string[]; skipped: string[]; warnings: string[]; } Permissions & security
The manifest's permissions gate the host API. A method whose
capability isn't granted is replaced with a stub that throws — so the manifest is the
advisory boundary.
rustExport fsWrite fsRead
The real boundary is enforced in the host's Rust layer: every write is
path-jailed to the run's authorized root and rejected if it escapes (no
.., no absolute path outside the root), and plugin source is served
from a protocol jailed to the plugins directory. A plugin cannot write outside the folder the
user picked for the run, regardless of what its manifest claims.
How loading works
Plugin modules load from a custom plugin:// protocol origin served by
the host (jailed to the plugins directory). Because it's a real origin — not a
blob: URL — it's subject to the document's import map, which is the
mechanism that lets your bare imports resolve to the host's shared chunks:
import * as THREE from "three"; // → the host's one three instance
import { useState } from "react"; // → the host's one React
import { defineExporter } from "@ldlework/toybox-sdk"; // → the host's SDK
import { Button } from "@ldlework/toybox-sdk/ui"; // → the host's design system
So you never bundle three, React, or the SDK into your plugin — you
import them by bare specifier and the host supplies the single shared instance. This is what
keeps r3f's instanceof checks working and React hooks valid across the
app/plugin boundary.
Developing a plugin
-
Create a folder with a
manifest.jsonand a TypeScript entry. Install@ldlework/toybox-sdkfor types. -
Build your entry (and any panel) to the
dist/paths your manifest points at. Keep bare imports — do not bundlethree, React, or the SDK. -
Drop the folder into the app's
plugins/directory (or, in a repo checkout, link it in). The app discovers it at startup; a load failure shows in the drawer with its reason rather than breaking the app. - Open the export or import drawer to exercise it. Iterate; reload to pick up a rebuild.
The bundled Godot asset_placer exporter is the reference implementation — a full exporter with a custom panel — and the best worked example to read alongside this page.