// Developer Guide

Plugin Developer Docs

Build plugins for the PioMode desktop app: add new tools for the AI, dock your own panel next to a session, send system notifications, parse new file formats. One manifest.json plus one entry script is enough to get started.

01Overview

A PioMode plugin is a plain Node.js program. After installation the app forks it as a standalone child process (pure Node, no Electron API) and talks to it over inter-process messages. No SDK package is required; all you need is process.on('message') and process.send().

Everything a plugin can do is declared statically in manifest.json: the pre-install consent page and marketplace review can tell which tools it adds and which permissions it needs without running any plugin code. All UI (toolbar icons, settings, command palette, context menus) is rendered by the host from the declaration; plugin code never runs inside the main window. Only docked views and floating panels are your own HTML, and they run in isolated sandboxed pages.

Capabilities at a glance

Manifest fieldCapabilityWhere users see it
toolsAdd callable tools for the AIThe AI calls them automatically in conversation; tool calls are shown in the session
viewsPersistent view docked in the session's right sidebarAn extra icon in the session toolbar opens your page on the right
uiStandalone floating windowOpened from the "⋯" menu in the plugin list
sessionActionPersistent action icon in the session toolbarOne click runs the tool you specify
configurationEntries on the settings pageSettings → Plugin configuration; values are passed with every call
commandsCtrl/Cmd+K command palette entriesCommand palette
keybindingsGlobal keyboard shortcutsA key combination triggers a command
menusSession context-menu itemsThe "⋯" / right-click menu on a session row
documentParsersParse files dropped into the input boxCalled automatically when the user drops a matching file
activationEventsStart together with the appInvisible; for plugins that need background timers
permissionsDeclare required permissionsPre-install consent page

02Quick Start

A minimal plugin needs only two files.

my-plugin/
├── manifest.json   # plugin manifest (required)
├── index.js        # entry script, referenced by manifest.main (required)
├── view.html       # docked view page (optional, manifest.views[].entry)
└── ui.html         # floating panel page (optional, manifest.ui.entry)

1. Write manifest.json

{
  "id": "com.example.hello",
  "name": "Hello Plugin",
  "version": "1.0.0",
  "description": "Say hello to the user.",
  "main": "index.js",
  "permissions": [],
  "tools": [
    {
      "name": "say_hello",
      "summary": "Greet the user by name",
      "description": "Return a greeting for the given name. Call this when the user explicitly asks the plugin to say hello.",
      "parameters": {
        "type": "object",
        "properties": {
          "name": { "type": "string", "description": "Who to greet." }
        },
        "required": ["name"]
      }
    }
  ]
}

2. Write the entry script

// The entry script runs in a standalone Node.js child process (ELECTRON_RUN_AS_NODE=1).
// Only Node built-ins and dependencies shipped inside the plugin folder are available;
// there is no Electron API.
process.on('message', (msg) => {
  if (!msg || msg.type !== 'call_tool') return;
  const { requestId, toolName, params } = msg;
  try {
    if (toolName !== 'say_hello') throw new Error(`unknown tool: ${toolName}`);
    process.send({
      type: 'tool_result',
      requestId,
      content: [{ type: 'text', text: `Hello, ${params.name}!` }],
    });
  } catch (err) {
    process.send({ type: 'tool_error', requestId, message: err.message });
  }
});

3. Install into the app

Open PioMode → top bar "Tools" → "Plugins" → "Install locally" in the top-right corner, then pick the plugin folder (or a zip of it). A consent page lists the declared permissions and every tool before installation; confirm to enable.

4. Try it

In any session, tell the AI "ask the plugin to say hello to Alice". Based on the tool description the AI decides to call say_hello, and the call and its result appear in the session.

TIP
The AI inside PioMode can also generate plugins for you (say "write me a plugin that does X" and it calls create_local_plugin). Generated plugins are disabled by default until you review the code on the plugin page and enable them manually. This guide applies equally to reading and modifying AI-generated plugins.

03Manifest Fields

