Reference

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:

bash
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.

<app-data>/plugins/ text
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.

manifest.json json
{
  "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
Reverse-DNS identifier. MUST equal the folder name, or the plugin is skipped.
kind: "exporter" | "importer"
Which contract the plugin fulfils.
entry: string
Path (relative to the plugin folder) of the exporter's entry module. Required for exporters; importers are panel-only and may omit it.
permissions: {…}
Capability flags gating the host API — see Permissions below.
fields?: FieldSpec[]
Optional declarative config inputs (the zero-code UI path). Ignored when a UI panel is present.
ui?: { exportPanel?, importPanel? }
Paths to plugin-shipped React panel modules. When present, the host mounts the panel instead of rendering fields.

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.

dist/index.js (authored as index.ts) ts
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.

ts
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.

dist/ui/Import.js (authored as Import.tsx) ts
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
Render-time reads + OS pickers (see below). The narrow read subset — write primitives fire later, in run.
shared: { targetDir, preserveStructure }
The host-level export inputs every mode shares.
setConfig(values, ready): void
Report the panel's collected config and whether it's valid. Call it whenever inputs change; the host's Export button enables on ready.
tsx
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
Same render-time read + picker surface as the export panel.
commit(entries): Promise<void>
Hand SeedEntryInput[] to the host. It merges them into the catalog and refreshes the grid. Rejects on failure — handle it.
close(): void
Close the import drawer (typically after a successful commit).

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[]
The currently selected catalog assets.
getAsset(id): AssetView | undefined
Look up one asset by id.
pickDirectory(): Promise<string | null>
Open the OS folder picker; null if cancelled.
pickSaveFile(defaultName): Promise<string | null>
Open the OS save-file dialog; null if cancelled.

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.

json
"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[]
The live selection (same as the assets argument to run).
getAsset(id): AssetView | undefined
Look up one asset by id.
getParsedGltf(id): Promise<unknown>
The asset's parsed glTF JSON document, for inspection or transformation.
getAssetUrls(id): Promise<{ gltf, bin, textures }>
Loadable URLs for the asset's files (e.g. to fetch bytes in JS).
assembleGlb(id): Promise<Uint8Array> rustExport
Bake the asset into a single self-contained binary .glb.
performCopy(id, targetDir, stem, preserveStructure): Promise<string[]> rustExport
Write a loose copy (.gltf + .bin + textures) under targetDir; returns the relative paths written.
transcodeImage(fileName, bytes): Promise<{ mime, bytes }> rustExport
Transcode an image through the host (e.g. normalize texture formats).
placerMerge(libraryJsonPath, subDirRes, assets): Promise<void> rustExport
Godot-specific: create or merge an asset_library.json for the asset_placer addon.

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
Write binary data at path (relative to or inside authorizedRoot).
writeText(authorizedRoot, path, text): Promise<void> fsWrite
Write a UTF-8 text file.
writeJson(authorizedRoot, path, value): Promise<void> fsWrite
Serialize value as pretty JSON and write it.
pickDirectory(): Promise<string | null>
Open the OS folder picker.
pickSaveFile(defaultName): Promise<string | null>
Open the OS save-file dialog.

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
Record a file you wrote.
skip(message): void
Record something intentionally skipped.
warn(message): void
Record a non-fatal warning.
done(): ExportReport
Finalize and return { written, skipped, warnings }.

Asset & data types

The read-only view of an asset a plugin sees:

ts
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:

ts
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
Allows the native glTF/GLB primitives on ctx.host (assembleGlb, performCopy, transcodeImage, placerMerge).
fsWrite
Allows ctx.fs.write* (always jailed to the run's authorized root).
fsRead
Allows reads through the host.

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:

ts
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

  1. Create a folder with a manifest.json and a TypeScript entry. Install @ldlework/toybox-sdk for types.
  2. Build your entry (and any panel) to the dist/ paths your manifest points at. Keep bare imports — do not bundle three, React, or the SDK.
  3. 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.
  4. 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.