Minimal starter plugin for the Instatic. Use this as the base for your own plugin.
# 1. Copy the template
cp -r examples/plugins/template my-plugin
# 2. Edit plugin.json
# Change id, name, description, author, and the permissions your plugin needs.
# 3. Edit editor/index.js and server/index.js
# Implement your activate() / deactivate() logic.
# 4. Package and install
cd my-plugin
zip -qr ../my-plugin.zip .
# Upload the .zip from the admin UI → Plugins → Install plugin| File | Purpose |
|---|---|
plugin.json |
Plugin manifest — identity, permissions, entrypoints |
editor/index.js |
Editor entrypoint — commands, toolbar buttons, palette providers |
server/index.js |
Server entrypoint — lifecycle hooks, CMS routes |
The template demonstrates all three levels of palette integration:
Any command registered with api.editor.commands.register automatically appears in the Command Spotlight palette under "Plugin commands". No extra code needed:
api.editor.commands.register({
id: 'acme.template.ping',
label: 'Template Ping',
run: () => ({ message: 'Done!' }),
})Use api.editor.palette.registerCommand for commands authored specifically for the palette — with subtitle, icon, argument collection, or workspace gating:
api.editor.palette.registerCommand({
id: 'acme.template.greet',
label: 'Greet user…',
subtitle: 'Collect a name, then say hello',
iconName: 'person-wave',
destructive: false,
workspaces: ['any'],
args: [
{ id: 'name', label: 'Name', type: 'text', placeholder: 'Your name' },
{ id: 'tone', label: 'Tone', type: 'select', options: [
{ value: 'formal', label: 'Formal' },
{ value: 'casual', label: 'Casual' },
]},
],
run: () => {},
})Register a provider to return dynamic search results on each keystroke. Results appear under your provider's label as a group in the palette:
api.editor.palette.registerProvider({
id: 'acme.template.items', // must start with "<pluginId>."
label: 'My items',
search: async (query) => {
const res = await fetch('/admin/api/cms/plugins/acme.template/runtime/items?q=' + query)
const data = await res.json()
return data.items.map(item => ({
id: item.id,
title: item.title,
subtitle: item.category,
run: async () => { /* open item, navigate, etc. */ },
}))
},
})Both registerCommand and registerProvider require the editor.commands permission in plugin.json.
The template requests:
| Permission | Why |
|---|---|
cms.routes |
Register a /status health-check route |
editor.code |
Required for entrypoints.editor — editor entrypoints run unsandboxed in the admin window |
editor.commands |
Register commands + palette commands and providers |
editor.toolbar |
Add a toolbar button |
Remove permissions you don't need — users see the full permission list before installing.
A plugin that drops its editor entrypoint (and any app-kind admin pages) can drop
editor.code too; everything else then runs inside the QuickJS sandbox.