FieldTypeRequiredDescription
idstringYesReverse-domain style, e.g. com.example.my-plugin. Letters, digits, dots and hyphens only. Immutable once created; installing the same id again is treated as a reinstall.
namestringYesPlugin name. Together with description it is fed to the model as context, so English is recommended.
versionstringYesSemantic version; the marketplace uses it to detect updates.
descriptionstringYesOne sentence describing what the plugin does (English).
authorstringNoAuthor.
readmestringNoLong-form description shown on the marketplace detail page, supports Markdown. Images inside it must be https URLs — relative paths to files bundled in the zip are not supported.
iconstringNoIcon shown in the marketplace list and detail page — a relative path to a file bundled in the zip (e.g. icon.png). Must be PNG, not SVG, at least 128×128.
mainstringYesEntry script, relative to the plugin folder. .. escaping the folder is not allowed.
permissionsstring[]Yesnetwork / filesystem / notifications; may be an empty array. See "Permission Model".
toolsobject[]YesTool declarations; may be an empty array. See "Tools & Call Protocol".
documentParsersobject[]NoFile parsing capability.
uiobjectNoFloating panel: { entry, title }
viewsobject[]NoDocked views: { id, title, icon, entry, width? }
sessionActionobjectNoToolbar icon, at most one per plugin.
configurationobject[]NoList of settings entries.
commandsobject[]NoCommand palette entries.
keybindingsobject[]NoShortcuts. command must reference the tool of an entry in commands[], otherwise installation is rejected.
menusobjectNoOnly the session/context location is available for now.
activationEventsstring[]NoOnly onStartupFinished for now.

Language conventions for text

Text in the manifest has two audiences. For the model: name, description, tools[].description, tools[].parameters.*.description. Write these in English; models understand English tool descriptions and fill parameters more reliably. For people: tools[].summary (one-line summary on the consent page), ui.title, views[].title, sessionAction.tooltip, configuration[].label, commands[].title and title inside menus. Write these in your target users' language.

{
  "id": "com.example.full",
  "name": "Full Example",
  "version": "1.2.0",
  "description": "Every contribution point in one manifest.",
  "author": "Example Inc.",
  "readme": "## Full Example\n\nLonger Markdown shown on the marketplace detail page. Images must be https URLs.",
  "icon": "icon.png",
  "main": "index.js",
  "permissions": ["filesystem", "network", "notifications"],
  "activationEvents": ["onStartupFinished"],
  "tools": [
    {
      "name": "do_something",
      "summary": "Do one thing to the current project",
      "description": "…English. State clearly when to call this and when not to…",
      "parameters": { "type": "object", "properties": {}, "required": [] },
      "renderAs": "checklist"
    },
    { "name": "is_available", "summary": "Whether the current folder applies", "description": "…", "parameters": { "type": "object", "properties": {} } },
    { "name": "list_modes", "summary": "List selectable modes", "description": "…", "parameters": { "type": "object", "properties": {} } }
  ],
  "documentParsers": [
    { "mimeType": "text/markdown", "toolName": "parse_markdown" }
  ],
  "ui": { "entry": "ui.html", "title": "Example Panel" },
  "views": [
    { "id": "board", "title": "Example Board", "icon": "checklist", "entry": "view.html", "width": 380 }
  ],
  "sessionAction": {
    "tool": "do_something",
    "icon": "git-branch",
    "tooltip": "Do one thing to the current project",
    "visibleTool": "is_available"
  },
  "configuration": [
    { "id": "mode", "label": "Mode", "type": "select", "optionsTool": "list_modes", "defaultValue": "auto" },
    { "id": "verbose", "label": "Verbose output", "type": "boolean", "defaultValue": false },
    { "id": "prefix", "label": "Prefix", "type": "string", "defaultValue": "" },
    { "id": "limit", "label": "Max items", "type": "number", "defaultValue": 20 }
  ],
  "commands": [
    { "tool": "do_something", "icon": "git-branch", "title": "Do one thing to the current project" }
  ],
  "keybindings": [
    { "command": "do_something", "key": "mod+shift+d" }
  ],
  "menus": {
    "session/context": [
      { "tool": "do_something", "icon": "git-branch", "title": "Do one thing to this session" }
    ]
  }
}

04Permission Model

