---
title: "Building a plugin"
description: "A plugin is a WebAssembly component that a world calls the way it calls a module."
section: "Core"
slug: "core-plugin-development"
canonical: "https://origozero.ai/docs/core-plugin-development"
updated: "2026-09-30T00:45:53.085659937+00:00"
tags: ["documentation", "guide"]
---

# Building a plugin

Everything here is built from two things: the interface file
`zero-plugin.wit`, which ships beside the `plugin` asset type, and crates
from crates.io. You need nothing else from the engine.

## What you build

One WebAssembly **component** (the component model, not a core module) that
exports the `zero:plugin/plugin` interface of the `zero-plugin` world:

| Export | When the engine calls it |
|---|---|
| `manifest() -> plugin-manifest` | once, when the plugin loads: what it is and what it offers |
| `init(config-json) -> result` | once, after `manifest`; an error stops the load |
| `call(plugin-call) -> plugin-result` | every time code in the world calls one of its methods |
| `tick(delta-secs) -> tick-result` | once a frame, with the seconds since the last one |
| `shutdown()` | once, before it is replaced or stopped |

Every export is `async`, and so is every import that does I/O.

## Getting the interface

Read `zero-plugin.wit` out of the engine and save it into your project as
`wit/zero-plugin.wit`:

```luau
local wit = asset.resolve("@builtin::assetTypes.plugin")
print(vfs.read(wit.path .. "/zero-plugin.wit"))
```

## A complete plugin

A counter that keeps its total in the plugin's own store. Build it as it is,
then change it.

`Cargo.toml`:

```toml
[package]
name = "counter"
version = "0.1.0"
edition = "2024"

[lib]
crate-type = ["cdylib"]

[dependencies]
wit-bindgen = { version = "0.54", default-features = false, features = ["async", "macros"] }
serde_json = "1"

[profile.release]
opt-level = "s"
lto = true
strip = true
```

Every function in `zero-plugin.wit` is `async`, and `wit-bindgen` before 0.54
cannot read it. 0.54 and 0.62 are both known to build it.

`src/lib.rs`:

```rust
wit_bindgen::generate!({
    world: "zero-plugin",
    path: "wit/zero-plugin.wit",
    generate_all,
});

use exports::zero::plugin::plugin::Guest;
use zero::plugin::types::{
    ArgDescriptor, MethodDescriptor, NamespaceDescriptor, OutboundEvent, PluginCall,
    PluginManifest, PluginResult, ReturnDescriptor, TickResult,
};
use zero::plugin::{zero_log, zero_storage};

struct Counter;

fn answer(value: serde_json::Value) -> PluginResult {
    PluginResult { value_json: value.to_string(), byte_slots: Vec::new(), error: None }
}

fn refuse(why: &str) -> PluginResult {
    PluginResult { value_json: String::new(), byte_slots: Vec::new(), error: Some(why.to_string()) }
}

async fn total() -> i64 {
    zero_storage::get("total".to_string())
        .await
        .and_then(|text| text.parse().ok())
        .unwrap_or(0)
}

impl Guest for Counter {
    async fn manifest() -> PluginManifest {
        PluginManifest {
            name: "counter".into(),
            version: "0.1.0".into(),
            description: "Counts, and remembers the count".into(),
            namespaces: vec![NamespaceDescriptor {
                name: "counter".into(),
                methods: vec![
                    MethodDescriptor {
                        name: "add".into(),
                        signature: "counter.add(amount: number) -> number".into(),
                        description: "Add to the count and answer the new total".into(),
                        args: vec![ArgDescriptor {
                            name: "amount".into(),
                            arg_type: "number".into(),
                            description: "How much to add".into(),
                            optional: false,
                        }],
                        returns: Some(ReturnDescriptor {
                            return_type: "number".into(),
                            description: "The new total".into(),
                        }),
                    },
                    MethodDescriptor {
                        name: "total".into(),
                        signature: "counter.total() -> number".into(),
                        description: "The count so far".into(),
                        args: vec![],
                        returns: Some(ReturnDescriptor {
                            return_type: "number".into(),
                            description: "The total".into(),
                        }),
                    },
                ],
            }],
        }
    }

    async fn init(_config_json: String) -> Result<(), String> {
        zero_log::log(zero::plugin::types::LogLevel::Info, "counter ready");
        Ok(())
    }

    async fn call(request: PluginCall) -> PluginResult {
        let args: Vec<serde_json::Value> = serde_json::from_str(&request.args_json).unwrap_or_default();
        match request.method.as_str() {
            "add" => {
                let Some(amount) = args.first().and_then(serde_json::Value::as_i64) else {
                    return refuse("add needs a whole number");
                };
                let next = total().await + amount;
                zero_storage::set("total".to_string(), next.to_string()).await;
                answer(next.into())
            }
            "total" => answer(total().await.into()),
            other => refuse(&format!("counter has no method '{other}'")),
        }
    }

    async fn tick(_delta_secs: f32) -> TickResult {
        TickResult { events: vec![] }
    }

    async fn shutdown() {}
}

export!(Counter);
```

