Plugin API
A plugin is a manifest plus one HTML document. The editor opens the document in a floating panel, and the document reads and changes the project only through the functions on this page.
Where a plugin runs
A plugin is a single .sshowplugin file: a renamed zip file that holds the manifest (plugin.json) and one UI document. An icon is optional. The editor uses only these files. The 13 plugins use the same format.
When you click a plugin in the list, the editor opens a separate floating panel next to the list and loads the UI document in a sandboxed frame inside it. The SSHOWPlugin SDK and the editor’s theme styles go in before the document’s scripts, so your script can call SSHOWPlugin.connect() from the start.
A plugin reaches the editor only through the functions in the tables below. Every function returns a Promise, and applyActions is the only way to change the document. The AI Assistant and AI apps (Connect an AI app (MCP)) change the document with the same actions.
| Name on the handle | What it holds |
|---|---|
| apiVersion | This editor’s plugin API version. Currently 1. |
| engineVersion | The editor engine’s version. Use it for display only. |
| document | Reading the document, selection, switching scenes, and writing. Reading and selection · Writing: applyActions |
| assets | Reading and registering the bytes of project files. Assets |
| storage | Keeping plugin settings. Storage |
| events | Notifications when the document, selection, or clock changes. Events |
| ui | Panel size and theme. Panel UI and keyboard |
Write your first plugin
All you need is a text editor and a zip tool. You build a Hello plugin that adds one text object to the canvas when you click a button.
-
Write plugin.json
Create an empty folder, and create plugin.json inside it. Copy the first row of the table below exactly. The rules for each field are in Manifest.
-
Write ui.html
Create ui.html in the same folder. Add one button element whose id is insert. Below it, add a script element, and write Script 1 and Script 2 from the table below, in that order. A plugin can’t load other files, so all styles and scripts go in this file.
| File | Contents |
|---|---|
| plugin.json | { "id": "com.example.hello", "name": "Hello", "version": "1.0.0", "api": 1, "main": "ui.html", "author": "Example" } |
| ui.html · UI | One button element, with the id insert and the text Insert. Put the script element below this button. You can leave out the html, head, and body elements. |
| ui.html · Script 1 | const action = { op: 'create_object', type: 'text', config: { name: 'Hello', data: { text: 'Hello, SSHOW', fontSize: 48, autoSize: true }, transform: { x: 200, y: 200 } } }; |
| ui.html · Script 2 | (async () => { const api = await SSHOWPlugin.connect(); document.querySelector('#insert').onclick = () => api.document.applyActions([action], 'Hello'); })(); |
Import and run it
-
Pack it as .sshowplugin
Open the folder, select plugin.json and ui.html, and compress them into a zip. Rename the file to hello.sshowplugin. When you import, you can choose only files that end in .sshowplugin. On Windows with file extensions hidden, check that .zip isn’t left at the end.
-
Import it into the editor
At the top right of the editor, click the light-bulb-shaped Plugins button. In the panel’s title row, click the plus-shaped Import plugin (.sshowplugin) button, and choose hello.sshowplugin. Hello appears at the bottom of the list. If you don’t see it, scroll to the end of the list.
Choosing hello.sshowplugin with the + button and scrolling to the end of the list shows Hello at the bottom. Video: Clicking the light-bulb Plugins button opens the panel, and hello.sshowplugin is chosen with the + button in its title row. Scrolling to the end of the list shows Hello at the bottom. -
Run it
Click Hello in the list. A floating panel opens next to the list and shows ui.html. Click Insert, and “Hello, SSHOW” appears on the canvas.
Clicking Hello opens the panel, and clicking Insert adds “Hello, SSHOW” to the canvas. Video: Clicking Hello in the plugin list opens the plugin UI in a floating panel, and clicking its Insert button adds the text Hello, SSHOW to the canvas.
Both files must be at the top level of the zip
The editor looks for the exact name plugin.json inside the zip. If you compress the whole folder, the name becomes “hello/plugin.json”, and you get “This file could not be read as a .sshowplugin package.” Open the folder, select the files inside, and compress those.
Manifest (plugin.json)
Five fields are required, and three are optional. Unknown fields are ignored, so a manifest with newly added fields still opens in older editors.
In the title row of a running plugin’s panel, click the Plugin info button (an i in a circle) to see the version, author, ID, and description.
| Field | Required | Rules |
|---|---|---|
| id | Required | Lowercase reverse-domain format (com.example.hello). Use only a–z, 0–9, periods, and hyphens, and start with a lowercase letter or a digit. Two periods can’t appear in a row. When publishing, it can be up to 100 characters, and the account that first submits it owns it from then on. |
| name | Required | The name shown in the list and in the panel title. Up to 100 characters when publishing. |
| version | Required | x.y.z, three numbers joined by periods (1.0.0). |
| api | Required | The plugin API version. Currently 1. If it differs from the editor’s, the plugin isn’t imported. |
| main | Required | The file name of the UI document (ui.html). |
| description | Optional | The description shown in the list and in Plugin info. Up to 2000 characters when publishing. |
| author | Optional | The author shown in the list and in Plugin info. Up to 100 characters when publishing. In the published list, the author is the submitting account, not this value. |
| icon | Optional | The name of the icon file in the package. Use png, svg, jpg, jpeg, or webp. |
Reading and selection
Every read function returns a copy as of that moment. Changing the copy doesn’t change the document. To make a change, send it with applyActions.
For images, video, and audio, data.src comes as an asset:// URL for a project asset, and you read the bytes with assets.get. A long src that isn’t an asset (such as a data: URL) comes as a placeholder that records only its length (src len=… kind=…), so don’t send it back. Web requests in interactions come with the values in their URLs and their header values masked.
| Function | Returns · does |
|---|---|
| document.getState() | { canvas, activeSceneId, scenes, fonts }: the canvas size, the id of the current scene, the list of scenes, and the names of the fonts in the document. The current scene includes all of its objects and the scene’s motion and interaction. Other scenes include only each object’s id, type, and name. |
| document.getObject(id) | A copy of one object in the current scene. null if the object is in another scene or doesn’t exist. To read one from another scene, open that scene with setActiveScene first. |
| document.getSelection() | An array of copies of the currently selected objects. |
| document.getTextLayout(id) | { char, word, line } for a text object. Each is an array of { text, line, word, localX, localY, width, height }. It uses the same measurement as what’s drawn on screen, so letter spacing and line breaks match, and the boxes are in the object’s own coordinates before transforms. text holds the characters with variable values filled in, and line and word are numbers in reading order. null if the text isn’t in the current scene, and an empty array if it hasn’t been drawn yet. |
| document.getTimelineTime() | The editor’s animation clock, in ms. In Animation mode it’s the playhead position, and in Design mode it’s 0. Start timeline writes from this value, and read it again right before you write. |
| document.setSelection(ids) | Selects objects of the current scene in the editor. Use it to hand the objects you just created back to the user, already selected. Ids that don’t exist are silently dropped, and nothing is added to the undo history. |
| document.setActiveScene(sceneId) | Switches the current scene. Unknown ids are rejected, so you never keep writing to the wrong scene. |
Writing: applyActions
document.applyActions(actions, label) is the only way to change the document. It applies the actions in the array in the order written, and the whole call becomes one undo step (one ⌘Z). label is that step’s name, and if you leave it empty, the plugin’s name is used.
It returns { applied, skipped, ids }. applied is the number of actions applied. skipped holds { index, op, id, code, reason } for each skipped action. Even if some actions are invalid, the rest are still applied.
In a create action, config.id is an alias for this call only. Later actions in the same array can refer to that object by the alias. The engine sets the real id, which comes back paired with the alias in ids.objects, ids.scenes, or ids.variables. From the next call on, use the real id. Name aliases like 'bar'. If you use the engine’s id pattern, like o1 or s2, and the engine happens to create that same id, the alias is left out of ids.
Writes take the same path as edits you make by hand in the editor. In Animation mode, when Auto-keyframe is on, values such as position, size, and opacity are recorded as keyframes at the playhead. To edit keyframes, see Work with keyframes.
The 25 actions
| op | Fields (+ optional) | What it does |
|---|---|---|
| create_object | type, config (+ sceneId, options) | Creates an object. type is rect, circle, path, text, image, video, audio, group, frame, or table. Give options.parentObjectId to create it inside a group or frame, and use options.index to set its place in the list. |
| update_object | id, set (+ sceneId) | Changes only the keys in set. set keys and how they merge |
| delete_object | id (+ sceneId) | Deletes an object. Deleting the text in a table cell keeps the cell and only empties its text. |
| duplicate_object | id (+ sceneId, options) | Duplicates an object. |
| move_object | id (+ sceneId, options) | options.parentObjectId puts it inside another group or frame, and options.index sets its place there. options.to changes the stacking order with front, back, forward, or backward. |
| group_objects | ids (+ config, sceneId) | Groups two or more objects. You can include aliases created in the same call. |
| ungroup | id (+ sceneId) | Ungroups a group. |
| convert_to_path | id (+ sceneId) | Converts an object to a path. |
| insert_table_rows | id (+ index, count, values, sceneId) | Inserts rows into a table (Tables). Without index, they go after the last row. values sets the text of the new cells. |
| insert_table_columns | id (+ index, count, values, sceneId) | Inserts columns into a table. |
| delete_table_rows | id, index (+ count, sceneId) | Deletes rows. count defaults to 1. |
| delete_table_columns | id, index (+ count, sceneId) | Deletes columns. |
| merge_table_cells | id, range (+ sceneId) | Merges the cells in range { rowIndex, colIndex, rowSpan, colSpan }. The range must cover two or more cells, and all of them must be text cells. |
| unmerge_table_cells | id, range (+ sceneId) | Splits the merged cells within range. |
| create_scene | config (+ options) | Creates an empty scene, and the editor switches to it. To add objects to it, give this scene’s id or alias as sceneId in create_object. |
| update_scene | set (+ sceneId) | Changes a scene’s keys. |
| delete_scene | (+ sceneId) | Deletes a scene. |
| duplicate_scene | (+ sceneId) | Duplicates a scene, and the editor switches to the copy. |
| move_scene | newIndex (+ sceneId) | Changes a scene’s place in the order. |
| set_scene_size | size: { width, height } | Changes the canvas size. A document has only one size, so it applies to every scene. |
| create_variable | config (+ options) | Creates a variable (Variables). |
| update_variable | variableId, set | Changes a variable’s keys. |
| delete_variable | variableId | Deletes a variable. |
| move_variable | variableId, newIndex | Changes a variable’s place in the order. |
| set_document | set | Changes the document’s name, description, and notes. |
set keys and how they merge
If set contains even one unknown key, the whole action is skipped, and the reason goes into skipped. For any action without sceneId, the target is the scene that’s current when you call. After create_scene or duplicate_scene, the current scene switches to the new one, so give sceneId to write to the original scene in the next call.
| set | Allowed keys |
|---|---|
| update_object | name · description · size · transform · distort · layout · style · opacity · blendMode · locked · visible · motion · interaction · data |
| update_scene | name · description · notes · style · visible · motion · interaction · data · clip |
| update_variable | name · description · type · value |
| set_document | name · description · notes |
| Key | How it merges |
|---|---|
| transform · size · data · layout | Only the subkeys you send change, and the rest stay as they are. |
| style · distort | Replaced as a whole. When you send style, always send all of fills, strokes, and effects. |
| motion | Replaced as a whole, separately for transitions and animations. If you send only animations, transitions stay as they are, and the other way around. To change one keyframe, read that whole group, change it, and send the whole group back. |
| interaction | All rules are replaced. Read the current rules, change them, and send them back. You can’t write new web request actions. Create them by hand in the Interactions panel, as shown in Requests and variables. Existing requests are kept if you leave them as you read them. |
When sent values are changed or rejected
| What you send | How it’s handled |
|---|---|
| transform.rotateX · rotateY · rotateZ | Taken in degrees and stored in radians. The old name rotate is read as rotateZ. Angles in read results and in motion tracks are in radians, so convert an angle you read to degrees before you send it back in set. |
| A fill with color but no type | type is filled in as 'solid'. Fills and effects that can’t be fixed are discarded. |
| The two-character sequences \n and \t in data.text | Become real line breaks and tabs. |
| data.src · src in image fills | Only asset:// URLs for project assets are accepted. If you give an http(s) URL or a data: URL, that action is skipped. Register files first through Assets. |
Example: create, edit, and select
This example puts an action that creates an object and an action that edits it in one call. 'bar' is an alias for this call only, and after the call, ids.objects.bar is the real id.
As in Hello’s Script 2, write the code below inside an async function, where the api you got from connect() is available. Writing await at the top level, outside a function, causes a syntax error.
transform.x and y are the position of the object’s anchor point, and a shape’s anchor point is at its center by default (anchorX and anchorY 0.5). So this bar spans from (340, 180) to (460, 420).
| Step | Code |
|---|---|
| Build the actions | const actions = [ { op: 'create_object', type: 'rect', config: { id: 'bar', name: 'bar', size: { width: 120, height: 240 }, transform: { x: 400, y: 300 }, style: { fills: [ { type: 'solid', color: '#8A8A8E' } ], strokes: [], effects: [] } } }, { op: 'update_object', id: 'bar', set: { opacity: 0.8 } } ]; |
| Send | const { applied, skipped, ids } = await api.document.applyActions(actions, 'Add bar'); |
| Check what was skipped | if (skipped.length) console.warn(skipped); |
| Select and hand over | await api.document.setSelection([ids.objects.bar]); |
Assets
You read the bytes of images, video, and audio by their asset:// URLs, and you register new bytes to get a URL. A plugin that edits the selected image first reads the image’s data.src with getSelection. It gets the bytes with assets.get, edits them, registers the result with assets.register, and then changes data.src to the new URL with update_object.
Put the URL you get into an action right away. Assets that aren’t used anywhere may be cleaned up.
| Function | Returns · does |
|---|---|
| assets.get(uri) | { bytes, mimeType, originalName } for an asset:// URL. bytes is an ArrayBuffer. null if there’s no such asset. |
| assets.register(bytes, { mimeType, originalName }) | Registers the bytes as a project asset and returns an asset:// URL. bytes must be an ArrayBuffer, not a Uint8Array, and each can be up to 10 MB. The same content gets the same URL. |
Storage
Closing the panel also discards the plugin’s document, so values kept in script variables are gone when you open it again. Keep values that must last, like options, in storage. localStorage isn’t available inside the frame, so the editor keeps one record per plugin id on its behalf.
The record lives in the app, not in the document. Every project you open in this app sees the same record, and it isn’t saved in .sshow files. Web, desktop, and tablet keep separate records, and importing the same id again doesn’t erase the record. Put anything that must stay with the document into objects with applyActions.
| Function | Returns · does |
|---|---|
| storage.get(key) | The value for that key. null if it was never saved. |
| storage.set(key, value) | Saves a JSON value. A plugin’s whole record can be up to 64 KB. A save that goes over is rejected, and the record stays as it was. Saving undefined deletes that key. |
| storage.remove(key) | Deletes one key. |
| storage.keys() | An array of the keys this plugin has saved. |
Events
Listen with events.on(type, callback), and stop with events.off(type, callback). The callback receives no value. It’s a signal to read again, so when it arrives, read again with the read functions. Types other than the three below are rejected, and all subscriptions end when the plugin stops.
| type | When it arrives |
|---|---|
| history:update | When the document changes. Edits, undo, and redo all count. |
| ui:modes:edit:changeSelectedObjects | When the selection changes. |
| motion:animation:timeUpdate | When the animation clock moves. During playback it arrives every frame, so throttle your handling. |
Panel UI and keyboard
Request a UI size with ui.resize(size). A single number is the height, and { width, height } sets whichever sides you include. The panel starts 340px wide and 52% of the window height, and it resizes only between 280 × 240px and 90% of the window. A requested size stays, as if the user had resized the panel.
The UI document gets the editor’s design values as the CSS variables below, and they follow the same light or dark setting as the editor. Use them like var(--sshow-primary), and the panel changes along with the theme with no extra code. Scrollbars are the same 6px bars as in the editor, and if you write your own ::-webkit-scrollbar rules, yours take priority.
ui.getTheme() returns { mode, colors, borderRadius, fontSize }. mode is 'light' or 'dark'. Use it only when your script needs color values, such as when drawing on a canvas element.
Editor shortcuts keep working even when the plugin UI has focus. Keys the plugin doesn’t handle go on to the editor: ⌘Z undoes, Tab switches the edit mode, and Space pans the view (a short press plays the timeline in Animation mode). So inside the UI, Tab can’t move focus and Space can’t press buttons.
To use a key in your plugin, stop it before it reaches window. For keys your script handles itself, call preventDefault(). For keys that should keep a browser default, like moving focus or pressing a button, call stopPropagation() in both keydown and keyup. preventDefault() stops that default too. The SDK forwards keys from window, so attach your listeners to document or an element, not to window. While an input, a textarea, or a contenteditable element has focus, no keys are forwarded.
| CSS variable | Value |
|---|---|
| --sshow-primary | Accent color #2196F3 |
| --sshow-primary-strong | Surface of a button that’s on or pressed. The accent color, slightly darker. |
| --sshow-primary-soft | Surface of a button on hover. The accent color, darker and slightly transparent. |
| --sshow-primary-foreground | Text on the accent color #ffffff |
| --sshow-secondary | Secondary color #FFC107 |
| --sshow-foreground | Body text. Light #30353e · Dark #f5f5f5 |
| --sshow-background | Translucent panel surface |
| --sshow-background-solid | Opaque panel surface |
| --sshow-border-color | Borders |
| --sshow-radius | 13px |
| --sshow-font-size | 12px. The text size inside editor panels. |
| --sshow-scrollbar-size | 6px |
| --sshow-scrollbar-radius | 3px |
Sandbox and limits
A plugin runs in a sandboxed frame, separate from the editor. The limits below protect the documents and accounts of the people who use plugins.
| Item | Limit |
|---|---|
| Network | Blocked. You can’t use fetch, external scripts, external images, or web fonts. Write scripts and styles directly in the UI document, and embed images and fonts as data: URLs. Images can also use blob: URLs. |
| Scripts | Scripts in the document and WebAssembly work. eval and new Function don’t. |
| Frame | The sandbox allows only allow-scripts. Inside the frame, localStorage and cookies aren’t available, and alert and confirm dialogs and new windows don’t open. |
| Package | Up to 64 zip entries. plugin.json, the UI document, and the icon can each be up to 5 MB uncompressed, and nothing other than these three is used. When publishing, the whole package can be up to 10 MB, and any entry over 5 MB uncompressed is rejected, even an unused one. |
| Assets · storage | Up to 10 MB per assets.register call, and up to 64 KB of storage per plugin. |
| Running | Only one plugin is open at a time. Opening another plugin or closing the panel stops it. |
| API version | If api in plugin.json differs from the editor’s API version, the plugin isn’t imported. The version goes up only for changes that would stop existing plugins from working. New functions can be added without a version change, so check that one exists as you use it, like api.storage?.get. |
| Errors | A failed call is rejected with an Error, and its message holds the reason. |
| Language | Plugins get no value that tells them the editor’s UI language. The 13 built-in plugins also appear only in English. |
Edit and check again
There are two ways to check a plugin in the editor again after you change it. You can import the file again anywhere, and in the Windows desktop app you can also have the app watch a folder.
Import again
Web · DesktopPack the changed files again, and import the package with Import plugin (.sshowplugin). A plugin with the same id is replaced by the new file, so upgrading, rolling back, and reinstalling are all the same action. Below the list, “Hello updated: 1.0.0 → 1.1.0.” or “Hello 1.0.0 reinstalled.” appears, and a running plugin reopens with the new version. If the file is rejected because it can’t be read, the existing plugin stays. If it’s rejected because the api value differs, the existing one is also removed from the list, so import a fixed file.
Watch a folder
Desktop app (Windows)From the File menu, choose Settings › Edit settings.json. In the file that opens, change the value of "plugins.devPath" to the absolute path of your plugin folder, and save. If the key isn’t there, add it. Write the path with /, like C:/Users/me/hello. With single backslashes, the file can’t be read, and your other settings go back to their defaults too. After you save, restart the app. From then on, each time you save a file in the folder, the plugin is registered again in every open editor, and a running plugin reopens. You don’t need to zip it. To turn this off, change the value to null and restart the app. The macOS app can’t read folders outside the app because of its sandbox, so on macOS, import again instead.
Plugins imported from a file are gone after a reload
An imported plugin lives only in the editor you have open now. When you reload or reopen the editor, it’s removed from the list, so import it again. Folder watching in the desktop app carries on even after you reopen the app.
Publish
A published plugin goes into the plugin list in Studio, where anyone can install it. A person reviews every version before it’s published.
-
Open the developer console
Open the developer console. You need to sign in to open it.
-
Choose the package
Click “Choose .sshowplugin” and choose your package. The server reads the manifest from the package, so there’s nothing else to fill in. Put anything you want to tell the reviewers in the note field.
-
Request a review
Check the consent box and click “Submit for review”. The plugin appears in the “My plugins” list below as “In review”.
-
Check the result
When the review is done, you get a notification and an email. An approved version is published to the list right away. If changes are requested, read “Reviewer feedback” in the “My plugins” list. Fix the plugin, and submit it again with a higher version.
| Rule | Details |
|---|---|
| id | The account that first submits it owns it from then on. Use an id that is a domain you manage, written in reverse. installed, mine, and submit can’t be used. |
| Version | Each submission needs a higher version than the last one. Raise the number even when you fix a rejected version and submit it again. |
| Review | A plugin can have only one version in review at a time. Submit the next version after the result is in. |
| Delivery | The reviewed zip file goes to users exactly as is. The server doesn’t change the package. |
| Unlisting | Click “Unlist” in the “My plugins” list, and the plugin disappears from the plugin list in Studio. When a new version is approved, it’s published again. |
When something goes wrong
-
Why do I get “This file could not be read as a .sshowplugin package.” when I import?
It means the zip couldn’t be read or the manifest doesn’t follow the rules. Check that both files are at the top level of the zip, that plugin.json is valid JSON, and that id, version, and main follow the rules. -
Why do I get “This plugin was built for a different plugin API version.”?
api in plugin.json differs from the editor’s API version. An existing plugin with the same id is also removed from the list. Change api to 1, pack it again, and import it. -
Why does nothing change when I call applyActions?
The reason in the returned skipped tells you why each action was skipped. Common causes are the id of an object in another scene, an unknown set key, and a src that isn’t an asset:// URL. For an object in another scene, give sceneId as well. -
Why does a rotation come out far too large?
Angles you read from transform are in radians, but angles you send in set are read as degrees. When you send back a value you read, multiply it by 180 / Math.PI. -
Why won’t script files, images, or fonts load?
A plugin UI can’t use the network or outside files. Write scripts and styles directly in the UI document, and embed images and fonts as data: URLs. -
Why don’t Space or Tab work inside my plugin?
Both keys go on to the editor as shortcuts. To keep focus moves and button presses working, call stopPropagation() for those two keys in keydown and keyup listeners attached to document. Calling preventDefault() also keeps them from the editor, but stops the browser default too. Keys aren’t forwarded while an input field has focus. -
Where do I see script errors?
In the web editor, errors from the plugin frame also show in the console of the browser’s developer tools. In the desktop app, developer tools don’t open. If a plugin you changed through folder watching doesn’t appear in the list or doesn’t work, pack the same files, import them in the web editor, and check with the browser’s developer tools.
Updated 10/12/2026 · The videos on this page were recorded in the current editor.