plugin SDK — Build your own plugin

Build your own plugin

The same contract every first-party plugin is written against: a typed host, the object model, the scripting surface and a validator — so a plugin of yours sits in the editor exactly like ours.

MPL-2.0 OR PMEL·canary — usable, moving fast·0.2.48-canary.0·github.com/paged-media/plugin-sdk ↗

30
members on the host a bundle receives
8
first-party plugins on the same contract
3
packages: contract, runtime, validator
The contract

The same packages our plugins use

paged.draw, paged.image, paged.sheet and the rest are written against the three packages you would use. Where the contract still falls short, the escape hatch is marked: host.editor, the raw editor handle, which will not survive the move to an isolate.

@paged-media/plugin-api
The contract, as types only: the manifest and its JSON Schema, the bundle lifecycle, the BundleHost surface, the contribution shapes and the engine's wire types. To be frozen at version 1.
@paged-media/plugin-sdk
The runtime: defineBundle, loadBundle with apiVersion negotiation, the host adapter, DisposableStore, the page-drag gesture helpers and a headless host that runs a bundle in Node against the real engine.
@paged-media/plugin-cli
paged-plugin validate: the manifest schema, the namespace rule, panel files, and wasm names, paths and sizes.
Fig. 09
The plugin contract: a bundle imports only types from plugin-api; the editor's host adapter implements the same interface and hands the bundle live values through activate(host); the host talks to the engine wasm in typed mutations and receives snapshots and change events.your bundlemanifest.jsonactivate(host)plugin-apitypes onlyBundleHost · manifesteditor hostcreateBundleHostloadBundleengine (wasm)canvas-wasmin a workerimport typeerased at buildsame interfaceimplemented herewire ops ↓snapshots, events ↑at run time · activate(host) → values in · tools, panels, commands → disposables outstability · contract frozen at v1 · runtime moves faster · cli on its own cadence
Fig. 09 — Types from the contract, values from the host — the bundle never imports the editor.click a part to inspect it

Types come from the contract, values from the host. A bundle’s module graph holds no editor code: everything it touches at run time arrives through the host object handed to activate. Every id it registers starts with its manifest id, every door it uses must be declared in its manifest, and everything it registers is tracked — disposing a bundle leaves the editor as it found it.

The plugin SDK is licensed MPL-2.0 OR PMEL — deliberately more permissive than the AGPL editor, so a plugin built on it can carry any license you choose.

The manifest

Say what you touch

A manifest names the plugin, the API range it was built for, the capabilities it needs and what it contributes. The loader refuses an apiVersion that does not match; the host refuses a door the manifest did not declare. This is paged.draw’s own.

{
  "id": "media.paged.draw",
  "name": "paged.draw",
  "version": "0.5.0",
  "apiVersion": "^0.2",
  "publisher": "paged.media",
  "capabilities": {
    "document": { "read": "broad", "write": "scoped" },
    "rendering": ["overlay", "hitTest"],
    "editContext": ["vectorGraphic"],
    "assets": ["images"],
    "network": false,
    "workers": { "max": 2 },
    "wasm": [
      { "name": "trace", "path": "wasm/trace_js_bg.wasm",
        "purpose": "compute", "maxBytes": 2097152 }
    ]
  },
  "contributes": {
    "tools": ["media.paged.draw.tool.curvature", "media.paged.draw.tool.shapeBuilder"],
    "panels": ["media.paged.draw.panel.appearance", "media.paged.draw.panel.symbols"],
    "commands": ["media.paged.draw.command.outlineStroke", "media.paged.draw.command.offsetPath"],
    "importers": ["media.paged.draw.importer.svg"],
    "exporters": ["media.paged.draw.exporter.svg"],
    "partTypes": [
      { "type": "symbolLibrary", "role": "spec", "format": "json", "linkable": false }
    ],
    "editContexts": [{ "type": "vectorGraphic", "entry": "doubleClick" }]
  }
}
packages/draw-bundle/manifest.json in paged-media/plugin-draw — excerpt; the full file lists 21 tools, 12 panels, 107 commands and 7 part types.
activate(host)

One function, one host

