Writing a plugin
A plugin is a folder on your disk. You edit it in your own IDE, it runs in the editor with no build step, and you publish it when it is ready.
Getting started
In the Plugins panel, press Create. Studio asks for a folder and writes a template into it:
my-plugin/
meta.json the manifest
main.html your panel's markup
main.js your code — .ts, .tsx and .jsx work too
plugin.d.ts generated types for the whole API
tsconfig.json so your IDE resolves those types
.wasignore what not to upload when publishing
Edit the files, press Reload from disk, and your changes are live. No bundler, no install step.
.ts, .tsx and .jsx are compiled as they are readYou can write TypeScript and JSX directly. Types come from the generated plugin.d.ts, so your
editor autocompletes the whole API.
The manifest
{
"id": "acme.shape-spawner",
"name": "Shape Spawner",
"version": "1.0.0",
"entry": "main.tsx",
"permissions": ["scene:read", "scene:write"],
"panels": [{ "id": "main", "title": "Shapes", "entry": "main.tsx" }],
"icon": "data:image/webp;base64,…"
}
The icon is a data URL rather than a file path, so a local folder and a published plugin look identical everywhere the plugin appears — the toolbar, the panel header, the library card. The publish form will generate it for you from any image.
Your panel
The simplest panel is an HTML file and a script:
<div id="root">Loading…</div>
<script type="module">
import { signal } from '@was/signals';
const clicks = signal(0);
init(async () => {
// editor, world and meta are ready here
document.getElementById('root').textContent = `${meta.name} — ${world.getEntities().length} entities`;
});
</script>
Or skip the HTML entirely and point entry at a .tsx file — the runtime mounts its default
export and you can build the panel from the editor's own component library:
// main.tsx
import { useState } from 'react';
import { Button } from '@was/ui';
export default function Panel() {
const [clicks, setClicks] = useState(0);
return <Button onClick={() => setClicks(clicks + 1)}>{clicks}</Button>;
}
init() waits until the world is really thereIt runs once the connection to the editor is open and the synced copy of the scene is ready, so you never have to poll for either.
Four globals are always available: editor (the editor API), world (a live, synced
copy of the scene as entities and components), meta (your manifest) and init.
You can import @was/ecs, @was/signals, @was/engine, @was/editor-api, @was/ui,
@was/icons, react and react-dom/client. Anything else fails loudly rather than silently
resolving to nothing.
Panels built from @was/ui use a prebuilt stylesheet, so only the classes it ships exist.
Arbitrary values like text-[13px] are not among them — use an inline style for sizes outside
the scale.
Working with the scene
You could assemble entities by hand through world, but for scenes and spaces there is a
better way — ask the editor, and you get its own defaults, numbering and safeguards:
await editor.scenes.list();
await editor.scenes.create({ name: 'Chapter 2', trigger: { type: 'image', imageId } });
await editor.scenes.setTrigger(sceneId, { type: 'surface', bindingType: 'wall' });
await editor.scenes.delete(sceneId);
await editor.spaces.create({ name: 'Lobby' });
The trigger is just an anchor's data, validated by the same schema the editor uses — so new
trigger types work without the plugin protocol changing. An image trigger works out its physical
size from the image itself if you do not give one.
Finding your own work again
A plugin that generates scenes needs to find them later. That is what tags is for — it is
namespaced to your plugin automatically, so two plugins never overwrite each other's marks, and
the editor neither shows nor touches them:
tags.set(entity, { kind: 'scene', card: '2' });
tags.find({ kind: 'scene' });
tags.remove(entity, 'card');
Shared session state
Some state belongs to the meeting, not the document: a running timer, an open vote, a raised hand. Undoing it, publishing it or storing it in the project would all be wrong.
The project room is a small shared key-value store that everyone currently in the project sees:
const { now, entries } = await editor.room.get();
await editor.room.set('timer', { running: true, endsAt: now + 60_000 });
editor.on('room.changed', (state) => render(state.entries.timer));
await editor.room.delete('timer');
It survives a page reload, expires after twelve hours, and holds up to 64 keys of 8 KB.
now is the server's clock, and that is the pointTwo people's computers can disagree by minutes. Store absolute end times from the server clock and let each client count down itself — never store "seconds remaining", or a ticking timer would mean writing to the room every second for everyone in the project.
The server also records who wrote each key, which is what makes honest voting possible: a
vote counts only when the key vote/<userId> was actually written by that user.
The viewport overlay
A panel is private and can be closed, which is no good for something everyone should see. A
panel declared with "surface": "hud" is drawn as a small overlay on top of the scene
instead:
editor.hud.set({ visible: true, width: 240, height: 96 });
editor.panels.open('main'); // an overlay can summon its own panel
It starts hidden and shows itself when it has something to show — an invisible transparent frame over the scene would swallow clicks. Size is capped at 640×400.
It is up for everyone, all the time, so it is deliberately cheap: whatever it needs to display, the panel puts into the room.
Publishing
The publish form takes an icon, a name, up to 12 tags, a description up to 500 characters, up to 4 screenshots, and a subscription-only flag.
Limits: 512 KB per file, 64 files, 50 plugins per account.
Publishing again updates the same entry. You can also download a published plugin for editing, which writes its files back into a folder you pick.
A published plugin's files are served properly, so ./icon.png resolves. A local development
folder only inlines its .ts, .js and .json — images referenced by path will not appear
until you publish.
Permissions
The manifest lists what your plugin needs — scene:read, scene:write, resources:write,
spaces:write, collaboration:read, collaboration:write, editor:panels and others — and
the server rejects unknown ones.
Permissions are what reviewers read, and what people judge your plugin by. Ask for the narrowest set that does the job.
And when installing someone else's plugin, assume it can reach the whole project — only install what you have reason to trust. → Trust and verification
Next: Component extensions