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 field | Capability | Where users see it |
|---|---|---|
tools | Add callable tools for the AI | The AI calls them automatically in conversation; tool calls are shown in the session |
views | Persistent view docked in the session's right sidebar | An extra icon in the session toolbar opens your page on the right |
ui | Standalone floating window | Opened from the "⋯" menu in the plugin list |
sessionAction | Persistent action icon in the session toolbar | One click runs the tool you specify |
configuration | Entries on the settings page | Settings → Plugin configuration; values are passed with every call |
commands | Ctrl/Cmd+K command palette entries | Command palette |
keybindings | Global keyboard shortcuts | A key combination triggers a command |
menus | Session context-menu items | The "⋯" / right-click menu on a session row |
documentParsers | Parse files dropped into the input box | Called automatically when the user drops a matching file |
activationEvents | Start together with the app | Invisible; for plugins that need background timers |
permissions | Declare required permissions | Pre-install consent page |
02Quick Start
A minimal plugin needs only two files.
1. Write manifest.json
2. Write the entry script
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.
03Manifest Fields
| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Reverse-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. |
name | string | Yes | Plugin name. Together with description it is fed to the model as context, so English is recommended. |
version | string | Yes | Semantic version; the marketplace uses it to detect updates. |
description | string | Yes | One sentence describing what the plugin does (English). |
author | string | No | Author. |
readme | string | No | Long-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. |
icon | string | No | Icon 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. |
main | string | Yes | Entry script, relative to the plugin folder. .. escaping the folder is not allowed. |
permissions | string[] | Yes | network / filesystem / notifications; may be an empty array. See "Permission Model". |
tools | object[] | Yes | Tool declarations; may be an empty array. See "Tools & Call Protocol". |
documentParsers | object[] | No | File parsing capability. |
ui | object | No | Floating panel: { entry, title } |
views | object[] | No | Docked views: { id, title, icon, entry, width? } |
sessionAction | object | No | Toolbar icon, at most one per plugin. |
configuration | object[] | No | List of settings entries. |
commands | object[] | No | Command palette entries. |
keybindings | object[] | No | Shortcuts. command must reference the tool of an entry in commands[], otherwise installation is rejected. |
menus | object | No | Only the session/context location is available for now. |
activationEvents | string[] | No | Only 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.
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.
| Permission | When it must be declared | How the host treats it |
|---|---|---|
filesystem | Reading or writing user files (reading project code, generating documents, storing data in the home folder) | Informed consent, shown to the user. |
network | Making any network request (third-party APIs, downloads) | Informed consent, shown to the user. |
notifications | Showing system notifications via the show_notification reverse RPC | Enforced: calling it without the declaration returns host_error and no notification is shown. |
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
| Field | Description |
|---|---|
name | Tool 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). |
description | Description 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. |
summary | One-line human-readable summary shown on the consent page. Optional but strongly recommended; otherwise a truncated description is shown instead. |
parameters | Standard 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. |
renderAs | Optional; currently only checklist: the host renders the todos array from the call arguments as a checklist card, see below. |
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.
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
Checklist rendering: renderAs checklist
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.
| Field | Description |
|---|---|
id | Unique within the plugin. |
title | Tooltip text for the icon (human readable). |
icon | Pick from the host allowlist: git-branch, checklist. Keeps icons consistent across the app; custom images are not accepted. |
entry | HTML file, relative to the plugin folder. |
width | Optional 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.
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.
| Field | Description |
|---|---|
tool | Tool to call on click. |
icon | Allowlisted icon name. |
tooltip | Hover text. |
visibleTool | Optional. 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". |
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.
| type | Rendered as | defaultValue type |
|---|---|---|
select | Single-choice list; options come from optionsTool | string |
boolean | Toggle switch | boolean |
string | Text input, committed on blur | string |
number | Number input | number |
10Command palette / shortcuts / context menu
All three are different entry points for "call a tool with empty arguments"; a plugin can declare several.
| Field | Shape | Description |
|---|---|---|
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.
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.
| capability | params | result | Precondition |
|---|---|---|---|
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. |
13Lifecycle
| Stage | Behavior |
|---|---|
| Start | Lazy 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. |
| Resident | Once 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. |
| Crash | If 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 / reinstall | The process is terminated and docked views and floating panels are destroyed; a reinstall replaces the whole folder. |
| Logs | Your stdout/stderr are written to the app log file prefixed with the plugin id. Plain console.log is enough for debugging; see "Debugging & FAQ". |
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.
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.
16Debugging & FAQ
| Symptom | Cause and fix |
|---|---|
| Manifest validation fails on install | Check 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 tool | Almost 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 blank | Wrong 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 appear | Check 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 readable | Values are in message.context.config[id], not in params. |
Log file location
| OS | Path |
|---|---|
| macOS | ~/Library/Logs/PioMode/main.log |
| Windows | %USERPROFILE%\AppData\Roaming\PioMode\logs\main.log |