PodoLay Plugin Developer Guide (Plugin API 1.7)
A guide for people who build plugins. Get the SDK from the developer docs or Portal › Developer › Download the SDK (
podolay-plugin-sdk-<version>-en.zip— this guide is the README.md inside it; the Korean edition ispodolay-plugin-sdk-<version>.zip. The names without a version,podolay-plugin-sdk-en.zipandpodolay-plugin-sdk.zip, are always the latest version). Every API is described in detail in API.md (also in the SDK) — this document covers the workflow and the rules. Run the commands below in the folder where you unzipped the SDK. Type definitions:podolay-plugin.d.ts; examples:examples/. (Inside the PodoLay repository:packages/plugin-sdk/; design notes:docs/PluginPlan.md.)
1. At a glance
- A plugin is one folder:
podolay-plugin.json(the manifest) +main.js+ (optional) panel HTML and icons. main.jsruns in a sandbox isolated from the editor (a Web Worker inside an opaque-origin iframe). It cannot reach the editor's DOM, sign-in credentials or network, and talks to the editor only through the globalpodolayobject. Every call returns aPromise.- Because it is a worker, there is no
documentorwindow(worker APIs such asself,OffscreenCanvasandsetTimeoutare available). If you need UI, use a panel. - If a plugin stays blocked for a long time (it fails to answer a render, tool or event call within 8 seconds and also doesn't respond to the liveness check), the editor turns it off. Split heavy computation into small pieces or spread it across
awaits. - Graphics are drawn as a display list. The editor stores the result in the document, so it looks and prints the same for people without the plugin, in reader mode and in PDF.
- Every document change made with one
podolay.edit()call is one Undo step. If anything in it fails, nothing changes.
2. Getting started
Building with an AI coding agent (Claude Code, Codex, Antigravity, Cursor, VS Code Copilot)? Follow Build with AI agents from installation to your first request.
- Portal › My Account › Developer: apply as a developer → an administrator approves.
- Unzip the file from Portal › Developer › Download the SDK (
podolay-plugin-sdk-<version>-en.zip, Node 18 or later) — you get apodolay-plugin-sdk-<version>-en/folder (e.g.podolay-plugin-sdk-1.6.2-en/).node tools.mjsbelow is the one in that folder; upgrading to a new SDK: §8.9.1. Create a skeleton:node tools.mjs init my-plugin— a graphic plugin whose objects you draw by dragging with a tool. For a menu/shortcut command plugin, use--kind command. You can also pass--id com.yourdomain.name(it becomes the property of the first developer to upload it, so choose carefully) and--name "Name". The folder getspodolay-plugin.json,main.js,podolay-plugin.d.ts(for editor autocompletion), plusAGENTS.md,CLAUDE.md,jsconfig.jsonand.gitignorefor AI agents and type checking (and, for a graphic plugin, arendertesttest.mjs) — development files are not included in the zip you upload. Larger examples are inexamples/(manuscript grid, select same fill (panel), number objects (shortcut only), catalog builder (API 1.1 automated layout and network)). - Check:
node tools.mjs check my-plugin— checks the manifest, definitions,renderand permissions with the same rules as the editor and server, without opening the editor (§8.2). Dev server:node tools.mjs dev my-plugin(default port 5199) — plugin logs are printed in this terminal, and the editor reloads the plugin when you edit files. - Try it in the editor — either way:
- Sandbox editor (recommended):
node tools.mjs dev my-plugin --open— the editor opens without signing in, on a new document with default settings that saves nothing to the server, and loads this plugin. Drive it from another terminal withtools.mjs test,run,stateandshot(§8.10). AI agents use this route. - In your own document: open a document in the editor, append
&pluginDev=http%3A%2F%2Flocalhost%3A5199%2Fpodolay-plugin.jsonto the URL and reopen it. Works only with an approved developer (or administrator) account and only for localhost. Changes the plugin makes are saved like ordinary edits.
- Sandbox editor (recommended):
- When you edit files, the editor reloads the plugin on its own (the dev server announces file changes).
console.*output and errors, withmain.js:line, are collected in the dev server terminal and in the editor › Plugins menu › Log. Set breakpoints in the browser DevTools › Sources at<plugin id>/main.js(or putdebugger;in your code) — see §8 Debugging. - Publish:
node tools.mjs pack my-plugin(runscheckfirst and stops on errors) → Portal › Developer › Upload a version → Request review. Once approved, it is listed in the store (Dashboard › Plugins). People who already installed the plugin keep the version they installed — a new version is used only after they upgrade in Dashboard › Plugins (since 2026-10-27; there is no automatic update). To ship a fix, bumpversionand upload it as a new version (every version is reviewed).
In dev mode, storage is not written to the server; it lives only in that window.
3. Manifest podolay-plugin.json
| Field | Meaning |
|---|---|
id | Reverse domain (com.example.qr) — becomes the property of the first developer to upload it |
name, description | A string or a per-language object { "en": …, "ko": … } |
version | semver (1.2.0) — every version is reviewed anew |
apiVersion | "1.0" |
minEditor | Load only in this editor version or later (optional) |
main | The script to run in the sandbox |
permissions | document:read · document:write · selection · ui · storage · network:<host> (§7) |
privacyUrl | https URL of your privacy policy — required if the plugin has a network permission |
contributes.graphics | {type, name, usesFillStroke?, defaultSize?, inspector?} — plugin graphic types |
contributes.tools | {id, label, icon?, kind: 'createGraphic'·'custom', graphicType?} — toolbar buttons |
contributes.menus | {command, label} — header 'Plugins' › plugin name › item |
contributes.keybindings | {command, key} — mod+alt+q, alt+shift+f, f6 (a letter without a modifier is not allowed) |
contributes.panels | {id, title, html, width?, height?} — floating panel windows |
mod= ⌘ on macOS, Ctrl elsewhere. Shortcuts the editor already uses (including ones like ⌘F that ignore ⌥ and ⇧) are not bound, and a warning is logged.- Inspector fields
inspector: [{key, label, type: number·text·checkbox·select·color, min?, max?, step?, unit?, options?}]— when a plugin graphic is selected, they appear in the editor's own style; changing one updatesdata[key]and then callsrenderagain (one Undo step).
4. API
// Graphic type — render is a pure function that draws from the data and size alone
podolay.graphics.define('qr', {
defaults: { text: 'https://podolay.com' },
render(data, { width, height }) { return [{ t: 'rect', x: 0, y: 0, w: width, h: height, fill: '#000' }]; },
});
podolay.commands.define('regen', async () => { /* menus and shortcuts */ });
podolay.tools.define('lasso', { onDown(e) {}, onMove(e) { return previewItems; }, onUp(e) {} }); // kind: 'custom'
const ids = await podolay.selection.get(); // selection
const g = await podolay.doc.graphic(ids[0]); // document:read
const { created } = await podolay.edit('Align', tx => { // document:write
tx.set(g.id, { frame: { ...g.frame, x: 0 } });
tx.create({ kind: 'text', frame: { x: 0, y: 0, width: 100, height: 20 }, text: 'Hello' });
tx.setPluginData(g.id, { tag: 'hero' }); // this plugin's own property, on any object
});
await podolay.ui.toast('Done'); // no permission needed
await podolay.ui.showPanel('main'); // ui
await podolay.storage.set({ last: Date.now() }); // storage (per account, 64KB)
podolay.on('selectionChange', ({ ids }) => {}); // selectionChange · documentChange · historyChange · panelClose- All coordinates are in pt; object frames are in spread coordinates.
doc.info()gives each page's position within its spread. - What
tx.setcan change:frame,rotation,opacity,visible,locked,fill,stroke(more in later API versions). tx.createkinds:plugin(your own graphic type),rectangle,ellipse,line,text(unformattedtext, orstoryfrom 1.1).tx.removeonly removes objects placed directly on a spread (objects inside a group go with the whole group). Locked objects are not removed.
4.1 Text and filling templates (API 1.1)
Requires "apiVersion": "1.1" in the manifest (calling these from a 1.0 plugin is an error). Older editors that don't know 1.1 won't load the plugin.
const { para, char } = await podolay.doc.styles(); // [{id, name, key?}] — the built-in 'Basic' style has key '$base'
const slots = await podolay.doc.find({ text: '{{title}}' }); // text objects whose text contains the placeholder
const tagged = await podolay.doc.find({ tag: 'price', page: 0 }); // objects this plugin tagged with setPluginData
const story = await podolay.doc.text(slots[0].id); // [{ para, runs: [{ text, char?, font?, size?, color?, bold?, italic? }] }]
await podolay.edit('Fill catalog', tx => {
tx.setText(slots[0].id, 'New title'); // the whole text — keeps the current text's first formatting
tx.replaceText(slots[1].id, '{{price}}', '12,000'); // just the placeholder — surrounding formatting stays
tx.create({ kind: 'text', frame: { x: 40, y: 40, width: 300, height: 120 }, story: [
{ para: 'Title', text: 'New for spring' },
{ para: '$base', runs: [{ text: 'List price ' }, { text: '$12.00', char: 'Emphasis', color: { cmyk: [0, 100, 100, 0] } }] },
] });
});- A Story is a string (one paragraph per line) or an array of paragraphs
{ para?, runs: [...] }/{ para?, text }. Line breaks inside a run become spaces (split paragraphs with the array). - Formatting order: base → paragraph style (+ that paragraph style's character style) → the run's character style → the run's direct formatting (
font,size,color,bold,italic). The base is the document's 'Basic' style for new text, and forsetTextit is the current text's first-paragraph and first-character formatting — so the formatting the designer applied in the template is kept. - Styles are names (or ids) that exist in the document. An unknown name makes the whole edit fail (so nothing gets filled in wrongly). Built-in styles are
'$base'and'$none'. - For threaded text frames, any frame id in the chain addresses the whole text (the chain).
doc.find({ text })also matches each chain only once. replaceTextonly finds placeholders that lie entirely within one formatting run (if the formatting changes partway, it won't match). It never touches variable runs such as page numbers or footnotes.- Objects have no names. Find template slots by placeholders in the text (
{{field}}) or by a tag you attached withsetPluginDatato objects picked in your plugin's panel. - A text can hold up to 200,000 characters and 10,000 paragraphs.
4.2 Images (API 1.1)
const res = await fetch('https://cdn.example.com/p/123.jpg'); // network permission (§7) — or something drawn with OffscreenCanvas
const { asset, width, height } = await podolay.assets.upload(await res.arrayBuffer(), { name: '123', type: 'image/jpeg' });
await podolay.edit('Place photo', tx => {
tx.setImage(slotId, asset, 'fill'); // into a shape or image frame — drawn clipped to the shape
tx.create({ kind: 'image', frame: { x: 40, y: 300, width: 200, height: 150 }, asset, fit: 'fit' });
});assets.uploadcreates an image asset in this document under the user's account (document:write). Plan storage limits and format checks are the same as for an ordinary upload, and the original goes to print unchanged. png, jpeg, gif and tiff are kept as they are; webp is converted to PNG. Up to 30MB.- The
assetpassed to tx must be one this plugin uploaded in that window, or the path of an image already in the document. fit:fill(default; covers the frame proportionally and crops the overflow),fit(shows everything),stretch(ignores the aspect ratio). After placement the user can change it with the inspector's frame fitting options.
4.3 Editing pages (API 1.1)
const info = await podolay.doc.info(); // spreads[].pages[]: {index, label, master, …}, masters: [{id, name}]
const r = await podolay.edit('Catalog pages', tx => tx.duplicatePages({ from: 2, times: items.length })); // template page (2), once per product
const { pages, copies } = r.results[0]; // copies[k] = { original object id: object id in the k-th copy }
await podolay.edit('Fill catalog', tx => items.forEach((it, k) => {
tx.setText(copies[k][titleId], it.name); // original id found in the template → copy's id
tx.setImage(copies[k][photoId], it.asset);
}));
await podolay.edit('Tidy pages', tx => {
tx.addPages({ after: info.spreads.at(-1).pages.at(-1).index, count: 2, master: 'A-Master' }); // master: id or name, null = none
tx.applyMaster([0], null);
tx.removePages([2]); // remove the template page when done
});- Page numbers start at 0 and are the numbers at the moment the op is called (if an earlier op adds or removes pages, they shift).
pagesin the result are the numbers after the whole edit has finished. - If
addPageshas nomaster, the new pages follow the neighbouring page's master.duplicatePagescopies objects, the master and even removed master items. - An
editcontaining page ops is undone as one snapshot of the whole document (other ops in the same edit included). If it fails, nothing changes. - Exceeding the plan's per-document page limit fails the whole edit. One edit can add at most 1000 pages, and you can't remove every page. Not available while editing a master.
- To group several edits, such as duplicating and filling, into one step, use
podolay.batch(§4.5).
4.4 Text flow (API 1.1)
const st = await podolay.doc.textStatus(bodyId); // { frame, frames, lines, chars: [start, end), length, height, overflow }
if (st.overflow) {
const r = await podolay.edit('Frame for overflow', tx => tx.create({ kind: 'text', frame: next, story: '' }));
await podolay.edit('Thread', tx => tx.link(st.frames.at(-1), r.created[0])); // end of the chain → empty frame
}
const { height, lines } = await podolay.text.measure(story, 180, { like: bodyId }); // measures without changing the documenttextStatusreads from the same chain layout as the screen.charsgives the range of characters in this frame, andoverflowtells you whether the end of the chain overflows.link(from, to):fromis the end of a chain (it must have no next frame),tois an empty text frame that doesn't belong to another text.unlink(from)cuts the chain afterfrom(the text of the following frames gathers into the earlier ones). Title text and text on a path can't be threaded.text.measure(story, width, { like })gives the height and line count of the story set at widthwidth(pt, the text width excluding the frame's insets). Withlike, it uses that text's first formatting as the base (= the same formatting assetText). It measures with the fonts loaded right now — fonts and characters not yet used in the document may at first be measured with a fallback font. Confirm withtextStatusafter inserting.
4.5 Batches (API 1.1)
await podolay.batch('Build catalog', async (signal) => {
const r = await podolay.edit('Duplicate', tx => tx.duplicatePages({ from: 2, times: items.length }));
for (let k = 0; k < items.length; k++) {
signal.throwIfAborted(); // stop if the user cancelled
await podolay.ui.progress({ label: items[k].name, value: k + 1, max: items.length });
const { asset } = await podolay.assets.upload(await fetchImage(items[k]), { type: 'image/jpeg' });
await podolay.edit('Fill', tx => { tx.setText(r.results[0].copies[k][titleId], items[k].name); tx.setImage(r.results[0].copies[k][photoId], asset); });
}
}); // to the user, a single Undo step 'Build catalog'- The edits inside show up on screen right away, but the Undo history and server save happen once at the end (for the whole document). If fn throws or the user cancels in the progress window, the document goes back to how it was before the batch and
batchthrows an error (uploaded image assets remain — if the document doesn't use them, they can be deleted from the gallery). - Meanwhile the editor is covered by the progress window and the user can't edit. If the batch doesn't finish within 5 seconds after a cancel, the editor rolls it back. Other plugins can't modify the document during the batch.
ui.progress({ label, value, max })updates the progress window's text and bar (only inside a batch). Withoutmax, the bar shows an indeterminate animation.- Batches can't overlap. Page limits and the rules for each edit still apply.
- There is no total time limit (since the API 1.6 editor). While that plugin's batch runs, commands (60 s) and panel messages (8 s) don't fail by going over time either, and the edits inside redraw the screen at most once every 250 ms. A few dozen records per edit is a good size. Undo keeps two document snapshots, at the start and the end.
- An example of the whole workflow:
examples/catalog— duplicates a template page with{{field}}text and a 'photo slot' (tagged withsetPluginDatafrom a panel button) once per product and fills them. The data comes from a sample online API (dummyjson.com,network:permission) or pasted JSON. Don'tawaitlong-running work in the panel message handler; run it separately (the editor applies an 8-second timeout to every plugin call).
4.6 Inline objects (API 1.2)
Set "apiVersion": "1.2" in the manifest. Images, shapes and your own graphics placed inside text (inline anchors) take up one character position and flow with the text.
await podolay.edit('Insert icons', tx => {
tx.insertInline(bodyId, '{{icon}}', { kind: 'plugin', type: 'icon', data: { name: 'star' }, width: 12, height: 12 }); // at every placeholder
tx.insertInline(bodyId, null, { graphic: logoId }); // moves an object on the spread to the end of the text (its id goes away)
tx.setText(captionId, [{ runs: [{ inline: { kind: 'image', asset, width: 40, height: 30 } }, { text: ' Figure 1' }] }]);
});
const list = await podolay.doc.inlines(bodyId); // [{ index, kind, width, height, offset, placed }, …]
await podolay.edit('Remove', tx => tx.removeInline(bodyId, list[0].index));- Where to insert: placeholder text (every occurrence) · a character position (relative to
Graphic.text) ·null= end of the text. You can also insert with a Story run{ inline }. - In 1.2,
doc.textreturns inline objects as runs{ inline: { index, … } }— pass them back tosetTextunchanged and the objects are kept. - Inline objects have no id — to find one again, attach
pluginDatawhen inserting it and read it withdoc.inlines. Detailed rules: API.md §9.14 and §16.8.
4.7 Context menus (API 1.2)
Add context to an entry in the manifest's menus[] and it appears in the context menu instead of (or as well as) the header's Plugins menu. "apiVersion": "1.2".
"menus": [{ "command": "makeQr", "label": "Make QR code", "context": "object", "when": { "kind": ["rectangle"], "single": true } },
{ "command": "lookup", "label": "Look up selected text", "context": "text" }]podolay.commands.define('makeQr', async ({ ids }) => { … }); // the selected objects
podolay.commands.define('lookup', async ({ id, selection }) => { … selection.text … });context:header(default) ·object(objects) ·text(while editing text) ·canvas(empty area).whensets the conditions for showing an object menu item (kind, your own graphic type, single object only).- The command receives where it was invoked as its argument — the object ids, the right-clicked point (spread coordinates), and when editing text, the selected character range (relative to
Graphic.text). Details in API.md §3.5 and §5.3. - Testing: the mock editor and
tools.mjs testhandles havep.menuItems(context, ids)(the items that would appear there) andp.runMenu(command, { context, ids | id·start·end | point }).
4.8 Natural size and editing inline objects (API 1.3)
"apiVersion": "1.3". A graphic whose data decides its size (an equation, say) declares "autoSize": true in the manifest's graphics[] and defines size(data, frame). When the editor changes the data (an Inspector field, tx.setData, creating with a tool, tx.setInline) it fits the frame to that size and renders at that size (one Undo step).
podolay.graphics.define('math', {
defaults: { tex: 'x^2' },
size: (data, frame) => ({ width: measure(data.tex), height: 14 }), // without x/y the top-left stays put; null = keep the size
render(data, { width, height }) { … },
});
await podolay.edit('Edit', tx => tx.setInline(bodyId, 0, { data: { tex: 'x^3' } })); // edit an inline object in place (size too with autoSize)
await podolay.edit('To equation', tx => tx.replaceRange(bodyId, sel.start, sel.end,
{ inline: { kind: 'plugin', type: 'math', data: { tex }, width: 30, height: 14, baseline: 3 } })); // turn the selected characters into an object- Inspector field options
live: false(number fields only — commits once after the stepper settles) andhideInline: true(hidden when the object is selected as an inline object). - An inline object's
baseline= from its bottom edge up to the text baseline (pt) — denominators and subscripts go below the baseline. - Event
dataChange { id, before, after, origin }— when your object's data changes (Inspector,tx.setData, Undo, Redo). Details in API.md §5.1, §6, §9.15 and §16.8.
4.9 Commands while editing text, range formatting, text targets, package files (API 1.4)
"apiVersion": "1.4". For plugins that fix text (phonetic notation, transliteration, spelling).
"keybindings": [{ "command": "ipa", "key": "ctrl+alt+c" }],
"menus": [{ "command": "ipa", "label": "To IPA", "context": ["header", "text"] }]let table;
podolay.commands.define('ipa', async (arg) => {
if (arg?.context !== 'text') return; // while editing text, header menus and shortcuts also pass the text argument
table ??= await podolay.package.json('data/ipa.json'); // a data file in the package
const { start, end, text } = arg.selection;
await podolay.edit('IPA', tx => tx.replaceRange(arg.id, start, end, { runs: [{ text: convert(text, table), font: 'Charis SIL' }] }));
}); // afterwards the editor reopens editing with the caret right after the new text- Commands while editing text — commands called from the header menu or a shortcut also get the same
textargument as the text context menu while a text frame or table cell is being edited. The editor closes editing, calls the command and then reopens editing (the original selection if the text is unchanged, otherwise a caret moved by the number of characters added). If the plugin selects another place withselection.setText(id, start, end?), that selection stays. - Shortcut modifier
ctrl= ⌃ on Mac (Ctrl elsewhere) — combinations like ⌃⌥C couldn't be written withmod(⌘). Plugin shortcuts also work while text is being edited. podolay.text.selection()and thetextSelectionChangeevent — the selection of the text being edited{ id, start, end, text }(permissionselection). For previews in a panel.- Range formatting —
tx.replaceRange(id, s, e, { runs })(each run's character style and direct formatting on top of the formatting at start),tx.formatRange(id, s, e, { char?, font?, size?, color?, bold?, italic? })(the text stays; the result = characters formatted). - Text targets — wherever a text id is taken, a table cell
{ id: table, cell: [row, col] }or an inline text frame{ id: story, inline: n }works too.doc.texts()lists every text place in the document. - Package files —
podolay.package.read(path)(→ ArrayBuffer),text,json. A relative path inside the package, up to 20 MB; packages may also holdbin,csv,tsv. Code can't beimported — bundle it intomain.js. - Testing: in the mock editor,
p.editText(id, start, end)(the user edits text) followed byp.run(command)— the argument, reopening andtextSelectionChangebehave as in the editor. Details in API.md §5.3, §7.12, §7.13, §8.3, §9.16, §14 and §16.9.
4.10 Database publishing — copying text with formatting, table rows, picking files, autoflow (API 1.6)
"apiVersion": "1.6" (editor 2.14.12 or later). For plugins that repeat a template over data (CSV, JSON, photo folders) to build catalogs, directories and price lists.
const [csv] = await podolay.ui.pickFiles({ accept: '.csv' }); // a file on the user's computer — the editor shows a picker window
const photos = await podolay.ui.pickFiles({ directory: true }); // a whole folder ([{ name, path, type, size, bytes }])
const records = parseCsv(new TextDecoder().decode(csv.bytes));
await podolay.batch('Directory', async (signal) => { // no total time limit
for (const rec of records) {
signal.throwIfAborted();
const photo = photos.find(f => f.name === rec.photo);
const { asset } = await podolay.assets.upload(photo.bytes, { name: rec.photo.replace(/\.[^.]+$/, ''), type: photo.type, reuse: 'name' });
const r = await podolay.edit('Duplicate', tx => tx.copyRange({ id: tplId, start: 0, end: tplLen }, { id: bodyId, newParagraph: true }));
const { start, inlines } = r.results[0];
await podolay.edit('Fill', tx => {
tx.setImage({ id: bodyId, inline: inlines[0], path: [1] }, asset, 'fit'); // second child of the duplicated inline group
fillTags(tx, bodyId, start, rec); // the <field> tags via replaceRange (from the back)
});
}
await podolay.edit('Flow', tx => tx.autoflow(bodyId)); // overset text onto new pages and frames
});tx.copyRange(from, to)— duplicates a text range with its paragraph styles, character styles, direct formatting and inline objects (including groups and inline tables). Paragraph formatting follows the paragraph end (\n) — to move whole paragraphs, include the paragraph end in the range. Result{ start, end, inlines, dropped }(the inserted range, the ordinals of inline objects in it, the number of footnote/endnote references left out). Later ops in the same edit see the duplicated text.- Inline object addresses
{ id: story, inline: n, path?: [child index, …] }— reach children inside inline groups withtx.set,setPluginData,setImage,doc.graphic,doc.tableand text targets ({ id, inline, path, cell? }).doc.inlinesanddoc.graphicputpathon group children. - Table rows —
doc.table(target)(rows, columns, header and footer rows, merges, sizes —footerRowsis the real number of footer rows from editor 2.15.0),tx.insertRows(target, { after, count?, copyFrom? })(repeats template rows with formatting, text, backgrounds, heights and borders),tx.removeRows(target, at, count?). - Inserting whole paragraphs —
tx.appendText(id, story, { newParagraph? })andtx.replaceRange(id, s, e, { story }). The Story limit of 200,000 characters and 10,000 paragraphs is per input, so you can append repeatedly. When text overflows,tx.autoflow(id, { master?, maxPages? })inserts pages and threads frames (a page operation). - Files —
podolay.ui.pickFiles({ accept?, multiple?, directory? })(up to 5000 files and 1 GB, before the batch). With a panel you can also read files through the panel's<input type="file">or drag and drop and pass them withpodolayUI.postMessage(msg, [buffers…])(ArrayBuffers don't count toward the message character limit). - Assets —
assets.list({ name? })(the document's image assets),reuse: 'name'inassets.upload(reuse one with the same name and size) andsha256·reusedin its result. For content deduplication across windows, keep asha256 → assetmap insetDocData. - Formatting — Story runs
tracking,hScale,vScale,baselineShift,underline,strike,position,smallCaps; paragraphsalign,leading,spaceBefore,spaceAfter,indentLeft,indentRight,indentFirst.doc.textincludes them only when they differ from the default (for paragraphs, from the paragraph style). - Also —
tx.duplicate(id, { spread?, dx?, dy? })(result{ id, copies }), group children in thecopiesofduplicatePages.doc.find,doc.textsanddoc.graphicsalso find objects on the pasteboard, hidden objects and objects on hidden levels (masters only withmasters: true). Details in API.md §7.14, §9.17–§9.21, §10, §11.5, §13, §16.6 and §16.10.
5. Display list
[
{ t: 'rect', x, y, w, h, fill: '#ff0000', stroke: { color: { cmyk: [0, 0, 0, 100] }, width: 1, dash: [3, 2] } },
{ t: 'ellipse', cx, cy, rx, ry, fill: { gray: 30 } },
{ t: 'line', x1, y1, x2, y2, stroke: { color: '#000', width: 0.5, cap: 'round' } },
{ t: 'path', d: 'M0 0 L10 0 Q20 5 10 10 Z', fill: '#09f', rule: 'evenodd' },
{ t: 'group', opacity: 0.5, items: [ … ] },
]- Coordinates = the object's local pt (origin = top-left of the frame). If the size differs from when it was drawn, the editor stretches the drawing and, when the plugin is present, asks it to redraw at the new size.
- Colors:
'#rrggbb'(RGB) ·{cmyk:[c,m,y,k]}(0–100, printed with exactly these inks) ·{gray:k}(K ink). - Paths: SVG
M L H V C S Q T A Z(upper or lower case; arc flags may be written without separators). Arcs are converted to cubic curves (straight lines in older editor versions). - Limits: 5000 items, 1,000,000 path characters in total, nesting depth 8. If a list exceeds these or is malformed, the drawing is rejected and the reason is logged.
- 1.0 has no text or image items. If you need text, create an ordinary text frame with
tx.create({ kind: 'text' }).
6. Panels
Panel HTML is loaded under the same sandbox rules as the plugin (no network, inline scripts and styles only, images as data:). A panel doesn't call the editor API directly; it talks only to its own main.js:
// panel.html
podolayUI.postMessage({ type: 'apply', value: 3 });
podolayUI.onMessage(msg => { … });
// main.js
podolay.ui.onMessage((msg, panelId) => { … });
await podolay.ui.postMessage('main', { count: 3 });- Inside a panel,
<input type="file">(multiple,webkitdirectory), drag and drop andFileReaderwork. Pass the bytes without copying withpodolayUI.postMessage(msg, transfer)(API 1.6 editor; ArrayBuffers don't count toward the 4-million-character limit). Without a panel, usepodolay.ui.pickFiles(API.md §11.5).
7. Network (network:<host>)
"permissions": ["document:read", "network:api.example.com", "network:*.cdn.example.com"],
"privacyUrl": "https://example.com/privacy"- Requests can go only to the listed hosts, over https/wss.
*.matches one leading level (subdomains), and you can add a port, like:8443. Up to 10 hosts. IP addresses,localhost, private names (.local,.internal, etc.) and broad wildcards like*.comare not accepted. - Available:
fetch,WebSocket,EventSource.XMLHttpRequest,importScriptsand creating workers are always blocked (you can't download and run code). - Requests carry no cookies or referrer, and
Originisnull— **the remote API must sendAccess-Control-Allow-Origin: *(ornull) via CORS**. Authenticate with a token in a header. Don't put API keys in main.js (anyone can read it) — have users enter their own key in a panel and keep it instorage. - You can't connect to a database directly (this is browser code). Put an HTTP API in front of the database.
- In dev mode (
pluginDev), if the plugin has at least one network permission,http://localhost:*and127.0.0.1are also allowed (for testing against a mock API). Not in uploaded versions. - People installing the plugin see "Can send document contents to this server." and the list of hosts. If a new version adds network permissions, the editor keeps using the old version until the user upgrades and consents again (a version only ever changes when the user upgrades).
- The log window records
[network] GET api.example.com → 200for each request (paths and bodies are not recorded).
8. Debugging
8.1 Setup
- Develop in dev mode: start
node tools.mjs dev my-pluginand append&pluginDev=http%3A%2F%2Flocalhost%3A5199%2Fpodolay-plugin.jsonto the editor URL. Works only with an approved developer (or administrator) account and only for addresses on this computer (localhost). When the plugin loads, a "Development plugin loaded" notification appears. - Test in a test document — changes the plugin makes are saved like ordinary edits (you can revert them with Undo). Even simpler is the sandbox editor (§8.10):
node tools.mjs dev my-plugin --openopens the editor without signing in, on a new document that saves nothing to the server. - In dev mode,
storageis not written to the server; it lives only in that window (it is cleared when you reload).
8.2 Checking without the editor — tools.mjs check
node tools.mjs check my-plugin # ✗ error / ! warning / pass — exit code 1 on errors
node tools.mjs check my-plugin --json # { errors, warnings, info } — for agents and CI| What it checks | Examples |
|---|---|
| Manifest (editor and server rules) | Invalid fields, missing files (main, icons, panel HTML), shortcut syntax, a tool's graphicType, network and privacyUrl |
Running main.js | Runs the top-level code against a podolay stand-in — syntax and runtime errors (main.js:line) |
| Definitions | Whether the commands for menus and shortcuts, custom tools and graphics' render are defined |
render | Calls it with defaults at 1×1, 100×100, the default size and 2000×1200, and checks display list rules, same output for the same input, and timing |
| Code and permissions | APIs used without their permission (error), permissions declared but unused (warning), API 1.1–1.6 features with a lower apiVersion, async edit fn, DOM |
| Natural size (API 1.3) | autoSize without size or the other way round (warning), whether size(defaults) returns a shape the editor accepts |
The rules are the same as the editor's (the editor-side tests compare the two). Behaviour that depends on the network, timers or document content is checked in the editor.
8.3 Dev server terminal and automatic reload
Start node tools.mjs dev my-plugin and open the editor once with pluginDev, and then:
- Everything that goes into the plugin's log window (§8.4 below) is also printed in the dev server terminal —
21:04:12 [error] command run: TypeError: … (com.example.qr/main.js:5:12). When the editor connects you seeEditor connected, and when the plugin loads,불러옴 com.example.qr 0.1.1 — 편집기 2.11.0, API 1.1(loaded … — editor 2.11.0, API 1.1). - When a file in the folder changes, the editor notices within 1 second and reloads the plugin (manifest included). If a batch is running, it reloads after the batch ends.
--quiet= don't print logs,--no-watch= turn off automatic reload. You can also run it in the background and capture the output to a file:node tools.mjs dev . > dev.log 2>&1 &.
When developing with an AI agent (Claude Code etc.), use this loop — edit → check → node test.mjs → tools.mjs test, run and shot in the sandbox editor (§8.10). The AGENTS.md and CLAUDE.md created by init summarize the commands and rules.
8.4 Log window
Editor › Plugins menu (puzzle icon) › Log: plugin name. The same content is also printed in the browser console and the dev server terminal.
| What is collected | Example |
|---|---|
console.log, info, warn, error | Objects as JSON, Errors with their stack |
| Errors in plugin code (with location) | command boom: TypeError: … (com.example.qr/main.js:5:12) |
| Uncaught errors and Promise rejections | Uncaught ReferenceError: … (com.example.qr/main.js:8) · Uncaught (in promise) … |
| Startup failures (top-level errors, syntax errors) | Unexpected token ';' (com.example.qr/main.js:12) |
| Calls the editor rejected | render: 그림 목록이 올바르지 않습니다 — … (invalid display list), 권한이 없습니다: document:write … (permission missing) |
| Timeouts and hangs | 플러그인이 시간 안에 답하지 않았습니다 (plugin didn't answer in time), 응답이 없어 플러그인을 껐습니다 (turned off for not responding) |
| Network requests | [network] GET api.example.com → 200 (paths and bodies are not recorded) |
| Shortcut warnings | 단축키 mod+f 는 편집기가 이미 씁니다 — 붙이지 않았습니다 (the editor already uses mod+f — not bound) |
Up to the last 200 lines; each line is truncated at 2000 characters.
8.5 Browser DevTools
Plugin code runs in a Web Worker started from a hidden sandbox iframe inside the editor. Debug it with Chrome DevTools like any other script.
| To | How |
|---|---|
| Set breakpoints | Sources › in the threads/files list, <plugin id>/main.js (e.g. com.example.qr/main.js) — line numbers match your file. Or put debugger; in your code |
| Call the API directly | Switch the execution context at the top of the Console to that worker, then await podolay.doc.info(), await podolay.selection.get() … |
| Inspect network | The worker's fetch requests show up in Network. If CORS blocks one, the console says why |
| Inspect panels | A panel is a separate iframe — inspect it with Elements, and switch the console context to that iframe to call podolayUI |
While you are paused at a breakpoint for a long time, calls the editor sends in the meantime (render, events) hit the 8-second limit and are logged as failures. So that the plugin isn't turned off as 'not responding' while paused, resume as soon as you've finished inspecting (if it was turned off, use Reload).
8.6 Editing and reloading
| What you changed | What to do |
|---|---|
main.js, panel HTML, icons, manifest | Nothing — the dev server notifies the editor and it reloads (with --no-watch, use Plugins menu › Reload Development Plugins) |
| Tool buttons or menus left in an odd state | Reload the editor tab |
| Dev server port or address | Change pluginDev in the editor URL and reopen |
Reloading doesn't change the drawing of plugin objects already in the document — they are redrawn with the new render when their data or size changes.
8.7 Common errors
| Message (partial) | Cause and fix |
|---|---|
| The plugin manifest is invalid: … | The notification names the invalid field (permissions[2], contributes.tools[0].graphicType …). API.md §3 |
| Development plugins can only be loaded with an approved developer account | Apply and get approved at Portal › Developer |
| Couldn't load the development plugin | Check that the dev server is running and that the address ends in …/podolay-plugin.json |
권한이 없습니다: … (permission missing) | Add that permission to the manifest's permissions and reload. API.md §2 |
… 는 apiVersion 1.1 이상에서 씁니다 (requires apiVersion 1.1 or later) | Set "apiVersion": "1.1" in the manifest ("1.2"–"1.6" if the error says that version or later) |
render: 그림 목록이 올바르지 않습니다 — … (invalid display list) | An item or color format is wrong, or a limit (5000 items, path characters …) was exceeded. API.md §17 |
플러그인이 시간 안에 답하지 않았습니다 (plugin didn't answer in time) | The call took over 8 seconds — keep render light, and don't await long work in a ui.onMessage handler; run it separately |
| Plugin command failed | The command threw or ran longer than 60 seconds — check the location in the log. Use batch for long jobs |
응답이 없어 플러그인을 껐습니다 (turned off for not responding) | Synchronous code (an infinite loop, a big computation) blocked the worker — split it into pieces between awaits. Reload to turn it back on |
허용되지 않은 주소입니다: … (address not allowed) | The manifest lacks network:<host>, or the URL isn't https. §7 |
Failed to fetch (CORS in the console) | The remote server doesn't allow Origin: null — fix the server's CORS or use a different API |
열린 패널이 없습니다 (no open panel) | Call ui.showPanel before ui.postMessage. If the user closed it, the panelClose event tells you |
단락 스타일이 없습니다: … (no such paragraph style) / 쓸 수 없는 그림입니다: … (unusable image) | Check the style name (built-in: '$base') and the image path (the result of assets.upload) |
잠긴 개체는 … (locked object …) / 요금제의 문서당 쪽 수 한도 … (plan's per-document page limit …) | The state of the user's document — have the plugin tell the user with a notification |
edit and batch change nothing when they fail, so there is no need to roll the document back yourself after a failure.
8.8 Unit tests with the mock editor — podolay-mock.mjs
Test the logic of commands, edit, batch and render in Node without opening the editor. The SDK's podolay-mock.mjs emulates the podolay API on a small in-memory document with the same rules as the editor — permission and apiVersion checks, edit all-or-nothing, rejection of locked objects and page limits, the shape of results and copies, and validation of render output against the display list rules. The test.mjs created by init uses it (node test.mjs).
// test.mjs — node test.mjs
import assert from 'node:assert/strict';
import { loadPlugin } from '../podolay-plugin-sdk-1.6.2-en/podolay-mock.mjs'; // adjust to where the SDK is (upgrade rewrites it for a new SDK)
const p = await loadPlugin(new URL('.', import.meta.url).pathname, {
doc: {
pages: 2, // page count (or [{ master: 10 }, …]) — the mock lays pages out horizontally: page i has x = i × 595
graphics: [
{ id: 1, kind: 'rectangle', frame: { x: 10, y: 10, width: 50, height: 30 }, fill: '#ff0000' },
{ id: 2, kind: 'text', frame: { x: 10, y: 60, width: 200, height: 40 }, text: 'Value: {{v}}' },
{ id: 3, kind: 'rectangle', frame: { x: 100, y: 10, width: 40, height: 40 }, locked: true, pluginData: { slot: 'photo' } },
],
},
selection: [1, 3],
storage: { next: 5 }, // initial value of the account storage
});
await p.run('myCommand'); // a command from the manifest (same as from a menu or shortcut)
assert.equal(p.graphic(1).frame.x, 20); // objects in the same shape the plugin sees
assert.equal(p.history.length, 1); // Undo steps — a batch is one step
assert.equal(p.toasts.at(-1).kind, 'success');| Handle | What it does |
|---|---|
p.run(command) | Calls a command (API 1.4 — while editing text with p.editText, it gets the text argument like a header menu or shortcut, and editing reopens afterwards) |
p.menuItems(context, ids) · p.runMenu(command, at) | Context menus (API 1.2) — the items that would appear there · choosing an item (throws if it wouldn't appear; the command gets arguments in the same shape as in the editor) |
p.render(type, data, size) | Calls render and returns the items, validated against the display list rules (p.defaults(type) = default data) |
p.tool(id, 'onDown', { x, y }) | custom tool events — the preview drawing (validated) |
p.inspect(id, { key: value, … }) | The user changes Inspector fields — one Undo step per field, size too with autoSize, dataChange (inspector) |
p.inspectorFields(id | { text, index }) | The fields the Inspector would show, with values (for an inline object, hideInline fields are left out) |
p.createWithTool(toolId, frame?) | Create with a createGraphic tool — default data, autoSize size, selects the new object → new id |
p.undo() · p.redo() | Undo and redo (the step leaves and re-enters history) — dataChange (undo/redo), historyChange |
p.emit(name, payload) | Send an event by hand (selectionChange only with the selection permission) — usually they arrive by themselves |
p.events · p.flush() | The events delivered to the plugin [{ name, payload }] · deliver queued events now |
p.fromPanel(panelId, msg) | A message sent by a panel (to ui.onMessage) |
p.select(ids) · p.cancelBatch() | The user selects objects (selectionChange) · Cancel in the progress window |
p.editText(id, start?, end?) · p.textSelection | The user edits text (API 1.4 — [start, end) selected, null = editing ends, then textSelectionChange) · the selection being edited { id, start, end, text }. The mock has no tables, so text frames only |
p.graphic(id) · p.graphics() · p.pages() · p.pageOf(id) · p.docData() | Document state |
p.selection · p.history · p.toasts · p.progress · p.panelsOpen · p.panelMessages · p.storage · p.license · p.logs | Records |
Options: doc.styles (lists of paragraph and character styles), doc.masters, pageLimit (the plan's page limit), locale, fetch (a network stand-in), echo: true (also print logs to the console), dir (with createHarness, the plugin folder package.read reads from — loadPlugin uses its folder). podolay.package.read reads from the plugin folder by the editor's rules (relative path, 20 MB, package file types).
Events arrive by themselves, as in the editor — selectionChange (from selection.set, p.select, a context menu, removed objects, or the selection coming back after a batch; only when it changes), dataChange (when your object's data really changes — plugin for tx.setData, inspector for p.inspect, and p.undo/p.redo), historyChange (p.undo/p.redo), and documentChange { origins } (the reasons plugin, user, history collected and sent once — as the editor collects them for 0.2 s). They are delivered in order, and awaited, when a handle action (run, runMenu, inspect, undo…) finishes, so you can assert right after it. For an edit called outside an action (a timer, say) use await p.flush(). Errors thrown in handlers only go to p.logs, as in the editor (p.emit throws them). A manifest the editor would refuse (live on a text field, autoSize with 1.2, …) makes loadPlugin throw, as the editor wouldn't load it.
The mock editor doesn't compose text — line counts, heights and overflow from doc.textStatus and text.measure are estimates based on character counts, and nothing is drawn. Check those by running the same test.mjs in the real editor: node tools.mjs test my-plugin (§8.10). See the test.mjs files in the number-objects, select-same-fill and catalog examples.
Testing panel html — podolay-ui-stub.js: a panel talks only through the podolayUI the editor gives it, so panel html opened on its own in a browser doesn't work. Run the SDK's podolay-ui-stub.js before the panel and you get a podolayUI of the same shape (plus the editor panel's dark base look). With Playwright:
import { chromium } from 'playwright';
import { loadPlugin, wirePanel, PANEL_STUB } from '../podolay-plugin-sdk-1.6.2-en/podolay-mock.mjs';
const browser = await chromium.launch();
const page = await browser.newPage();
// ① The panel alone — inspect the messages directly
await page.addInitScript({ path: PANEL_STUB });
await page.goto('file://' + new URL('panel.html', import.meta.url).pathname);
await page.click('#apply');
assert.deepEqual(await page.evaluate(() => __podolayUI.sent), [{ type: 'apply', value: 3 }]); // panel → main.js
await page.evaluate(() => __podolayUI.deliver({ count: 3 })); // main.js → panel (ui.postMessage)
// ② Wired to the mock editor — the panel's postMessage goes to p.fromPanel, the plugin's ui.postMessage to the panel (before goto)
const p = await loadPlugin('.', { doc: { … } });
const page2 = await browser.newPage();
const wire = await wirePanel(page2, p, 'main');
await page2.goto('file://' + new URL('panel.html', import.meta.url).pathname);
await page2.click('#apply');
await wire.idle(); // until main.js has handled it and the reply reached the panel__podolayUI = { sent, deliver(msg), waitFor(pred?, timeout?), clear(), listeners }. Messages are copied with structured clone, as in the editor. To leave out the editor's base look, set window.__podolayUINoBaseCss = true first. Install Playwright in your plugin folder (npm i -D playwright — with Google Chrome installed, launch({ channel: 'chrome' })).
8.9 Before and after uploading
- SDK version:
node tools.mjs --version→PodoLay plugin SDK 1.6.0 (API 1.6).checkanddevalso show the version on their first line, and the dev server tells you if the connected editor's API version is newer or older than the SDK (meaning you should get a new SDK). If thepodolay-plugin.d.tsin your plugin folder is outdated,checkwarns. - New SDK notices: once a day,
dev,checkand--versionreadhttps://podolay.com/downloads/podolay-plugin-sdk.json(latest SDK version, API version and changes) and tell you when a newer SDK is out. Without network access they stay quiet. To turn it off:PODOLAY_NO_UPDATE_CHECK=1. - SDK language: the English edition (
podolay-plugin-sdk-<version>-en.zip) and the Korean edition (podolay-plugin-sdk-<version>.zip) differ only in the language of the tools' output, theAGENTS.md,CLAUDE.mdand skeleton filesinitwrites, the type-definition comments, the examples and the guides. To switch, setPODOLAY_SDK_LANG=ko(oren). Error messages the editor gives your plugin are always in Korean. - Upgrading the SDK — see §8.9.1 below.
node tools.mjs packrunscheckfirst (stops on errors), then checks allowed file extensions and the server's limits (5MB zip, 200 files, 20MB unpacked). Development files (AGENTS.md, CLAUDE.md, jsconfig.json,*.mjs, dev.log, test/) are left out. Files are compressed with deflate (already-compressed ones such as png, jpg, webp and woff2, and anything that wouldn't shrink, are stored as is) — the 5MB limit applies to the compressed zip.--storeturns compression off.- When you upload to the portal, the server checks again and tells you what's wrong. Versions submitted for review are run by an administrator in the real editor.
- Editors of people who installed the plugin have no log window — when a problem is reported, load the same version in dev mode and reproduce it with the same document state. Report failures the user should see with
ui.toast(…, 'error').
8.9.1 Upgrading the SDK — where to download, where to unpack, which commands
Each SDK version unpacks into a new folder whose name carries the version (zip name = folder name inside). Don't unpack over the old folder: unpack next to it, then move your plugin folders over with the new SDK's tools.mjs. The SDK path is written into your plugin folder's AGENTS.md and test imports, and only upgrade run by the new SDK's tool rewrites it to the new folder.
| What | Address / name |
|---|---|
| Find the latest version | the sdk field of https://podolay.com/downloads/podolay-plugin-sdk.json (that version's zips: zips.en, zips.ko) |
| English edition zip | https://podolay.com/downloads/podolay-plugin-sdk-<version>-en.zip → unpacks to podolay-plugin-sdk-<version>-en/ |
| Korean edition zip | https://podolay.com/downloads/podolay-plugin-sdk-<version>.zip → unpacks to podolay-plugin-sdk-<version>/ |
| Always the latest (no version) | https://podolay.com/downloads/podolay-plugin-sdk-en.zip / podolay-plugin-sdk.zip — still unpacks to a versioned folder |
| Where to unpack | the folder that holds your current SDK folder (next to it). The first section of your plugin's AGENTS.md shows that path |
Example: the work folder ~/Documents/podolay holds podolay-plugin-sdk-1.6.2-en/ and my-plugin/, and the new version is 1.6.3 — from the plugin folder:
cd ~/Documents/podolay/my-plugin
curl -s https://podolay.com/downloads/podolay-plugin-sdk.json | grep '"sdk"' # "sdk": "1.6.3"
curl -L -o ../podolay-plugin-sdk-1.6.3-en.zip https://podolay.com/downloads/podolay-plugin-sdk-1.6.3-en.zip
unzip -q ../podolay-plugin-sdk-1.6.3-en.zip -d .. # → ../podolay-plugin-sdk-1.6.3-en/
node ../podolay-plugin-sdk-1.6.3-en/tools.mjs upgrade . --dry-run # only show what would change
node ../podolay-plugin-sdk-1.6.3-en/tools.mjs upgrade . # ← always the NEW SDK's tools.mjs
node ../podolay-plugin-sdk-1.6.3-en/tools.mjs check . && node test.mjsOn Windows (PowerShell): Invoke-WebRequest <address> -OutFile ..\podolay-plugin-sdk-1.6.3-en.zip → Expand-Archive ..\podolay-plugin-sdk-1.6.3-en.zip -DestinationPath ... With several plugins, run node <new SDK folder>/tools.mjs upgrade <plugin folder> for each. Once all are moved, the old SDK folder (podolay-plugin-sdk-1.6.2-en/) and the zip can be deleted.
- Don't
upgradewith the old tool — the old folder'stools.mjs upgradeonly matches the old version. Check withnode <SDK folder>/tools.mjs --version. - Coming from SDK 1.6.1 or older: those zips unpacked to
podolay-plugin-sdk/without a version. The new zip makespodolay-plugin-sdk-<version>-en/next to it — run the new folder'stools.mjs upgradeas above and the plugin folder points at the new folder; thenpodolay-plugin-sdk/can be deleted. - An AI agent without network access asks the human to "download the zip above and unpack it in
<where to unpack>", then continues from theupgradestep.
upgrade replaces podolay-plugin.d.ts with the new version, rewrites only the SDK section of AGENTS.md (<!-- podolay-sdk:begin --> … end, SDK paths and upgrade commands included; your notes below it are kept — an AGENTS.md from SDK 1.1.x has no markers, so it is kept as AGENTS.old.md and a new one is written), creates CLAUDE.md and jsconfig.json if missing, points the podolay-mock.mjs and podolay-remote.mjs imports in your *.mjs tests at the new SDK, and lists what changed since your old version. It never touches your plugin code or manifest. You can leave apiVersion as it is — 1.1–1.5 plugins keep running in a 1.6 editor. Raise it only when you use the new API (editors that don't know that API then won't load the plugin). Afterwards run check → node test.mjs → tools.mjs test.
8.10 Sandbox editor — testing in the real editor without a server and driving it from the terminal
node tools.mjs dev my-plugin --open # dev server + sandbox editor in the browser (without a browser window: --headless)The sandbox editor is an editor that opens without signing in (…/editor/vX.Y.Z/index.html?sandbox=1&pluginDev=…).
- Document: a new document with default settings — the same defaults as the portal's 'New Document' (A4, 12.7mm margins, 3 pages, document language = UI language).
- Never touches the server: no save, autosave, PDF or upload, and it neither reads nor writes the server's
/api. Even if you are signed in in the same browser, it doesn't use that account's token. - Plugins: installed plugins are not loaded — only the plugin under development. There is no permission consent dialog (same as dev mode). Images from
assets.uploadexist only in that window's memory. - Closing the tab discards the document. CMYK colors are shown with an on-screen approximation (the color conversion tables live on the server).
With dev running, drive the editor from another terminal. Results come back to the terminal, and the exit code is 1 on failure.
| Command | What it does |
|---|---|
node tools.mjs status | Editor and API versions, loaded plugin (state, commands), document |
node tools.mjs run <command> [--arg JSON] | Runs a plugin command — waits until it finishes (batches included) and prints the logs, notifications and Undo steps from that time |
node tools.mjs panel <panel> '<JSON>' · emit <event> [JSON] | Sends the plugin a message as if from a panel · an editor event |
node tools.mjs state [-o f.json] | Document state — objects (as the plugin sees them), selection, storage, Undo steps, notifications, logs, progress, panel messages |
node tools.mjs shot [page] [--scale 1.5] [-o p.png] | A page image as PNG (inside the trim, without guides) — AI agents read the image file to check it visually |
node tools.mjs shot [page] --ui [-o p.png] | The same image with the selected objects' selection frame and handles drawn on top, plus handle positions and Inspector values in p.ui.json |
node tools.mjs raw [-o doc.json] | The document's serialized JSON — can be reused with reset --fixture |
node tools.mjs select <id…> · undo · redo · reload | Select · undo · redo · reload the plugin |
node tools.mjs reset [options] | A new test document (below) |
node tools.mjs test my-plugin | Runs the folder's test.mjs in the real editor (instead of the mock editor) |
reset options:
- Trim size and pages:
--size a4|a5|letter|6x9|<width>x<height>[pt|mm]·--pages n·--mode double(facing pages) - Language and writing direction:
--lang ko|en|ja|zh-Hans|…·--vertical(vertical writing) - Primary text frame:
--primary - Test document:
--fixture f.json(a document JSON inside the dev server folder) ·--doc spec.json({ graphics, selection, storage }— the same shape as the mock editor'sdoc.graphics, including groupskind: 'group'(children), tableskind: 'table'(table) andimageon shapes — from editor 2.14.13. The document'stableStylesandfooterRows,tableStyleandcellStyleson tables from editor 2.16.3; afill(colour) on a cell style goes into the cells — the mock editor keeps names only) - Testing with a real layout (editor 2.17.2 or later) — in a tab that opened an account layout with
pluginDev, plugin menu › Export as test document downloads the layout as<title>.fixture.json. Put it in the plugin folder and runreset --fixture <title>.fixture.jsonin the sandbox — automated tests on the real layout without changing it (images use server addresses and may not show in the sandbox). - Read-only remote control of a real layout — add
&pluginRemote=1next topluginDev=…in an account layout's address and that tab takes onlystatus,state,rawandshot(write commands are refused). Use it to check sample layouts from the terminal. Without it a real tab doesn't attach to remote control (so it never takes over sandbox tests). - An account layout tab takes only the plugin id it loaded first — if you start another plugin on the dev server, that tab doesn't load it and says so (reopen the tab).
Give dev the same options and the document is set up that way when the editor first opens. By default the editor opens the default version on production (podolay.com). Choose another version with --editor vX.Y.Z | local | <url>.
The same test on both sides: node test.mjs runs in the mock editor, and node tools.mjs test . in the real editor. The latter sets the environment variable PODOLAY_EDITOR, which makes the mock editor's loadPlugin return the handle from podolay-remote.mjs instead.
- Handle: same shape.
run,runMenu,menuItems,select,fromPanel,emit,render,defaults,undo,redo,graphic,graphics,pages,selection,history,toasts,progress,panelsOpen,panelMessages,storage. Only in the real editor:shot(page, { ui })(PNG Buffer; withui, handle positions and Inspector values too),raw(),reset(opts). Only in the mock editor:inspect,inspectorFields,createWithTool,events,flush(in the real editor the Inspector and tools are used by hand). - Ids and coordinates: the handle translates the ids written in the test (1, 2, 3…) and mock editor coordinates (page i at x = i × page width) into the editor's ids and spread coordinates when sending, and back when receiving. The real editor coordinates are in
graphic(id).editorFrame. Objects the plugin creates keep their editor ids. - Assertions that depend on composition: assertions that rely on text composition, such as line counts and overflow, hold only in the real editor. The mock editor gives estimates.
// Directly from Node — after starting dev --open
import { connectEditor } from '../podolay-plugin-sdk-1.6.2-en/podolay-remote.mjs';
const p = await connectEditor({ doc: { pages: 2, graphics: [{ kind: 'text', frame: { x: 40, y: 40, width: 300, height: 80 }, text: '{{title}}' }] } });
await p.run('fill');
writeFileSync('page0.png', (await p.shot(0)).png);Good to know:
- Tabs: only the editor tab that connected last receives commands. Reloading reconnects that tab. A tab that was pushed aside keeps asking every few seconds and, when the tab receiving commands closes (stops asking for 3 seconds), re-attaches by itself — commands sent in between go to it.
shot --ui: handles are drawn where the editor puts them (eight around the frame plus the rotation handle above; graphics with end-point handles, such as lines, at those points), with the object's rotation and skew, at their on-screen size (8 px). Positions inp.ui.jsonare pt from the page origin; Inspector values are included when the selection is a single plugin object (withouthideInlinefields for an inline object). The Inspector panel itself isn't captured — a person checks how the fields look.- Security: only a fixed set of commands exists. No arbitrary code runs in the editor page. So that a web page can't drive the editor through the dev server, commands can be sent only from Node (the terminal).
--headless: Playwright must be installed in the plugin folder or the current folder (npm i -D playwright). If Google Chrome is installed, no separate browser download is needed.- https editor →
http://localhost: Chrome allows this combination (allow local network access if asked). If your browser blocks it, open it in Chrome.
9. Review criteria (summary)
- Do the manifest permissions match what the plugin does? (Don't declare permissions you don't use.)
- Change the user's document only when the user asks for it (commands, tools, the inspector). Don't modify the document as soon as it opens.
- Is render pure and fast? (Same input → same drawing, within a few tens of ms.)
- Do the name, description and panel text match the actual features? Does it avoid imitating other trademarks?
- Is each network host truly necessary for the described feature, and does the
privacyUrlsay what is sent? If hosts were added between versions, why.
10. Paid plugins and sales
The seller is PodoLay (Podo Co., Ltd.). Paid plugins are sold by PodoLay in the plugin list inside the editor. Payments, receipts and taxes are handled by our Merchant of Record, Paddle; the customer's purchase contract, refunds and support are PodoLay's responsibility. The conditions are in Article 6-2 of the Terms of Service and in the Refund Policy.
- What is sold now — only plugins made by PodoLay itself.
- What a buyer gets — a one-time payment, a license to use the plugin on that account without time limit, later updates included. It works on any plan, and any purchase can be fully refunded within 14 days of payment, for any reason (the license is revoked on refund).
- Prices — one of a fixed set of tiers ($9, 19, 29, 39, 49, 79, 99, 149, 199, 299, 499, 749, 999). A plugin may have a trial period (from the first install).
10.1 Selling developer plugins — coming later
Sales of plugins made by other developers will open after confirmation from our payment provider. Until then, developers can publish free plugins only. When it opens, it will work like this:
- The developer signs a publishing and resale agreement with PodoLay and grants PodoLay the right to distribute and sell the plugin.
- PodoLay sells the plugin as the seller, through its own checkout. Developers never take payments, enter into contracts with customers, or handle refunds or support.
- PodoLay reviews every version before publishing it (section 9), and plugins run in an isolated environment inside the editor, only within their declared permissions. PodoLay can unlist a plugin or revoke licenses at any time.
- The developer picks one of the tiers above as the price and sets the trial period (0 days allowed).
- Under the agreement, PodoLay pays the developer a monthly royalty of 85% of net sales (excluding taxes and refunds). PodoLay bears the payment fees, and the minimum payout is $100. Refunded sales are excluded from the royalty.