Where the pieces are, because the generated paths are not obvious:

- The trait you implement is `exports::zero::plugin::plugin::Guest`: the
  world exports a named interface, `plugin`.
- The records are in `zero::plugin::types`: `PluginManifest`,
  `NamespaceDescriptor`, `MethodDescriptor`, `ArgDescriptor`,
  `ReturnDescriptor`, `PluginCall`, `PluginResult`, `ByteSlot`, `TickResult`,
  `OutboundEvent`, `LogLevel`.
- The imports are modules under `zero::plugin`: `zero_log`, `zero_storage`.
- The bindings cover every interface in the WIT, but a component imports only
  what its code calls. Calling one a world plugin may not use is what makes it
  refuse to load.

Build it:

```sh
rustup target add wasm32-wasip2
cargo build --release --target wasm32-wasip2
```

The component is `target/wasm32-wasip2/release/counter.wasm`.

## Putting it in a world

1. Write `counter.wasm` into the world's files, by any upload or from Luau
   with `vfs.write("/zero/source/counter.wasm", bytes)`. It becomes the asset
   `counter.plugin/`, with the component inside as `plugin.wasm`, a
   `plugin.yaml`, a `surface.module/` and a README, and it loads. The asset's
   name, `counter`, is what code in the world calls it by.
2. Nothing is callable yet. Edit `plugin.yaml` to list the methods:

   ```yaml
   profiles:
     - editor
     - runtime
   storage: private
   exposed:
     - name: add
     - name: total
   ```

   The plugin reloads with them. A listed method the component does not
   declare is refused at load, naming it. Left out, `storage` is `none`,
   `events` is `private` and `exposed` is empty; `profiles` is required.
3. Give the surface a function per method. It is the file
   `surface.module/init.luau`:

   ```luau
   local self = asset.containing(__FILE__)
   local me = self.modules.shared.forAsset(self)

   local M = {}

   function M.add(amount: number)
       return me.call("add", amount)
   end

   function M.total()
       return me.call("total")
   end

   return M
   ```

   `me.call` calls the plugin this module belongs to.

4. Call it from anywhere in the world:

   ```luau
   local counter = require("counter")
   print(await(counter.add(2)))   -- 2
   print(await(counter.total()))  -- 2
   ```

## The manifest

`plugin.yaml` may say how much memory the plugin may hold: `memory: 512MiB`
(`KiB`, `MiB` or `GiB`, up to `4GiB`, the default). The limit is enforced on
both hosts: on the desktop by the engine's runtime, in a browser by compiling
your component with its memory capped. Past it your component stops, and the
log and every waiting call say it went past its `memory:`. A plugin that loads
a model declares enough for the model.

- Declare **one** namespace. A caller names your plugin's asset, and every
  call reaches that namespace; its name comes back to you in
  `plugin-call.namespace`. A component that declares none, or several, does
  not load.
- The namespace name must be a Luau identifier (letters, digits and `_`, not
  starting with a digit) and not the name of one of the engine's own
  bindings, such as `vfs`. A method may be named anything except `onEvent` and
  `offEvent`.
- The engine reads each method's `name`. `signature`, `description`,
  `arg-type`, `return-type` and the argument descriptions are text for people
  and are not parsed. The types a caller sees are the ones you write in
  `surface.module`.
