Skip to main content

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​

  1. Open the Plugins panel.
  2. Press Create plugin.
  3. 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.

TypeScript and JSX work as they are

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:

FieldWhat it adds
componentsa component for objects → Custom components
runtimecode for the published project → Runtime code
triggers, stepsentries in the events panel → Triggers and steps
settingsa section in Project settings → Project settings
localestranslations → Translate your plugin
actionsitems in the right-click menu of layers and assets → Menu items and shortcuts
commandscommands other plugins and the assistant can call → Commands
skillsguides 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 ready

init() 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:

GlobalWhat it is
editorthe editor API
worldthe live scene, as entities and components; null unless your manifest lists scene:read, and always null in an overlay
tagsyour plugin's own labels on objects (see below); null when world is
metayour manifest
initruns 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.

Only the built-in styles exist

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.
Use the server clock for timers

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.

The overlay cannot read the scene

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, description and the titles of panels and components can be ${…} templates, read from the dictionary.
  • In a panel, useEditorLocale() from @was/editor-api, or editor.i18n(), gives you t(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, a kind (success, warning or error) and a duration.
  • confirm answers true or false. 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() and confirm(): 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]);
  • set glides to a view that is close and jumps to one that is far. Pass animate: false to always jump.
  • focus frames one or several objects. It answers false when there is nothing to show.
  • In 2D, set only pans and zooms.
  • This needs the editor:viewport permission.

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 everything fn changes one step, even when fn is 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.

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
});
  • targets is entity for layer rows and resource for asset rows. A plugin can have up to 20 actions.
  • A click opens your panel if it is closed, and calls the handler there.
  • A shortcut holds Mod, Alt or both, in the order Mod+Alt+Shift, with the key's code, like KeyD. It runs on the current selection. The editor's own shortcuts win, and keys the browser keeps, like Mod+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 with editor.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.
  • permissions lists 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:

FieldLimit
Iconmade from any image you choose, cropped to a 256×256 square
Name—
Tagsup to 12
Descriptionup to 500 characters
Screenshotsup to 4
AccessFree, 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.

Images work only after publishing

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.

Ask only for what you need

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.