A bundle is a manifest and an activatefunction. The host it receives carries the contribution surface, typed document reads, the one write path into the engine’s undo history, the selection, the viewport, overlays, storage, diagnostics, the object model — and supports(), which answers “can I?” at run time instead of a version sniff.

import { defineBundle } from "@paged-media/plugin-sdk";
import type { PluginManifest } from "@paged-media/plugin-api";
import manifest from "./manifest.json";

export const fadeBundle = defineBundle({
  manifest: manifest as PluginManifest,
  activate(host) {
    host.contribute.command({
      id: "com.example.fade.command.fadePage",
      title: "Fade the text frames on page 1",
      category: "Fade",
      handler: async () => {
        // The object model: one selector grammar over core and every plugin.
        const frames = await host.objects.query("page:#1 textFrame");
        const result = await host.objects.batch(
          frames.map((address) => ({ op: "set" as const, address, path: "frameOpacity", value: 40 })),
          { label: "Fade text frames" }, // one batch = one undo step
        );
        host.log.info(`${frames.length} frames · applied: ${result.applied}`);
      },
    });
    host.contribute.keybinding({ key: "cmd+shift+h", command: "com.example.fade.command.fadePage" });

    // The host tears down everything registered through it on dispose.
    return { dispose() {} };
  },
});
A sketch on real members: host.contribute.command, host.contribute.keybinding, host.objects.query, host.objects.batch, host.log.
The object model and scripting

One address for everything

Every object in a document — a page, a frame, a swatch, a style, a paragraph — has an address, and a selector grammar finds them: page:#1 textFrame, group > *. A plugin can add its own kinds to that model through host.contribute.objectModel, and from then on its objects answer the same query, get, set and batchas the engine’s own. paged.draw contributes ten kinds this way, from paths to symbols and blends.

// A paged.* script: the same edit, from the editor's script panel.
// Reads return JSON strings, so parse before you index.
const frames = JSON.parse(paged.query("page:#1 textFrame"));
const results = JSON.parse(
  paged.batch(frames.map((address) => ({ op: "set", address, path: "frameOpacity", value: 40 }))),
);
console.log(results.filter((r) => r.ok).length, "of", frames.length, "frames faded");
paged.query and paged.batch are host functions of the engine's scripting crate; a batch of sets is all-or-nothing and lands as one undo step.

The same scripts run in the editor’s script panel and on the command line — reference at docs.paged.media/scripting ↗

Contributions

Everything the editor has, you can add

Tools
A rail entry with an icon, a shortcut, a cursor, tool options and a gesture handler.
Panels
React panels, or declarative schema panels whose rows bind to object-model properties.
Commands, keys and menus
Commands with a handler, key bindings that call them, and menu entries.
Importers and exporters
Open a format by extension or MIME type; write the document out as bytes and a file name.
Object types and edit contexts
A content type of your own, with an edit context you enter by double-click and its own pointer, key and undo hooks.
Part types
Your plugin's own data as parts inside the .paged container, under its own namespace.
Overlays and scene layers
Tool previews and overlays on the canvas, and vector, text or image content the engine paints in a frame.
Object-model kinds
Typed kinds and commands that the selector grammar, panels and scripts all reach.
Start here

Your first plugin

The tutorial walks through a complete bundle: a manifest with your reverse-DNS id, an activate that contributes a command and a key binding, and the honesty test — activate, dispose, and check that the editor is unchanged. Its runnable twin, the plugin template, consumes only the published canaries, and declares plugin-api and plugin-sdk as peer dependencies so the editor provides the one copy. A npm create flow ships at the v1 freeze.

npm install --save-dev @paged-media/plugin-cli@canary
npx paged-plugin validate src/manifest.json
Validate a manifest before the editor ever sees it.

Honest limits

Incubating, in the open

The API is at version 0.2 and every release is a canary: breaking changes are allowed until 1.0, and a bundle states the range it was built for. Plugins run in the editor’s own process. The capability gate keeps a manifest truthful about what a bundle touches; it is not a security boundary, and there is no sandbox, no loading from a URL and no signature check yet. The isolate that will host plugins is designed as a second implementation of the same host interface, so bundle source does not change when it lands.