- `name`, `version` and `description` describe the component.

## Calls and answers

A call from Luau arrives as a `plugin-call`:

- `method` is the method's name.
- `args-json` is a JSON array with one entry per argument, in order. A call
  with no arguments sends `[]`.
  - A whole number arrives as a JSON integer (`2`, not `2.0`), up to 2^53 in
    magnitude, where Luau numbers stop being exact; beyond that it arrives as
    a JSON float. Any other number is a JSON number. A number that is not
    finite (`NaN`, `inf`), anywhere in the arguments, is refused before the
    call is sent, naming the argument.
  - A string arrives as a string, whole, however long. It has to be text:
    a string that is not UTF-8, or that holds a NUL byte, is refused naming
    the argument. Send bytes as a `buffer`.
  - A `buffer` arrives as bytes, whole, wherever it sits: its place in
    `args-json` holds `null`, and `byte-slots` carries the bytes with a JSON
    pointer (RFC 6901) to that place. `/0` is the first argument, `/1/data`
    the `data` field of the second. A table shaped like a slot is only a
    table: nothing in `args-json` is reserved.
  - A handle (`vfs.open`, `vfs.openDir`, `vfs.sink`, `vfs.dirSink`) arrives
    as a resource, the same way: its place holds `null`, and `handle-slots`
    carries it with a pointer to that place. See "Files and folders".
  - A boolean arrives as a boolean.
  - `nil` arrives as `null`.
  - A table with keys `1..n` and nothing else arrives as an array (an empty
    table as `[]`); any other table as an object with string keys. A table
    of more than 262,144 items or 4,096 keys, a value nested more than 64
    deep, and a function or coroutine are refused, naming the argument.

Answer with a `plugin-result`:

- To answer, set `value-json` to one JSON value and `error` to `none`. The
  caller receives it as a Luau value: an object as a table, an array as an
  array-like table, `null` as `nil`.
- To answer with bytes, put `null` where they belong in `value-json` and a
  `byte-slot` in `byte-slots` whose pointer names that place (`""` when the
  whole answer is bytes). The caller receives a `buffer` there. A slot that
  names a place the answer does not have, or a place holding anything but
  `null`, is refused rather than dropped.
- To refuse, set `error` to a message. The caller's `await` raises with it,
  and `pcall` catches it like any other error. `value-json` is ignored.
- An answer's strings and buffers arrive whole up to 256 MiB each; an answer with a
  longer string, an array of more than 262,144 items or an object of more
  than 4,096 keys is refused, saying where, rather than delivered cut.
- A panic in your component is a trap: see "When it crashes" below. A
  component is 32-bit, so a size read from its input and trusted as it is
  (an allocation of `len` bytes where `len` came from the data) can overflow
  and panic there even when it passed on a 64-bit build. Check a length
  against the input before allocating for it.

## What a plugin may use

A plugin in a world is linked against three of the engine's interfaces:

- **`zero-log`**: `log(level, message)` writes a line to the engine's log,
  under your plugin's name. Anything written to stdout or stderr goes there
  too.
- **`zero-storage`**: a key/value store of strings. `get(key)`,
  `set(key, value)`, `delete(key)`, `list-keys(prefix)`. With
  `storage: private` in `plugin.yaml` it is kept for this asset in this world
  and survives a restart; a `set` that cannot be saved traps the call rather
  than answering as if it had. With `storage: none` it lives in memory until
  the plugin is reloaded. No size limit is enforced, and the whole store is
  written again on every `set`, so keep it small. It is kept on the machine
  running the engine: a player has their own.
- **`zero-handles`**: the files, folders and entities a script passes you in
  a call. See "Files and folders" and "Entities".

The other interfaces in `zero-plugin.wit` belong to the engine's own plugins.
A component in a world that imports one of them does not load, and the
refusal names the import.