permissions must honestly reflect what the code actually does. The pre-install consent page lists each one so users can decide whether to install; marketplace review also checks the declaration against the code.

PermissionWhen it must be declaredHow the host treats it
filesystemReading or writing user files (reading project code, generating documents, storing data in the home folder)Informed consent, shown to the user.
networkMaking any network request (third-party APIs, downloads)Informed consent, shown to the user.
notificationsShowing system notifications via the show_notification reverse RPCEnforced: calling it without the declaration returns host_error and no notification is shown.
NOTE
The plugin child process is a full Node.js environment. filesystem and network are currently consent-level declarations, not a runtime sandbox: the host does not intercept fs or fetch. That is exactly why declarations must be honest. Plugins that under-declare are rejected in marketplace review and lose user trust when discovered. Do not over-declare either (unused permissions make users hesitate).

Privacy and data

context.cwd is the user's project folder and may contain source code and sensitive configuration; tool arguments may contain the user's conversation. Do not send this data over the network unless the feature requires it, and when you do, state in description and summary what is sent and to whom. Keep the plugin's own persistent data in a file under the user's home folder named after the plugin id, so it is easy to find when uninstalling.

05Tools & Call Protocol

Declaration

FieldDescription
nameTool name in lowercase letters and underscores, unique across the whole app (it is registered with the model alongside built-in tools and other plugins' tools).
descriptionDescription for the model (English). State three things: what the tool does, what the user says when it should be called, and when it must not be called. This is the single most important field for whether the AI uses your plugin.
summaryOne-line human-readable summary shown on the consent page. Optional but strongly recommended; otherwise a truncated description is shown instead.
parametersStandard JSON Schema object with a description on every property. The model fills arguments from it and the host passes them through without extra validation, so check required fields in your code.
renderAsOptional; currently only checklist: the host renders the todos array from the call arguments as a checklist card, see below.
Note
The model can only call tool names declared in tools[] here — sessionAction.tool, commands[].tool, and the tool field inside menus are only checked to be non-empty strings at install time; the host does not verify the name is declared in tools[], and still dispatches a call under that name when the button, shortcut, or menu item is triggered. So a purely UI-triggered action can be handled entirely in your own index.js without ever appearing in tools[] — but then the AI can never see or call it. If you want the same action to be triggerable both by clicking and by the AI acting on a chat request, declare that same name in tools[] too, so both entry points share one handler; if it's meant to stay UI-only, leave it undeclared — there's no need to add an entry just to look complete.

Message protocol

The host sends call_tool; the plugin must reply with a tool_result or tool_error carrying the same requestId. A call with no reply within 30 seconds times out and the model receives an error.

// host → plugin
{
  "type": "call_tool",
  "requestId": "0b2d…",          // unique per call, echo it back unchanged
  "toolName": "do_something",
  "params": { "name": "…" },     // arguments validated against tools[].parameters
  "context": {
    "cwd": "/Users/me/project",  // project folder of the calling session; a temp folder for chat sessions; empty string for document parsing
    "config": { "mode": "auto", "verbose": false }   // present only when configuration is declared; keys are configuration[].id
  }
}

content accepts only text and image blocks (image as base64 data plus mimeType); other types are dropped. details is arbitrary structured data that is never fed to the model; it is for host UI rendering and your own docked views.

File results: file cards

// When the result is a file: include the absolute path and file name. The session
// renders a clickable file card that previews in-app (docx/xlsx/pdf/markdown/code…).
process.send({
  type: 'tool_result',
  requestId,
  content: [{ type: 'text', text: `Generated [${fileName}](${filePath})` }],
  details: { filePath, fileName },
});

Checklist rendering: renderAs checklist

// For tools with renderAs: "checklist", the host renders the todos array from the call arguments:
{
  "todos": [
    { "id": "1", "content": "Initialize repository", "status": "completed" },
    { "id": "2", "content": "Write unit tests", "status": "in_progress" },
    { "id": "3", "content": "Publish", "status": "pending" }
  ]
}

Writing a good description

Describe the trigger in the imperative ("Call this when the user asks to…") and state exclusions ("Do not call this for…"). If the result is a file, ask the model to reference the returned file link rather than claiming it was generated. If arguments are forwarded to an external generation API, say which language they should be in. Avoid one-liners like "manage todos"; the model will not know when to use the tool.

06Docked views (views)

A docked view is a persistent page in the session's right sidebar: an extra icon appears in the session toolbar and your HTML shows on the right when opened. Page state (scroll position, drafts) survives switching sessions and collapsing/expanding, the same semantics as a VS Code sidebar view.

FieldDescription
idUnique within the plugin.
titleTooltip text for the icon (human readable).
iconPick from the host allowlist: git-branch, checklist. Keeps icons consistent across the app; custom images are not accepted.
entryHTML file, relative to the plugin folder.
widthOptional default panel width in pixels, 240 to 640, default 360. Users can drag to resize and the size is remembered.

Page runtime

The page runs in a sandbox (sandbox + contextIsolation, no Node, no access to host app data) and can only talk to its own plugin process through the few methods on window.pluginHost. Bring your own styles; following the app's light/dark scheme as in the example below is recommended.

interface PluginHost {
  // Call a tool declared in this plugin's manifest.tools; same call_tool channel the AI uses
  invoke(toolName: string, params: unknown): Promise<
    { content: unknown[]; details?: unknown } | { error: string }
  >;
  // Subscribe to ui_push messages from the plugin process; returns an unsubscribe function
  onPush(callback: (channel: string, payload: unknown) => void): () => void;
  // Current light/dark scheme and subsequent changes
  getColorScheme(): Promise<'light' | 'dark'>;
  onColorSchemeChange(callback: (scheme: 'light' | 'dark') => void): () => void;
}
declare const pluginHost: PluginHost; // exposed as window.pluginHost

Recommended pattern: on first load call one of your own query tools with invoke() and have it return structured data in details; afterwards push every change from the plugin process with ui_push instead of polling. When the AI changes data in the conversation, the panel refreshes immediately.

07Floating panel (ui)

ui: { entry, title } declares a standalone floating window (900×700) that users open from the "⋯" menu in the plugin list; it is destroyed on close. The page environment and the window.pluginHost API are identical to docked views, and ui_push is delivered to both the floating panel and docked views. Suited to large panels opened occasionally; use views for content that should stay next to the session.

08Session toolbar icon (sessionAction)

At most one persistent icon per plugin. Clicking it calls tool with empty arguments, so that tool must do something meaningful without parameters.

FieldDescription
toolTool to call on click.
iconAllowlisted icon name.
tooltipHover text.
visibleToolOptional. When set, it is called with empty arguments before rendering and the boolean details.visible in the result decides whether the icon shows. Useful for runtime checks such as "is the current folder a git repository".
// The host calls visibleTool with empty params and shows the icon based on details.visible
process.send({
  type: 'tool_result',
  requestId,
  content: [{ type: 'text', text: isGitRepo ? 'git repo' : 'not a git repo' }],
  details: { visible: isGitRepo },
});

09Settings (configuration)

Declared entries appear under plugin configuration on the settings page. The host stores values changed by the user and passes them as context.config on every call_tool (keyed by id, falling back to defaultValue), whether the call comes from the AI, the toolbar icon or your panel. You never need to ask for them.

typeRendered asdefaultValue type
selectSingle-choice list; options come from optionsToolstring
booleanToggle switchboolean
stringText input, committed on blurstring
numberNumber inputnumber
// For type: "select" settings, the host calls optionsTool with empty params to fetch options
process.send({
  type: 'tool_result',
  requestId,
  content: [{ type: 'text', text: '2 options' }],
  details: {
    options: [
      { value: 'fast', label: 'Fast' },
      { value: 'accurate', label: 'Accurate' },
    ],
  },
});
// The host automatically prepends an "Auto" option whose value equals this
// configuration entry's defaultValue; the plugin must not return it again.

10Command palette / shortcuts / context menu

All three are different entry points for "call a tool with empty arguments"; a plugin can declare several.

FieldShapeDescription
commands{ tool, icon, title }[]Shown in the Ctrl/Cmd+K command palette and triggered after the user searches for it.
keybindings{ command, key }[]command must equal the tool of an entry in commands[]. key uses the mod+shift+g notation, where mod is Cmd on macOS and Ctrl elsewhere. Conflicts with other shortcuts are not checked statically, so pick uncommon combinations.
menus{ "session/context": { tool, icon, title }[] }Added to the menu on a session row. Only this location is available for now.

11Document parsers (documentParsers)

When the user drops a file into the input box, the host looks up parsers declared by enabled plugins by MIME type and passes the file content as base64 to the matching toolName. This is not a tool the AI decides to call, so do not also list the parser tool under tools, or it will be exposed as an AI-callable tool.

// The toolName declared in documentParsers is called when the user drops a file into the input:
// params = { base64: "<file content as base64>" }
// Return: details must be { success: true, text } or { success: false, reason }
process.on('message', (msg) => {
  if (msg.type !== 'call_tool' || msg.toolName !== 'parse_markdown') return;
  const text = Buffer.from(msg.params.base64, 'base64').toString('utf8');
  process.send({
    type: 'tool_result',
    requestId: msg.requestId,
    content: [{ type: 'text', text: 'parsed' }],
    details: { success: true, text },
  });
});

12Reverse RPC (host_request)

The plugin process has no Electron API. When it needs a real system capability it sends host_request to the host, which replies with host_response or host_error. This channel is fully independent of call_tool; you can use it while handling a tool call or from a timer.

capabilityparamsresultPrecondition
show_notification{ title, body?, viewId? }{ shown: true } / { shown: false, reason: "disabled" | "unsupported" }Requires the notifications permission. Returns disabled instead of an error when the user turned off desktop notifications in settings. When viewId points to one of your docked views, clicking the notification brings the app to the front and expands that view.
render_pdf{ outputPath, title, content }{}content is Markdown; the host renders a PDF to outputPath with its built-in renderer.
const crypto = require('crypto');
const pending = new Map();

function requestHost(capability, params) {
  const requestId = crypto.randomUUID();
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => {
      pending.delete(requestId);
      reject(new Error(`host capability "${capability}" timed out`));
    }, 30_000);
    pending.set(requestId, { resolve, reject, timer });
    process.send({ type: 'host_request', requestId, capability, params });
  });
}

