Writing a plugin
Build your own Studio plugin: create the folder, write a panel, work with the scene and publish it.
A plugin is a folder on your computer. You edit it in your own code editor, Studio runs it with no build step, and you publish it when it is ready.
For code that runs in the published project, see Runtime code.
Create a plugin
- Open the Plugins panel.
- Press Create plugin.
- Pick a folder. Studio writes a starter 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
To see a change, save your files and press Reload. There is nothing to bundle or install.
You can write .ts, .tsx and .jsx files directly. The generated plugin.d.ts gives your
code editor autocomplete for the whole API.
The manifest
meta.json describes your plugin:
{
"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, not a file path. That way a local folder and a published plugin show
the same icon everywhere: in the toolbar, in the panel header and on the library card. The
publish form can make it for you from any image.
The manifest can add more:
| Field | What it adds |
|---|---|
components | a component for objects → Custom components |
runtime | code for the published project → Runtime code |
triggers, steps | entries in the events panel → Triggers and steps |
settings | a section in Project settings → Project settings |
locales | translations → Translate your plugin |
actions | items in the right-click menu of layers and assets → Menu items and shortcuts |
commands | commands other plugins and the assistant can call → Commands |
skills | guides the assistant reads in projects that have the plugin, each a .md file of the folder |
Your panel
The simplest panel is an HTML file with a script:
<div id="root">Loading…</div>
<script type="module">
init(async () => {
// editor, world and meta are ready here
document.getElementById('root').textContent = `${meta.name} — ${world.getEntities().length} entities`;
});
</script>
You can also skip the HTML and point entry at a .tsx file. Studio mounts its default export,
and you can build the panel from the editor's own components:
// 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 everything is readyinit() runs once the editor and the scene are ready. You do not need to check for either
yourself.
What your code can use
These globals are available:
| Global | What it is |
|---|---|
editor | the editor API |
world | the live scene, as entities and components; null unless your manifest lists scene:read, and always null in an overlay |
tags | your plugin's own labels on objects (see below); null when world is |
meta | your manifest |
init | runs your code once the editor and the scene are ready |
You can import @was/ecs, @was/signals, @was/engine, @was/editor-api, @was/ui,
@was/icons, react and react-dom/client. Any other import fails with an error, so you notice
it straight away.
Panels built with @was/ui use a ready-made stylesheet, so only its classes work. Custom values
such as text-[13px] do not. For a size outside the scale, use an inline style.
Work with the scene
You can build entities by hand through world. For scenes and spaces, ask the editor instead:
you get the same defaults, numbering and safety checks as when you work in Studio.
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 an anchor's data. It is checked against the same schema the editor uses, so new
trigger types work in your plugin without changes to the plugin API. For an image trigger, if you
do not give a physical size, it is worked out from the image.
Find your own objects later
A plugin that generates scenes needs to find them again. Use tags for that:
tags.set(entity, { kind: 'scene', card: '2' });
tags.find({ kind: 'scene' });
tags.remove(entity, 'card');
Tags are kept separately for each plugin, so two plugins never overwrite each other's tags. The editor does not show or change them.
The project room
Some state belongs to the current session, not to the project: a running timer, an open vote, a raised hand. It should not be undone, published or saved with the project.
The project room is a small shared store of keys and values. Everyone who is in the project right now sees it.
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.
- It expires 12 hours after the last write.
- It holds up to 64 keys. A key name has up to 128 characters, and a value up to 8 KB as JSON. A write over a limit is ignored.
- Everyone in the project can read it. Only people who can edit the project can write to it.
Two computers' clocks can differ by minutes, so now gives you the server's time. Store the
end time based on now, and let each person's editor count down on its own. Do not store
"seconds remaining": the timer would then write to the room every second, for everyone.
The server records who wrote each key, and a plugin cannot fake it. That makes honest voting
possible, but your plugin has to check it: the server lets anyone who can edit write any key. The
Session voting example counts the key vote/<userId> only when that same user wrote it.
The viewport overlay
A panel is private to you, and you can close it. For something everyone should see, declare a
panel with "surface": "hud". It is drawn as a small overlay on top of the scene:
editor.hud.set({ visible: true, width: 240, height: 96 });
editor.panels.open('main'); // an overlay can open its own panel
The overlay starts hidden. Show it only when it has something to show: even an empty overlay catches clicks meant for the scene. The largest size is 640×400.
In an overlay, world is null. Put whatever it needs to show into the project room from your
panel.
Translate your plugin
Keep a dictionary for each language in one folder, and name the folder in the manifest:
"name": "${meta.name}",
"locales": "locale",
"panels": [{ "id": "main", "title": "${panel.title}", "entry": "main.tsx" }]
my-plugin/
locale/
en.json { "meta": { "name": "Shape Spawner" }, "panel": { "title": "Shapes" } }
de.json { "meta": { "name": "Formen-Werkzeug" }, "panel": { "title": "Formen" } }
name,descriptionand the titles of panels and components can be${…}templates, read from the dictionary.- In a panel,
useEditorLocale()from@was/editor-api, oreditor.i18n(), gives yout(key, vars)in the editor's language. It updates when the person switches language. - A missing language falls back to English, then to any language the plugin has.
- When you publish, the library shows the name and description in each language.
Remember settings
A panel has no localStorage. To remember something for the person between visits, use
editor.storage:
await editor.storage.set('lastShape', 'sphere');
const shape = await editor.storage.get('lastShape');
editor.storage.subscribe((values) => render(values));
There are also getAll(), delete(key) and clear(). Values are saved as JSON, for this person
and this plugin only: up to 256 KB per plugin, and 1 MB for all of a person's plugins together.
For values everyone in the project should see, use the project room.
Notifications and questions
editor.notify('Scene generated');
editor.notify({ message: 'Could not load the model', kind: 'error' });
const ok = await editor.confirm({ title: 'Delete 12 objects?', danger: true });
- A notification can also have a
detail, akind(success,warningorerror) and aduration. confirmanswerstrueorfalse. A click outside counts as no.- Both show your plugin's name. Up to three notifications stay on screen, and only one question can wait for an answer.
- Use them instead of
alert()andconfirm(): a panel cannot show those.
The camera
const view = await editor.viewport.camera.get();
await editor.viewport.camera.set({ position: [0, 2, 5], target: [0, 0, 0] });
await editor.viewport.camera.focus([entityId]);
setglides to a view that is close and jumps to one that is far. Passanimate: falseto always jump.focusframes one or several objects. It answersfalsewhen there is nothing to show.- In 2D,
setonly pans and zooms. - This needs the
editor:viewportpermission.
Undo
The changes your plugin makes to the scene can be undone, like the person's own. Changes less than 0.3 seconds apart become one step.
editor.history.transaction(fn)makes everythingfnchanges one step, even whenfnis async.editor.history.skip(fn)keeps changes out of the history. Use it for data your plugin works out from the scene. Otherwise an undo removes that data, your plugin writes it again, and the person never gets back to their own edit.
Menu items and shortcuts
actions add items to the right-click menu of rows in Layers and in the assets list:
"actions": [
{ "id": "check", "title": "Check the scene", "panel": "main",
"targets": ["entity"], "shortcut": "Mod+Alt+KeyD" }
]
editor.actions.on('check', ({ target, ids }) => {
// what was right-clicked or selected
});
targetsisentityfor layer rows andresourcefor asset rows. A plugin can have up to 20 actions.- A click opens your
panelif it is closed, and calls the handler there. - A
shortcutholdsMod,Altor both, in the orderMod+Alt+Shift, with the key's code, likeKeyD. It runs on the current selection. The editor's own shortcuts win, and keys the browser keeps, likeMod+KeyW, are refused.
Commands
commands let other plugins and the assistant call your plugin:
"commands": [
{ "name": "diagnose", "description": "Lists the problems in the scene", "panel": "main" }
]
editor.commands.handle('diagnose', async (input, { caller }) => {
return { problems: await findProblems() };
});
- The command is called
plugin.<plugin id>.<name>. Other plugins find it witheditor.commands.list(). A plugin can have up to 20 commands, named in camelCase. - Return JSON, or throw an error. Check the input yourself: nobody checks it for you.
- If the panel is closed, it opens first, but only for a plugin the person has already allowed.
permissionslists what a calling plugin must have. Without it, the caller needs all of your plugin's permissions.- With
destructive: true, the person confirms every call first, whoever makes it.
Publish
The publish form asks for:
| Field | Limit |
|---|---|
| Icon | made from any image you choose, cropped to a 256×256 square |
| Name | — |
| Tags | up to 12 |
| Description | up to 500 characters |
| Screenshots | up to 4 |
| Access | Free, Premium or Subscribers only — see Ratings and paid plugins |
If the plugin has locale files, the name and description come from them, in each language.
Other limits: up to 64 files per plugin, 512 KB per file, 50 plugins per account. Images and other binary files must each be under about 384 KB.
Publishing again updates the same plugin. To edit a published plugin, download it: Studio writes its files into a folder you pick.
A published plugin's files are served normally, so a path like ./icon.png works. A local folder
loads only the .ts, .tsx, .js, .jsx and .json files at its top level, plus the .html
files your manifest names as entries. Images, styles and files in subfolders appear only after
you publish.
Permissions
List what your plugin needs in the manifest: scene:read, scene:write, resources:write,
spaces:write, collaboration:read, collaboration:write, editor:panels and others. The server
rejects permissions it does not know.
Studio does not limit a plugin to most of these permissions: every plugin gets them. There are
two exceptions. A panel gets world only if the manifest lists scene:read. Project data
(editor.data) answers only a plugin whose manifest lists data:read or data:write.
Reviewers read your permissions, and people judge your plugin by them. Ask for the smallest set that does the job.
When you install someone else's plugin, assume it can reach the whole project. Only install what you have reason to trust. See Trust and verification.
Next: Custom components — add your own component to objects.