Guide 02
Build and publish a plugin
What a plugin is, what it may do, how to develop one against the running editor, and the whole path to publishing it on the registry so other people can install it.
A plugin is a package — a plugin.json manifest plus one ES module — that the editor runs in a Web Worker with no DOM, no editor globals and no network of its own. It talks to the editor over messages, and every call is checked against the permissions the user granted at install.
You need a hosted build
Plugins are a hosted-build feature. In the open-source local edition the panel, the menu and the host itself are absent, and the registry is never contacted. A project containing plugin content still opens, edits and saves losslessly there — the layers are real — but you cannot develop or run a plugin on that build.
1. Start from a sample
The Add plugin button in the Plugins panel offers two, and they cover deliberately different halves of the API.
| Menu item | What you get |
|---|---|
| Download starter template | A complete working plugin: a registered command, a keyframe write, and a panel |
| Download effect sample (blur) | A two-pass Gaussian blur — multi-pass chaining, downsampled passes, and the generated uniform layout |
- 1
Add plugin ▸ Download starter template, then unzip it.
What happens You get a folder with plugin.json, main.js and panel.html.
- 2
Add plugin ▸ Install from a folder… and accept the permissions.
What happens The manifest is validated and a consent screen lists what the plugin may do — before any of its code exists anywhere. Only after you accept is the module loaded into its worker.
- 3
Edit the code, then press Reload on the plugin's row.
What happens It re-reads the folder and reinstalls without asking for consent again — unless the manifest started asking for something new. The file picker still opens each time: a browser cannot re-read a directory without a gesture.
2. The manifest
{
"id": "studio.acme.easing-lab",
"name": "Easing Lab",
"version": "1.2.0",
"description": "Shown to the user before install",
"author": "Acme Studio",
"apiVersion": 5,
"main": "main.js",
"panel": "panel.html",
"permissions": ["scene:read", "animation:write"],
"requires": ["scene.read", "animation.write"],
"optional": ["webgpu"]
}apiVersion and requires answer different questions, and keeping them separate is why adding a host method no longer makes every published manifest look out of date. apiVersion is the manifest grammar you use. requires is what the host must be able to do, checked at install — and a refusal names the reason, so a capability this machine lacks reads differently from one that never existed, which is a typo.
Omitting requires is not “I need nothing”
A manifest without one is treated as needing whatever its apiVersion implied before capabilities existed, so every already-published plugin keeps working. Write one when you want a specific answer.
3. Ask for the fewest permissions you can
The permission list is the install screen, verbatim. Users can untick any of it and install anyway, so write for a refusal rather than assuming a grant.
| Permission | The plugin can |
|---|---|
| scene:read | See layer names, structure and scalar properties |
| scene:proxy | Write only inside its own layer type's generated subtree |
| scene:write | Create, change and delete layers anywhere |
| animation:read | Read keyframes and sample animated values |
| animation:write | Create and change keyframes and expressions |
| assets:read | Read the pixels of images already in the composition |
| assets:write | Create images and place them as layers |
| net:fetch | Contact the hosts listed in the manifest — and only those |
| timeline | Read the current time and move the playhead |
Registering commands, showing notifications, opening your own panel, reading composition settings and using your own storage need no permission at all — none of those read project data or change it.
if (await motion.has('scene:write')) {
// offer the feature
} else {
// hide it, and say why
}The narrow ask exists so you do not have to frighten people
If you build a subtree under your own layer type, ask for scene:read + scene:proxy rather than scene:write. The wide one makes your consent screen read “create, change, delete and reparent layers” — indistinguishable from a plugin that could rearrange someone's whole project.
4. The entry module
export function activate(motion) {
motion.commands.register(
{ id: 'bounce', label: 'Bounce selection', icon: 'zap', needsSelection: true },
async ({ selection }) => {
const t = await motion.timeline.getTime()
for (const id of selection) {
await motion.animation.setKeyframes(id, 'y', [
{ t, value: 0, easing: 'easeOut' },
{ t + 0.18, value: -60, easing: 'easeIn' },
{ t + 0.42, value: 0, easing: 'easeOut' },
])
}
await motion.ui.notify(`Bounced ${selection.length} layer(s)`, 'success')
},
)
}Every motion.* call returns a promise — it is a message to the editor, not a function call into it. Your code is in another thread.
Two calls worth knowing early
setKeyframes writes a whole track at once: the bulk API sorts once and notifies once, where a loop of single writes is quadratic and is what used to freeze the app on imports. scene.apply goes further — many mutations, one round trip, and one undo entry, which is what the user expects because they took one action.
5. What you can contribute
| Contribution | Where the user finds it |
|---|---|
| Commands | The Plugins menu under your plugin's name, and the command palette |
| Panels | Wherever placement sends it, plus a “Your plugin: Panel” command |
| Layer types | The layer-creation menu — Layer ▸ New |
| Effects | A folder named after your plugin, in the Effects browser |
| Notifications | A toast, always prefixed with your plugin's name |
An effect is data plus a shader body: you declare typed parameters and write one function, and the host writes the entry point, the uniform layout, the bindings and the draw call. There is no JavaScript in the frame loop, so a plugin cannot make the renderer stutter — and the generated layout is checked against a real GPU rather than trusted, because a uniform block that disagrees with the shader produces wrong colours silently.
An effect plugin needs WebGPU
On the WebGL2 fallback a plugin effect renders its input unchanged: not degraded, inert. Declare webgpu in requires so it refuses to install rather than looking healthy and doing nothing. Put it in optional instead if effects are a bonus, and feature-detect.
Panels declare a placement — shared (a tab in the one Plugin Panels panel), sidebar, or inspector. A tab of your own is granted, not guaranteed: each rail hands out a fixed number of plugin slots, three on the left and two on the right, and past that your panel is demoted to the shared host. It still opens; it just does not own a glyph, and which happened is printed on your plugin's row so a demotion never reads as your plugin being broken.
6. When it will not start
| Symptom | Cause |
|---|---|
| Stopped after 8 seconds, with a reason | activate did not finish. Do slow work after activation, not during it |
| “Stopped responding” | Two missed heartbeats, four seconds apart. Something blocked your event loop |
| A call returns an error naming a permission | The user unticked it, or you never asked. Check motion.has(p) and degrade |
Each plugin row carries a log with your console output, every call the permission gate refused, and the crash that stopped it. It is kept after the plugin dies, because that is when it gets read, and capped at 200 lines so a logging loop cannot grow the host. There is no DevTools in the packaged app — this log is your console.
7. Make a signing key
A signing key is a file you make, not something the registry issues you. There is nothing to find beforehand.
node scripts/sign-plugin.mjs keygen --out ./my-plugin.key.jsonOr, in the app, hit publish and choose Create a new key… — it asks where to save one, makes it, and signs with it immediately. Both routes produce the same file, so you can start in the app and script it later, or the other way round.
This key is your identity — back it up now
The registry pins it to your plugin id on your first publish, and every later version must verify against it. That is what tells a user's editor an update came from you rather than from whoever got into your account. Lose it and the plugin must be republished under a new id; your installs do not follow. Register a backup key on your first publish, while you have no install base and it interrupts nobody — authorising one later needs your account password and prompts every existing user.
Premation never stores your private key, and deliberately does not offer to remember it in your OS keychain — anything running as you could then publish as you, which is the compromise the signing model exists to survive. This is also why the web dashboard cannot publish for you: a browser upload asking for a private key would defeat the whole guarantee, so it prints the commands with your values filled in instead.
8. Publish it
- 1
Register a namespace on the publisher shelf — a namespace like acme and a display name like Acme Studio, then Register namespace.
What happens The namespace is yours; nobody else can publish under it. Signed out, the shelf simply reads “Sign in to publish plugins.”
- 2
Edit listing, write a README and a licence, then Save listing.
What happens This metadata lives outside the signed package on purpose — fixing a typo in your description does not mean re-signing and re-publishing your code.
- 3
Choose signing key and publish, then Use an existing key…
What happens The package, the signature and your public key go over the wire. Nothing else. The package digest is pinned in the registry's metadata before the bytes move.
- 4
Leave it Private, install it yourself, then Make public.
What happens Private first is the only way to test the real install path — download, signature check, package reader, consent screen — as a user experiences it, rather than as a folder install you already trust.
node scripts/sign-plugin.mjs publish my-plugin.zip \
--key ./my-plugin.key.json --token <access token>Versions are immutable
Re-publishing an existing version number is refused — two different sets of bytes both claiming to be 1.2.0 would make the signature guarantee unusable, because a user could not tell which one they verified. Ship 1.2.1. There is no force flag.
9. Updates, and taking it down
Publish a new version the same way. Users see it when they open the manager — update checks never run on a timer or in the background. An update asking for more permissions than were granted goes back through the consent screen rather than installing quietly.
| Action | What it does |
|---|---|
| Make private | Stops new public downloads. Reversible; the listing and versions stay |
| Withdraw permanently | Deletes the listing and every published version from the registry. Not reversible |
Installed copies keep working either way — someone in the middle of a project does not lose their afternoon because you withdrew a plugin. If you only want to stop new downloads, use Make private: withdrawing is the one action here you cannot undo, and because versions are immutable you cannot re-publish the same numbers afterwards either.
What the registry deliberately does not do
Settled decisions, so you do not plan around them. No ratings, comments or curation — it lists what was published, and the only public number is a deduplicated install count that nothing is ranked on. No automatic blocking on a report threshold, because reports need no account and a count that blocks is a takedown button handed to anyone who can make the count go up; cases go to a human. And no plugin-to-plugin communication: two plugins that can talk are two plugins whose combined permissions are the union of what was granted separately, which is not what either consent screen said.
Try it
Free and open source, and everything above is in the build you can download right now.