process.on('message', (msg) => {
  if (msg.type === 'host_response' || msg.type === 'host_error') {
    const p = pending.get(msg.requestId);
    if (!p) return;
    pending.delete(msg.requestId);
    clearTimeout(p.timer);
    if (msg.type === 'host_error') p.reject(new Error(msg.message));
    else p.resolve(msg.result);
  }
});

// usage
await requestHost('show_notification', {
  title: '3 todos for today',
  body: '• Write weekly report\n• Reply to emails',
  viewId: 'board',      // optional: expand this docked view when the notification is clicked
});
// => { shown: true } or { shown: false, reason: 'disabled' | 'unsupported' }

await requestHost('render_pdf', {
  outputPath: '/abs/path/report.pdf',
  title: 'Report',
  content: '# Title\n\nBody (Markdown)',
});

13Lifecycle

StageBehavior
StartLazy by default: not started with the app, forked on the first call to one of your tools (AI, toolbar icon, panel, command, document parsing). Plugins declaring activationEvents: ["onStartupFinished"] start right after the app finishes launching; use this only for plugins that need background timers (reminders, polling), not for ordinary tool plugins.
ResidentOnce started the process stays alive and is reused across tool calls. You may cache state in memory, but assume it can be restarted at any time.
CrashIf the process exits unexpectedly the host restarts it: immediately, then after 5 and 15 seconds. After 3 consecutive failures it is marked "crashed" and no longer retried; users see the status on the plugin page. In-flight calls all return errors.
Disable / uninstall / reinstallThe process is terminated and docked views and floating panels are destroyed; a reinstall replaces the whole folder.
LogsYour stdout/stderr are written to the app log file prefixed with the plugin id. Plain console.log is enough for debugging; see "Debugging & FAQ".
ENV
The child process starts with ELECTRON_RUN_AS_NODE=1, cwd is the plugin install folder, and the Node version matches the Electron bundled with the app. Do not depend on any globally installed package and do not assume npx exists.

