Writing a plugin
Start from a sample
The Add plugin button in the Plugins panel offers two, and they cover 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 |
Take the starter first. Unzip it, then Add plugin ▸ Install from a folder… and accept the permissions.
Iterate with the row’s Reload, which re-reads the folder and reinstalls without asking for consent again — unless the manifest has started asking for something new, which sends you back through the consent screen deliberately.
The file picker still opens on every Reload. A browser cannot re-read a directory without a gesture, so this is a platform limit rather than a missing feature — the consent screen is the part that is skipped.
Package format
- plugin.json
- main.js
- panel.html
Zipping the folder is fine — one wrapping directory is stripped automatically.
| Limit | Value |
|---|---|
| Per file | 2 MB |
| Per package | 8 MB |
| File count | 200 |
| Extensions | Text only |
Paths containing .. | Refused at the format level |
main is loaded as one file. Bundle your plugin if it has dependencies.
The manifest
{
"id": "studio.acme.easing-lab", // reverse-DNS, lowercase, unique
"name": "Easing Lab",
"version": "1.2.0", // semver
"description": "…", // shown to the user before install
"author": "Acme Studio",
"homepage": "https://…", // http(s) only
"apiVersion": 5, // the manifest GRAMMAR you use
"main": "main.js",
"panel": "panel.html", // optional
"permissions": ["scene:read", "animation:write"],
"requires": ["scene.read", "animation.write"], // what the host must DO
"optional": ["webgpu"]
}Ask for the fewest permissions you need — the list is the install screen.
Two version numbers, and they move independently
| Field | Answers |
|---|---|
apiVersion | What manifest grammar you use. Checked against the host’s MANIFEST_VERSION, currently 5 |
requires / optional | What the host must be able to do |
They used to be one number, which made every new host method look like a
manifest change and told authors their manifests were out of date when nothing
about them was. Bump apiVersion when you use a newer manifest field; use
requires to say what the host must support.
Capability strings are additive and permanent — never renamed, never removed, never repurposed, because your manifest is signed and a string that changed meaning would silently change what you asked for:
scene.read · scene.write · scene.proxy · scene.batch ·
animation.read · animation.write · assets.read · assets.write ·
timeline · net.fetch · storage.global · storage.project ·
effects.single · effects.multipass · layerkinds · panels · wasm ·
webgpu
requires is checked at install, and a refusal names the reason — a
capability the host knows but this machine lacks (“needs WebGPU”) reads
differently from one no version ever had, which is a typo.
Put webgpu in requires if your plugin is only effects. On the WebGL2
tier a plugin effect renders its input unchanged, so the plugin is not
degraded, it is inert — refusing to install is a better answer than looking
healthy and doing nothing. Put it in optional instead if effects are a
bonus, and feature-detect.
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.
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')
},
)
}export default { activate } and export default function (motion) also work.
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.
The API
motion.manifest // your own manifest
motion.permissions / motion.has(p) // what the user actually granted
motion.ui.notify(message, level) // info | success | warning | error
motion.ui.openPanel() / closePanel()
motion.ui.sendToPanel(data) / onPanelMessage(fn)
motion.commands.register(spec, handler) // spec: { id, label, icon?, needsSelection? }
motion.composition.get() // { name, width, height, fps, durationSeconds }
motion.scene.getSelection() / setSelection(ids)
motion.scene.getLayers() / getLayer(id)
motion.scene.createLayer({ kind, name, x, y }) // shape | text | group | null
motion.scene.setProperty(id, prop, value)
motion.scene.renameLayer(id, name) / deleteLayer(id)
motion.animation.getTracks(id) / sample(id, prop, time)
motion.animation.setKeyframe(id, prop, time, value, easing)
motion.animation.setKeyframes(id, prop, [{ t, value, easing }]) // prefer this
motion.animation.removeKeyframe(id, prop, time)
motion.animation.setExpression(id, prop, source)
motion.timeline.getTime() / setTime(seconds)
motion.scene.apply(mutations) // many mutations, one round trip, one undo
motion.storage.global / motion.storage.project // get / set / delete, no permission
motion.net.fetch(url, init) // only the hosts your manifest declaredPrefer setKeyframes over a loop of setKeyframe. The bulk API sorts once
and notifies once; writing a generated track a keyframe at a time is quadratic,
and is what used to freeze the app on imports.
scene.apply — many mutations, one round trip
Every motion.* call is a message across a thread boundary. A generator that
creates forty layers pays forty of those, and lands forty entries on the undo
stack. scene.apply takes the whole list, performs it inside one edit
transaction, and gives the user one Ctrl/⌘ +
Z — which is what they expect, because they took one action.
Contributing an effect
An effect is data plus a shader body. You do not write JavaScript that runs in the frame loop, and you do not write the pipeline:
- You declare typed parameters, which become ordinary keyframeable properties in the inspector.
- You write one function in the shader language. The host writes the entry point, the uniform layout, the bindings and the draw call.
- Multi-pass and downsampled passes are available as capabilities
(
effects.multipass), as is reading the original input alongside the previous pass.
The validator refuses a shader it cannot verify rather than compiling something that might sample out of bounds, and the generated layout is checked against a real GPU rather than trusted — a uniform block that disagrees with the shader is the failure mode that produces wrong colours silently, so it is tested rather than reasoned about.
Contributing a layer type
A layer type gives you a first-class layer that owns a generated subtree of ordinary layers. Two rules carry most of the design:
- Ask for
scene:read + scene:proxy, notscene:write. The proxy grant reaches only your own children, and it is the difference between a consent screen that says “manages its own content” and one that says “can delete your layers”. - Authored edits and generated content are different things. You are notified about a user’s hand edit and not about your own regeneration, so a user who tweaks one of your children keeps that tweak.
What a document stores is the subtree itself plus a provenance block naming your id and version. Without your plugin the layers are still there and still render — only regeneration is missing.
Degrading on a refused permission
Check rather than assume:
if (await motion.has('scene:write')) {
// offer the feature
} else {
// hide it, and say why
}A refused call returns an error naming the missing permission, so failing loudly is also fine — what is not fine is doing nothing silently.
Panels
panel.html is plain HTML, run in the sandboxed frame with two globals:
motionPanel.send(data) // → your plugin's onPanelMessage
motionPanel.onMessage(fn) // ← your plugin's sendToPanelThe panel talks to your plugin only. It has no access to the editor and no
access to the network — inline <script> runs, fetch does not.
Where it lands
You declare what kind of panel it is; the host decides where it goes.
"panels": [
{ "id": "main", "title": "Easing Lab", "entry": "panel.html",
"placement": "sidebar", "icon": "graph-value" }
]placement | Where it appears |
|---|---|
shared (default) | A tab inside the one Plugin Panels panel in the right inspector, shared with every other shared panel |
sidebar | Its own tab in the left sidebar, beside Scene, Assets and Library |
inspector | Its own tab in the right inspector, beside Properties and Effects |
Pick shared unless your panel is a place the user goes rather than a control
they reach for. It costs no rail space.
sidebar and inspector require an icon — the rail shows glyphs, not
titles. Names come from the editor’s icon set and are validated by the editor
and the registry, so a typo is a publish error rather than a generic glyph you
never notice.
A tab of your own is granted, not guaranteed. Each rail hands out a fixed
number of plugin slots — 3 on the left, 2 on the right — and past that
your panel is demoted to the shared host. It still opens, and
motion.ui.openPanel() still reveals it; it just does not own a glyph.
Which happened is printed on your plugin’s row, so a demotion never reads as your plugin being broken. Write the panel so it works either way: there is deliberately no API to ask where you ended up, because there is nothing useful you could do differently.
Getting it on screen
motion.ui.openPanel() reveals your panel wherever it landed. The user can also
reach it from Plugins ▸ Your plugin: Panel, or by clicking its rail tab —
which, if you declared onPanel:<id> in activationEvents, is what starts your
plugin in the first place.
The tab exists whenever your plugin is installed and enabled, running or not; the panel states its own status until the worker is up.
Do not build a “close me” control into your panel. There is no ✕ on a
plugin panel or tab, and the tab will not go away: a panel belongs to the rail
for as long as its plugin is installed and enabled. motion.ui.closePanel()
switches away from a shared tab and does nothing at all to a tab of your
own. Disabling or uninstalling is what removes it.
The development loop
Generate the starter
Add plugin ▸ Download starter template, then unzip it.
Install it as a folder
Add plugin ▸ Install from a folder…, accept the permissions.
Edit and reload
Change the code, press Reload on the row. The picker still opens — a browser cannot re-read a directory without a gesture — but the consent screen does not.
Watch the log
Each row has a log carrying your console.* output, every call the permission
gate refused, and the crash that stopped it if it stopped. It is kept after the
plugin dies, because that is when you need it, and it is capped at 200 lines so
a logging loop cannot grow the host.
There is no DevTools in the packaged app, so this log is your console.
Publish it
When it works, see Publishing a plugin — namespace, signing key, visibility, and what a user sees on install.
When it will not start
The three failures you will actually hit, and what the row says:
| Symptom | Cause |
|---|---|
| Stopped after 8 seconds, with a reason | activate did not finish. Boot timeout — do slow work after activation, not during it |
| “Stopped responding” | Two missed heartbeats, 4 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 |
Constraints to design around
| Constraint | Why |
|---|---|
| Boot must complete in 8 seconds | Boot timeout |
| Never block the loop | A missed heartbeat twice over terminates the worker |
No DOM, no window, no localStorage | Worker realm. Use motion.storage instead |
| No ambient network | Declare your hosts and ask for net:fetch; the panel stays network-free regardless |
One bundled file for main | No module resolution at install time |
| WebAssembly is allowed | Inside the Worker, where it is subject to the same gates as everything else |
| Effects need WebGPU | On WebGL2 a plugin effect is inert — declare webgpu |
Publishing
Publish from inside the editor, and choose who can see it. The package digest is pinned in the registry’s metadata before the bytes move, so a digest shipped with the package proves nothing and is not what is checked.
You can hide or withdraw your own plugin without asking anyone. If you rotate your signing key, pre-authorising the rotation is what distinguishes it from someone else signing as you — an unannounced key change is treated as the latter.
Related
- Plugins — the user-facing side
- Effect catalog — where a plugin effect appears
- Keyframes — the time-axis rule applies to your writes too