From WASI it gets what Rust's standard library imports: clocks, random
numbers, stdout and stderr (to the engine's log), an empty stdin and
environment, `exit`, and the I/O streams those use. It has **no filesystem**
(no directory is opened for it) and **no network**: a component that imports
any `wasi:sockets` interface does not load, in any world. Anything it keeps,
it keeps in `zero-storage`; a file it reads or writes, a script hands it.

It sets no globals and sees none. Code reaches it only through the methods
its `plugin.yaml` lists.

## Files and folders

A plugin cannot name a path. A script that wants it to read or write a file
passes it a handle, which carries what that script could do there, fixed when
the script made it. The plugin can do nothing more with it, and can reach
nothing else through it.

| Luau | The plugin gets | What it can do |
|---|---|---|
| `vfs.open(path)` | `reader` | `size()`, `read(offset, len)` |
| `vfs.openDir(path)` | `dir-reader` | `entries(path)`, `open(path)` for a `reader` |
| `vfs.sink(path, { maxBytes = n })` | `byte-sink` | `write(bytes)`, `close()` |
| `vfs.dirSink(path, { maxFiles = n, maxBytes = n, extensions = { ... } })` | `dir-sink` | `create(path)` for a `byte-sink` |

Take one with `request.take_reader(i)`, `take_dir_reader(i)`, `take_sink(i)`
or `take_dir_sink(i)`, where `i` is the argument it was passed as (from 0), or
`take_handle_at(pointer)` for one inside a table. Asking for the wrong kind
answers `None` and leaves the handle where it is.

```rust
"load" => {
    let Some(weights) = request.take_reader(0) else {
        return PluginResult::err("load needs a reader of the weights");
    };
    let size = match weights.size().await {
        Ok(size) => size,
        Err(why) => return PluginResult::err(&why),
    };
    let mut offset = 0;
    while offset < size {
        let chunk = match weights.read(offset, 4 << 20).await {
            Ok(chunk) => chunk,
            Err(why) => return PluginResult::err(&why),
        };
        offset += chunk.len() as u64;
        // ... use the chunk
    }
    PluginResult::ok_json(&size)
}
```

- **A reader** reads its file by range. A read that reaches the end answers
  the bytes that are there, and a read past the end answers none. A reader
  whose file leaves its path (removed, or another asset put there) answers
  `the asset is gone` and nothing else.
- **A folder reader** reads everything under its folder. `entries("")` lists
  the folder itself, `entries("cfg")` a folder inside it; `open` answers a
  reader. Paths are relative and `/`-separated: `..`, an absolute path, a
  drive or a backslash is refused.
- **A sink** writes its file from empty, up to `maxBytes`. A write that would
  go past it is refused, and what was written before stays. The file appears
  when you call `close()` or let the sink go; until then nothing sees it. If
  the plugin stops or traps first, nothing appears.
- **A folder sink** makes new files under its folder, subfolders included,
  within `maxFiles` files and `maxBytes` bytes together, and only with an
  extension from `extensions` when the script gave any. The path rules are a
  folder reader's.
- Only the world's own files take a sink. A script cannot make one for a
  library, the engine's trusted files or the engine's bookkeeping.
- You may keep a handle across calls, in a `thread_local!` or a field. It
  works until you drop it or the plugin stops.
- A handle goes one way, from a script to a plugin. An answer cannot carry
  one.
- In Luau a handle prints its kind and path. It cannot be built from a table,
  and nothing can be read out of it.

## Entities

A script that wants a plugin to move entities binds them and passes the
binding: `local bones = ecs.bindEntities(ids)`, then
`me.call("animate", bones:forPlugin())`. Take it with
`request.take_entities(i)`. The plugin never learns an entity's id; it names a
component field, and the values come packed in binding order: one float per
entity for a number field, three for a vec3, four for a quat.

```rust
"animate" => {
    let Some(bones) = request.take_entities(0) else {
        return PluginResult::err("animate needs the bones, bound");
    };
    let result = async {
        let mut positions = bones.read("Transform".into(), "translation".into()).await?;
        for p in positions.chunks_exact_mut(3) {
            p[1] += 0.1;
        }
        bones.write("Transform".into(), "translation".into(), positions).await
    };
    match result.await {
        Ok(written) => PluginResult::ok_json(&written.written),
        Err(why) => PluginResult::err(&why),
    }
}
```

- `len()` is how many entities the binding holds.
- `read(component, field)` is a snapshot taken at the frame point after the
  call.
- `write(component, field, values)` lands at the frame point, before
  skinning, so a pose written this frame is drawn this frame. `values` must be
  the binding's length times the field's width; any other length is refused
  and nothing is written.
- A write answers to the rules the script's own `entity.batchWrite` does: an
  entity whose component is locked, one another peer owns, or one outside a
  build the script has open is left as it was. The answer says how many it
  wrote and, for each it skipped, its place in the binding and why.
- Once the script destroys the binding, every operation on it answers that it
  is gone.

## GPU buffers and compute shaders

A script hands a plugin GPU work the same way: a buffer it made with
`substrate.createBuffer({ kind = "gpu", ... })` as `buf:forPlugin()`, and a
compute shader asset as `shaderRef:forPlugin()`. Take them with
`request.take_gpu_buffer(i)` and `request.take_compute_shader(i)`. A buffer is
floats, however the script typed it: a `vec3` buffer of `n` is `3n` floats.

```rust
"step" => {
    let (Some(step), Some(field)) = (request.take_compute_shader(0), request.take_gpu_buffer(1)) else {
        return PluginResult::err("step needs the shader and the field");
    };
    let result = async {
        field.write(0, initial).await?;
        step.dispatch(vec![&field], 16, 1, 1).await?;
        field.read(0, field.len().await?).await
    };
    match result.await {
        Ok(values) => PluginResult::ok_json(&values.len()),
        Err(why) => PluginResult::err(&why),
    }
}
```

- `len()` is how many floats the buffer holds.
- `write(offset, values)` is queued at the frame point, behind everything
  queued before it, so a dispatch asked for after the write sees it. A write
  that runs past the end is refused and nothing is written.
- `read(offset, len)` answers once the GPU has delivered the floats, a frame
  or more after the call, and holds what every write and dispatch queued
  before it left there. A read past the end is refused.
- `dispatch(buffers, x, y, z)` runs the shader over `x` by `y` by `z`
  workgroups, with `buffers` bound to its storage bindings in the order its
  `bindings.yaml` declares them. The wrong number of buffers, a destroyed
  buffer, or a zero dimension is refused and nothing is dispatched. A shader
  that binds a texture or a sampler cannot be passed to a plugin at all:
  `shaderRef:forPlugin()` refuses it.
- The plugin never learns a buffer's name or a shader's key. A handle holds
  its buffer, not the name: once the script destroys the buffer, the handle
  answers that it is gone, even if another buffer takes the name.

## Events

`tick` is called about every 16 ms (60 times a second), in the editor and in
play, one at a time, with the seconds since the last one. Return quickly: a
tick that awaits a long operation delays the next one. Calls are not held
back by it: a call can run while a tick, or another call, awaits.

To tell listeners something, return events from `tick`:

```rust
TickResult {
    events: vec![OutboundEvent {
        namespace: "counter".into(),        // your one namespace
        event_type: "changed".into(),
        payload_json: r#"{"total":3}"#.into(),
    }],
}
```

- `namespace` must be your plugin's namespace; an event on any other is
  dropped with a warning.
- `payload-json` must be JSON; a payload that is not is dropped with a
  warning. Listeners receive it as a Luau value.
- Code in the world hears them only when `plugin.yaml` says
  `events: exposed`. The surface module listens with
  `me.on(function(eventType, payload) ... end)`, which waits for the plugin
  to load and returns nothing; to let other code listen, give the surface a
  function that calls it.

## `init`

`init` receives a JSON object describing the engine the plugin runs in, for
plugins that run an agent (`orientation`, `skill`, `identity`: text). A
plugin that has no use for it ignores it. Return `Err(message)` to refuse to
start; the load fails with that message.

## While it runs

- **It never holds up the engine.** Your component runs on a thread of its
  own (a worker of its own in a browser). A call that computes for seconds
  costs your plugin's thread, not the frame: the script that called it waits
  for the answer, and the world goes on around it.
- **Your calls and ticks share that one thread.** On the desktop a call that
  computes is paused every few milliseconds so your other calls and ticks run
  beside it. In a browser a worker cannot pause running code, so while one
  call computes without yielding, your other calls and ticks wait for it.
- **A new build, an edit or removing the asset stops a call in progress.**
  The call answers that the plugin was stopped, however long it would have
  run; nothing waits on it.
- **A new build replaces it in place.** Upload the component again under the
  same name: the running instance gets `shutdown`, and the new one is loaded,
  with no restart. Its private store is kept.
- **The same bytes change nothing.** Uploading an identical component is not
  a reload.
- **Editing `plugin.yaml` reloads it.**
- **Removing the asset stops it.**
- **`plugins.status()`** lists every plugin: whether it is loaded, why not,
  and its component: SHA-256, size, and what built it.

### When it crashes

A trap (a panic, an out-of-bounds access, `unreachable`) stops the plugin at
once. The log says so in one line, `plugins.status()` shows
`stopped: <reason>`, and every later call raises that it stopped and why. It
stays stopped until it loads again: a new build, or an edit to
`plugin.yaml`.

## Saying what built it

The engine states, at load and in `plugins.status()`, what built a
component. Put a `zero-build` custom section in yours: `key=value` lines,
`name`, `version`, `source`, `revision`, `built`. Each value is text; one left
empty is left out. `#[link_section]` puts the section inside the component's
core module, and the engine reads it there.

```rust
const BUILD_RECORD: &str = concat!(
    "name=", env!("CARGO_PKG_NAME"), "\n",
    "version=", env!("CARGO_PKG_VERSION"), "\n",
    "source=https://example.com/counter\n",
    "revision=", env!("ZERO_BUILD_REVISION"), "\n",
    "built=", env!("ZERO_BUILD_TIME"), "\n",
);
#[unsafe(link_section = "zero-build")]
#[used]
static BUILD: [u8; BUILD_RECORD.len()] = {
    let bytes = BUILD_RECORD.as_bytes();
    let mut out = [0u8; BUILD_RECORD.len()];
    let mut i = 0;
    while i < bytes.len() {
        out[i] = bytes[i];
        i += 1;
    }
    out
};
```

with a `build.rs` that sets the two variables:

```rust
fn main() {
    // Empty outside a git checkout, and then left out of the record.
    let revision = std::process::Command::new("git")
        .args(["rev-parse", "--short", "HEAD"])
        .output()
        .ok()
        .filter(|o| o.status.success())
        .and_then(|o| String::from_utf8(o.stdout).ok())
        .map(|s| s.trim().to_string())
        .unwrap_or_default();
    println!("cargo:rustc-env=ZERO_BUILD_REVISION={revision}");
    println!(
        "cargo:rustc-env=ZERO_BUILD_TIME={}",
        std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| d.as_secs())
            .unwrap_or(0)
    );
}
```

The section survives a stripped release build. A component without one
loads, and is said to carry no build record.

## In a browser

The same component runs in the desktop engine and in a browser, with the
same rules. In a browser:

- It needs JavaScript Promise Integration: Chrome or Edge 137 or later. In a
  browser without it every plugin load is refused, saying so.
- The component is compiled in the page the first time it loads, so a larger
  component takes longer to start.
- It runs in a worker of its own, and each operation on a handle is a message
  to the engine's worker. Read in ranges of a few MiB rather than a few bytes.

## When a load is refused

| The log says | What to change |
|---|---|
| `plugin.yaml exposes 'x', which the component does not offer` | list only methods the manifest declares |
| `the component declares no namespace` / `declares 'a', 'b'` | declare exactly one namespace |
| `declares no 'runtime' profile, so it does not load in this engine` | add the profile to `profiles` |
| `a plugin has no network of its own, and this component imports wasi:sockets/...` | remove whatever pulls in sockets |
| `a plugin in a world uses zero-log, zero-storage and zero-handles, and this component imports zero:plugin/...` | import only those three |
| `grants storage: private, and the engine's index gives the asset no guid` | the asset has no `.meta`; let the engine create it by uploading the `.wasm` |
| `its memory starts at X, past the Y its plugin.yaml allows` | the component's initial memory is larger than `memory:`; declare more |
| `init() plugin error: ...` | your `init` returned an error |
| `this component was built against zero:plugin 0.4.0 ..., and this engine speaks zero:plugin 0.5.0` | build it against the `zero-plugin.wit` this engine ships (the asset type's copy) |