14Packaging & Publishing

Packaging

Zip the plugin folder with manifest.json at the archive root. If you have third-party dependencies, bundle them into a single file with esbuild/webpack first instead of shipping the whole node_modules.

my-plugin.zip
├── manifest.json     # must be at the archive root
├── icon.png          # optional, referenced by manifest.icon
├── index.js
├── view.html
└── node_modules/…    # include dependencies if any (bundling into a single file is recommended)

Publishing to the marketplace

PioMode → Tools → Plugins → "Marketplace" tab → "Publish plugin" in the top-right corner, then upload the zip. Submissions enter review, which checks that the manifest declaration matches the code (especially permissions). Once approved, other users can find and install it in the marketplace, and the AI can install verified plugins for users automatically when it deems them needed.

Updates

Bump version and publish again. The app compares the installed version with the marketplace version and prompts to update; reinstalling keeps the user's enabled state and settings values.

15Full example: Todo Quadrant

A real, working plugin (trimmed): the AI can add and remove todos, the right sidebar shows a board that refreshes in real time, and it can send system notification reminders. Chaining these three covers the tool protocol, docked-view pushes and reverse RPC.

{
  "id": "local.todo-quadrant",
  "name": "Todo Quadrant",
  "version": "1.2.0",
  "description": "Daily todo list organized by the Eisenhower matrix. Install this whenever the user wants to track tasks or asks to be reminded about something…",
  "main": "index.js",
  "permissions": ["filesystem", "notifications"],
  "activationEvents": ["onStartupFinished"],
  "tools": [{
    "name": "manage_todo_quadrant",
    "summary": "Add, list, complete, delete and update todos in an urgent/important matrix",
    "description": "Manage the user's daily todo list… Actions: add / list / complete / delete / update / set_reminder / remind_now. add/update also accept remind_at to schedule a one-time reminder for that specific todo. Call this whenever the user talks about their todos…",
    "parameters": {
      "type": "object",
      "properties": {
        "action": { "type": "string", "enum": ["add", "list", "complete", "delete", "update", "set_reminder", "remind_now"] },
        "id": { "type": "string" },
        "content": { "type": "string" },
        "quadrant": { "type": "string", "enum": ["urgent_important", "important_not_urgent", "urgent_not_important", "not_urgent_not_important"] },
        "due_date": { "type": "string", "description": "YYYY-MM-DD" },
        "remind_at": { "type": "string", "description": "YYYY-MM-DD HH:MM — a one-time reminder for this todo, independent of the daily digest" }
      },
      "required": ["action"]
    }
  }],
  "views": [{ "id": "quadrant", "title": "Todo Quadrant", "icon": "checklist", "entry": "view.html", "width": 380 }]
}

