Skip to content

Plugins

Agents extend Sparklight with plugins, never with forks. A plugin adds components (with a JSON Schema), systems (with a declared order), commands (they join the control registry, so they are MCP tools with schemas), event hooks and asset importers. It is a folder with a plugin.json manifest and one Lua file that runs in the script sandbox; Rust code can be registered statically as a native plugin. Design background: docs/AGENT_FIRST.md section 3.7. Code: crates/sparklight-plugin. Schema: schemas/plugin.schema.json (spark schema plugin). Examples: plugins/health, plugins/spawner, plugins/csv_tilemap.

plugins/lucky/plugin.json
plugins/lucky/main.lua
{
"$schema": "../../schemas/plugin.schema.json",
"manifest_version": 1,
"id": "lucky",
"version": "0.1.0",
"engine": ">=0.3.0, <0.4.0",
"entry": { "lua": "main.lua" },
"provides": {
"commands": [{
"name": "roll",
"summary": "Roll a die from the runtime generator.",
"class": "read",
"input": { "type": "object", "properties": { "sides": { "type": "integer", "minimum": 2 } }, "required": ["sides"] },
"output": { "type": "object", "properties": { "roll": { "type": "integer" } }, "required": ["roll"] }
}]
}
}
function command_roll(ctx, p)
return { roll = ctx.random(p.sides) }
end
Terminal window
spark mcp --plugins plugins # loads every plugin in plugins/*/ at start; the tool `lucky_roll` exists at once
spark call plugin.load '{"path":"lucky"}' --connect 127.0.0.1:PORT # or load at run time, by folder or by id

plugin.load {path} takes a folder (relative to the project root, or absolute) that holds plugin.json, or a plugin id found in <project>/plugins/<id>/, or native:<name> for a native plugin. The result is the plugin’s summary: its commands, components, systems, hooks and importers. plugin.list shows every loaded plugin with its permissions and whether its files changed on disk.