16Debugging & FAQ

SymptomCause and fix
Manifest validation fails on installCheck each field against the "Manifest Fields" table as the message indicates: id format, main/entry must not contain .., icon must be allowlisted, keybindings command must point to an existing tool in commands, width between 240 and 640.
The AI never calls my toolAlmost always a description problem: the trigger is unclear or it is not written in English. See "Writing a good description". Also check that the tool name does not collide with another tool.
Call times out (30 s)The entry script did not reply with tool_result. Usually requestId was not echoed back, the message type is misspelled, or the script threw on startup and exited. Look for lines prefixed with [plugin:your-id] in the app log.
Plugin status shows "crashed"3 consecutive failed starts. Commonly a require of a missing package; running node index.js in the plugin folder reproduces it. After fixing, toggle the plugin off and on again on the plugin page.
Panel opens blankWrong entry path, or a JS error on the page. The page runs in a sandbox without Node's require; only window.pluginHost is available.
Notification does not appearCheck in order: the manifest declares notifications; the result is not shown:false (the user turned off desktop notifications); the OS allows notifications from PioMode.
Settings values are not readableValues are in message.context.config[id], not in params.

Log file location

OSPath
macOS~/Library/Logs/PioMode/main.log
Windows%USERPROFILE%\AppData\Roaming\PioMode\logs\main.log
See the API integration docs