plugin.json is validated against schemas/plugin.schema.json, then checked further. A bad manifest is E_PLUGIN_MANIFEST with path pointing at the offending JSON path (.../plugin.json#/provides/commands/0/input). Unknown fields are refused.

Field Meaning
manifest_version 1
id 2–32 characters, [a-z][a-z0-9_]*. Commands start with <id>.. A built-in namespace (world, scene, plugin, …) is E_PLUGIN_ID_RESERVED
version semantic version of the plugin
engine semantic version range of the engine, >=0.3.0, <0.4.0; the running engine is sparklight_plugin::ENGINE_VERSION. A mismatch is E_PLUGIN_ENGINE_VERSION
name, description for humans
entry {"lua": "main.lua"} (relative to the plugin folder) or {"native": "<registry name>"}; omit for a plugin that only declares components
permissions fs_read, fs_write (folders relative to the project root, no ..) and network (hosts); see Permissions
provides components, systems, commands, hooks, importers

{name, doc, schema, default?, persistent?}. name is PascalCase and global to the world (a clash with an engine component or another plugin is E_PLUGIN_CONFLICT). schema is a JSON Schema (2020-12) of the component value; every writer is validated against it, so world.set, world.spawn, scene files, ctx.set and native code all get E_COMPONENT_VALUE with the JSON path of the violation. The values live in the world’s dynamic column of that name (World::register_dynamic: a sparse column of JSON values with change ticks, listed by ecs.stats as dynamic:<name>), which snapshots, transactions, run.seek and the state hash cover like any component; data of a plugin that is unloaded stays in its column and is back when the plugin loads again. component.list, component.schema and spark://components show it. persistent: true (default) writes it into scene files and scene.load reads it back (the plugin must be loaded first).

{name, order?, after?, before?, reads?, writes?, parallel?, function?}. A system runs once per simulation step, after the built-in simulation (physics, animation, scripts, particles) and after that step’s events were queued. All systems of all plugins form one list:

  • after / before name other systems (other_plugin.system, or a bare name for the same plugin) and are hard constraints; names that are not loaded are ignored. A cycle is E_PLUGIN_SYSTEM_ORDER with data.cycle.
  • Among the systems that are ready, the lowest order (default 0) runs first, then the lowest full name. So the list never depends on the order in which plugins were loaded.

Declared access. reads and writes are lists of component names (registry names, plugin components included; writes also means read). A system that lists either promises to touch nothing else, and the host holds it to that while it runs: ctx.get, ctx.try_get, ctx.query and ctx.entities(component) need the component in reads or writes; ctx.set, ctx.patch and ctx.remove need it in writes; ctx.spawn, ctx.despawn, ctx.entities() with no component and a path that stops at the entity are refused. A violation raises E_SYSTEM_ACCESS in the system (a plugin_error event names the system and the component; the step goes on). A system with neither list is exclusive: it may touch anything, as before. schedule.explain lists plugin systems after the engine’s passes with their declared access and, for each pair, why they run one after the other (write/write, write/read, exclusive, main_thread when the declared access would have allowed an overlap but plugin systems all run on the main thread, or runtime_hook for a plugin system and a native query system of the scene whose access would allow an overlap, because plugin systems run after the step). Declaring access buys the check and the explanation; parallel (next) is what lets a plugin system run beside native ones.

Parallel systems. "parallel": true (needs reads or writes, which may name plugin components and Name, never an engine component: the manifest is refused with E_PLUGIN_MANIFEST otherwise) makes the system a main-thread query system of the plugin schedule: it works through the world’s plugin columns only, so while it runs, other threads may run native systems whose declared access is disjoint (a native system added with PluginManager::with_phase_system, or any query system of the plugin phase). It still never runs with another Lua system, and the state, the events and their order are those of the same system without the flag, for every thread count (tests/parallel_gate.rs; the profiler shows the Lua span and the native span at the same time with 2, 4 and 8 threads, and one after the other with one). The plugin phase uses as many threads as the scene (Scene::set_threads). What a parallel system’s ctx has: get, try_get, set, patch, remove, entities(component), query(component), emit, log, fail, read_file, write_file, json_encode, json_decode, array, frame, dt, elapsed, seed, plugin, null. Entities are addressed by handle (#7 or #7.2, the id of a row) or, when Name is in reads, by a name. ctx.spawn, ctx.despawn, ctx.entities() without a component and ctx.random (one generator shared by all systems) raise E_SYSTEM_ACCESS, and so does a path with an undeclared component. Events (ctx.emit, ctx.log) are handed to the runtime in the order the systems ran, before the next exclusive system and at the end of the phase. Systems without the flag behave exactly as before.

A system that fails does not stop the step: a plugin_error event reports it (code: E_SCRIPT_BUDGET, E_SCRIPT_MEMORY, E_SCRIPT_RUNTIME or the plugin’s own code) and the next system runs.

{name, summary, class?, scope?, input?, output?, errors?, function?}. The command id is <plugin id>.<name>, the MCP tool name <plugin id>_<name>. summary is the tool description. input must be an object schema; unknown parameters are refused unless the schema sets additionalProperties. The control plane validates parameters before the plugin runs (E_PARAMS_INVALID), and the host validates the result against output afterwards (E_PLUGIN_OUTPUT_INVALID), so the published schemas are truthful. class is read or write (default); scope is world (default: a write command is recorded in the replayable command log and undone by txn.rollback), session or files (needs permissions.fs_write). errors documents the codes the command returns. Commands appear in system.commands, rpc.discover, schema.get commands and tools/list of the running server, and disappear on unload.

{name, event, target?, min_level?, function?}. The function is called with the event for every event of kind event (and target, at min_level or above) that was produced while the simulation stepped, after the systems of that step ran. Events a command emits between steps are not delivered; events a hook emits are delivered at the next step. Plugin events have kind <plugin id>.<name> (ctx.emit); engine kinds are contact, animation, script_error, … (docs/agent/runtime.md).

{id, summary, extensions, output_kind?, output_extension?, function?}. Each importer becomes the command <plugin id>.import_<id> with the parameters {source, dest?}. The host checks source against fs_read and dest (default: the source path with the extension replaced by output_extension) against fs_write, reads the text, calls the function with {text, source, dest, name}, validates the returned table against the engine schema named by output_kind (tilemap, palette, clip, emitter, prefab, scene, input_map; failures are E_PLUGIN_IMPORT_FAILED with the violations) and writes it as pretty JSON, atomically. An importer needs both fs_read and fs_write.

Editor panels, declared as data (no UI code): {id, title, for_component?, widgets[]}. The editor draws a panel in its inspector column while the selected object has for_component (or always, when it is omitted; a panel that uses {selected} needs a selected object). Widgets, tagged by kind:

kind Fields What the person sees and does
label text A line of text
value label, bind The value at the world path bind, read-only
number label, bind, min?, max?, step? A draggable number bound to a path
toggle label, bind A checkbox bound to a boolean
choice label, bind, options A drop-down bound to a string
button label, command, params? Runs a command (any command of the surface, plugins’ included)
separator - A rule

bind is a world path such as /{selected}/Health/current; {selected} (also inside params strings) is the selected object. A widget edits through editor.set (one undo step per gesture) and a button runs its command, so a panel offers nothing a command does not: an agent reads the resolved panels with editor.panels and does the same with editor.set and the button’s command (editor.md). plugins/health declares one. The manifest checks ids, titles, paths starting with /, non-empty options, min <= max, and button commands.

Every entry names the function that implements it: function, default command_<name>, system_<name>, hook_<name>, import_<id>. The entry file must define them as globals, else the load fails with E_PLUGIN_FUNCTION_MISSING before anything is registered.

All functions are called as function(ctx, arg): arg is the command parameters, nil for a system, the event for a hook and {text, source, dest, name} for an importer. The return value is the command result (a table, or nothing for {}).

ctx is rebuilt for each call and stops working when the call ends. It is the plugin’s only access to the world.

Member Does
ctx.frame, ctx.dt, ctx.elapsed, ctx.seed, ctx.plugin the step the call belongs to; the plugin id
ctx.get(path) / ctx.try_get(path) value at a JSON path (/Hero/Health/current, /#7/Transform2D/position); try_get answers nil when the path or component is missing, get raises
ctx.set(path, value) / ctx.patch(path, merge_patch) / ctx.remove(path) write; validated and atomic, like world.set, world.patch, world.remove
ctx.spawn(name, {Component = value}) / ctx.despawn(entity) create (returns "#7") and remove entities (name or handle)
ctx.entities([component]) array of {id, name}, in slot order
ctx.query(component) array of {id, name, value} for every entity that has the component, in slot order
ctx.emit(name, message, fields, level) event of kind <plugin id>.<name>, target the plugin id; name is [a-z0-9_]+; level debug/info/warn/error
ctx.log(text, level) event of kind plugin_log
ctx.random([m [, n]]) the runtime’s seeded generator: random() in [0,1), random(m) in [1,m], random(m,n) in [m,n]. The snapshot holds it, so runs replay. math.random raises
ctx.read_file(path) / ctx.write_file(path, text) text files below the declared fs_read / fs_write folders; at most 8 MiB; writes are atomic
ctx.json_encode(v) / ctx.json_decode(text) JSON text conversion
ctx.array(t) / ctx.null ctx.array({}) marks a table as a JSON array (an empty table is an object otherwise); ctx.null is JSON null
ctx.fail(code, message, data) raise an error with a code of your own (E_[A-Z0-9_]+); data becomes error.data

JSON ↔ Lua: objects are tables with string keys, arrays are tables (marked, so empty ones survive), integers stay integers, null is ctx.null. A table is an array if it is marked or its keys are exactly 1..n. Functions, userdata, non-finite numbers and tables nested deeper than 64 levels cannot be JSON (an error). Object key order is sorted, so output is deterministic.

Errors. A failing ctx function raises an error whose message starts with the engine’s code (E_ENTITY_UNKNOWN: ...). Uncaught, it reaches the caller with that code; pcall catches it like any Lua error. ctx.fail and error("E_MY_CODE: text") set a code of your own. A call that exceeds its limits is E_SCRIPT_BUDGET / E_SCRIPT_MEMORY; any other Lua error is E_SCRIPT_RUNTIME.

State. Plugin globals live in the plugin’s Lua state, which snapshots do not hold. Keep everything that must replay in components (the spawner example keeps its timer in the Spawner component). To keep plain data across a hot reload define export_state() (returns data) and import_state(state) (receives it).

Lua plugins use the same sandbox as object scripts (docs/LUA_API.md, sparklight_script::SandboxLua): no io, os, package, debug, require, dofile, print; 2 million instructions per call (a pcall/coroutine cannot swallow the budget); 16 MiB of memory; a 2 s wall-clock backstop; text chunks only in load; no __gc. The host’s Policy (PluginManager::with_policy) can set other limits for all plugins.

Everything not declared is denied. The plugin cannot ask for more at run time.

Permission Meaning Enforced by
fs_read folders or files (relative to the project root, no .., no absolute paths) the plugin may read with ctx.read_file; importers may read their source there E_PLUGIN_PERMISSION_DENIED at the call, data = {plugin, permission, path}
fs_write the same for ctx.write_file and importer destinations same
network hosts the plugin asks for. Plugins get no network access: a non-empty list is refused at load with E_PLUGIN_PERMISSION_DENIED unless the host program sets Policy::allow_network the Lua API has no network functions at all

Paths are compared after resolving symbolic links, so a link inside an allowed folder cannot lead out of it; a declared folder that resolves outside the project root is refused at load. Reading the plugin’s own source is the host’s business and needs no permission. Native plugins are trusted code: the permissions govern only what the host mediates (Ctx::read_file, Ctx::write_file, Guard::network_allowed); they are not a sandbox for Rust code.

Rust code can be a plugin too. It ships a plugin.json (entry {"native": "<name>"}) and a table of functions; the host program collects its native plugins in one static registry:

fn deposit(ctx: &mut Ctx<'_>, params: &Value) -> Result<Value, ControlError> { /* ctx.runtime: the whole Runtime */ }
sparklight_plugin::native_plugin! {
pub static BANK = {
name: "bank",
manifest: include_str!("../plugin.json"),
functions: ["command_deposit" => deposit, "system_interest" => interest],
components: [make_custom_component], // optional: hand-written ComponentOps
};
}
sparklight_plugin::native_registry!(pub NATIVE = [BANK]);
let manager = PluginManager::new().with_native(NATIVE); // then manager.attach(&mut session)

A native plugin loads with plugin.load {"path": "native:bank"} (or by id) and behaves like a Lua one: same manifest, same order, same events, same unload. Functions the manifest names but the table lacks are E_PLUGIN_FUNCTION_MISSING; a manifest naming a native plugin the program does not link is E_PLUGIN_NATIVE_MISSING. A reload re-applies the manifest (the code is fixed at build time). A working example is crates/sparklight-plugin/tests/native.rs.

  • Load builds a complete candidate (manifest, code, permissions, schemas, function names), checks it against the registries (component names, command ids and tool names, system order) and only then changes anything. A plugin that fails to load changes nothing.
  • Unload removes the commands, unregisters the components, drops the systems and hooks. The component data stays in the world (dormant: invisible to the registry and the state hash) and is back when the plugin is loaded again. Snapshots taken while a plugin was loaded cannot be restored without it (the hash would differ).
  • Reload (plugin.reload {id}) re-reads plugin.json and the entry file into a new candidate, carries export_state() / import_state() data over, and swaps the new version in place (component entries keep their position). Any failure leaves the old version running: the command returns E_PLUGIN_RELOAD_FAILED with data.cause = {code, message, ...} and data.keeps_running = "<old version>", and a plugin event with level: "error" and fields.code = "E_PLUGIN_RELOAD_FAILED" is published. The id of a plugin cannot change in a reload. plugin.list shows changed_on_disk per plugin; a host that watches files calls PluginManager::changed() / reload_changed(session).
  • Events. Lifecycle events have kind: "plugin", target: "plugin", and fields.event one of loaded, unloaded, reloaded, reload_failed, load_failed, with fields.plugin. Callback failures have kind: "plugin_error" and carry the code in fields.code (and in code when it is an engine code). Subscribe with events.subscribe {kinds: ["plugin", "plugin_error"]}.
  • Transactions. plugin.load/unload/reload refuse to run inside a transaction (E_TXN_BLOCKED). A failed plugin command may have written before it failed; wrap commands in txn.begin / txn.rollback when that matters.
  • Discovery. PluginManager::discover lists <project>/plugins/*/plugin.json, extra search paths and the native registry; spark ... --plugins <folder> loads every plugin in a folder at start (failures go to stderr and the event stream).

A system or command may use only the context it is given: ctx.random (not math.random), ctx.frame/ctx.dt (not a clock), the world. Then (scene, seed, inputs) still decides the state hash, and snapshots, txn.rollback and run.seek replay plugin behaviour exactly (tested: a_plugin_system_replays_after_a_snapshot_restore). Limits: snapshot.save / snapshot.load replays the command log in a scratch session that has no plugin host, so a log that contains plugin commands does not load (tracked as a follow-up); scene.new and scene.load with a new seed create a new runtime and the host installs components and systems again.

All are control-plane errors {code, message, path?, hint?, data?}; match on code.

Code Meaning
E_PLUGIN_NOT_FOUND No plugin folder, manifest or loaded plugin matches.
E_PLUGIN_MANIFEST plugin.json is not valid: it does not match the manifest schema or breaks a manifest rule.
E_PLUGIN_ENGINE_VERSION The plugin does not support this engine version.
E_PLUGIN_ID_RESERVED The plugin id is the name of a built-in command namespace.
E_PLUGIN_DUPLICATE A plugin with this id is already loaded.
E_PLUGIN_PERMISSION_DENIED The plugin asked for, or used, something its permissions do not allow.
E_PLUGIN_LOAD_FAILED The plugin code could not be loaded (data.cause has the script error and line).
E_PLUGIN_FUNCTION_MISSING The manifest names a function the plugin code does not define.
E_PLUGIN_CONFLICT A component, command or system of the plugin clashes with an existing one.
E_PLUGIN_SYSTEM_ORDER The declared system order has a cycle.
E_PLUGIN_OUTPUT_INVALID A plugin command returned a result that does not match its declared output schema.
E_PLUGIN_RELOAD_FAILED A hot reload failed; the previous version keeps running.
E_PLUGIN_STATE export_state or import_state of a plugin failed or returned a value that is not plain data.
E_PLUGIN_IMPORT_FAILED An asset importer failed or produced a file that does not match its declared output format.
E_PLUGIN_NATIVE_MISSING The manifest names a native plugin that is not linked into this program.

Also seen: E_PLUGIN_HOST_MISSING (no host attached: embedders call PluginManager::new().attach(&mut session)), E_TXN_BLOCKED, and the engine and script codes of docs/agent/runtime.md (E_COMPONENT_VALUE, E_ENTITY_UNKNOWN, E_SCRIPT_BUDGET, …). sparklight_plugin::error_registry() lists the plugin codes as JSON.

let mut session = Session::new(config);
PluginManager::new().with_native(NATIVE).attach(&mut session); // plugin.* commands now work

The host is a sparklight_control::PluginHost. It installs one system hook on the session’s Runtime (Runtime::set_system_hook) for systems and hooks, registers components in runtime.registry, and commands in the session registry. spark attaches a host to every session; --plugins <dir> loads a folder at start.