Skip to content

02. Plugin Manifest Schema ​

Appearance extensions ​

contributes.themes[].variables declares typed custom properties that the same plugin may change at runtime. A declaration has a safe custom-property name and exactly one type: length (unit: "px", numeric min, max, and default), number (finite default and optional range), color (hex default), or select (fixed safe values and default). Host-reserved prefixes are refused. Values are not CSS fragments.

contributes.scenicThemes declares a data-only host-rendered Settings entry: a stable id, localized label and description, palette icon token, localized keywords, and one to twelve ordered cards. Every card names a same-plugin theme, localized name/description, and a relative image asset declared by that theme. It requires both ui.settings and ui.theme. Plugins provide neither Settings HTML nor CSS or JavaScript: the host renders the Extensions entry, cards, range control, and Apply action in its normal React tree.

1. Purpose ​

Freeze the plugin manifest fields to guarantee:

  • The host can validate
  • Developers can depend on it
  • Future versions can migrate

Schema Version: 1

2. Root object ​

ts
type PluginManifestV1 = {
 schemaVersion: 1;
 id: string; // ^[a-z0-9]+(\.[a-z0-9_-]+)+$
 name: string;
 version: string; // semver
 description?: string;
 author?: string | { name: string; url?: string; email?: string };
 homepage?: string;
 /**
  * Display strings per locale, shown in place of `name`/`description` when the
  * shell's language matches one of the declared locales (see §3.1). The flat
  * fields stay the author's own language and remain the fallback.
  */
 i18n?: {
   [locale: string]: {
     name?: string;
     description?: string;
     safetyNotes?: string;
   };
 };
 repository?: string;
 icon?: string; // relative path
 main: string; // required plugin runtime entry
 renderer?: string; // module the host evaluates to mount UI slots (§3.2)
 rendererActions?: string[]; // actions a renderer component may dispatch, at most 16
 rendererCallMethods?: string[]; // onRendererCall methods plugin.call may reach, at most 32
 ui?: PluginUiConfig;
 contributes?: PluginContributes;
 permissions?: PluginPermission[];
 fs?: PluginFsPolicy; // which paths each file permission may touch (§5.2)
 net?: { domains?: string[] }; // egress allowlist (§5.3)
 engines?: {
 piDesktop?: string; // semver range
 };
 activationEvents?: string[]; // e.g. onCommand:xxx / onStartup
 /**
  * First-registration default for bundled plugins. Omitted means enabled.
  * Marketplace and development installs still enable after the user grants
  * permissions.
  */
 enabledByDefault?: boolean;
};

The SDK author type accepts a string or contact object, but the current Rust installer deserializes author as a string. Use a display string for installable packages. SDK validation is not an exact mirror of host installation: main is mandatory in both, while schema-version/metadata checks differ. Write schemaVersion: 1 and use check plus actual artifact installation; do not interpret a permissive low-level parser as a supported compatibility contract.

3. UI config ​

ts
type PluginUiConfig = {
 panel?: string; // html entry
 width?: number;
 height?: number;
 shape?: "panel" | "widget";
 alwaysOnTop?: boolean;
 resizable?: boolean;
 title?: string | {
   en: string;
   "zh-CN": string;
 }; // localized native panel identity; both locales are required for an object
};

3.1 Localized labels (i18n) ​

name, description, and safetyNotes are display text, so a plugin may declare them per locale in a top-level i18n block. The Extensions page, the plugin launcher, and the marketplace (which reads the same block from a catalog entry) show the entry matching the app language rather than the author's own language:

json
{
  "name": "小清新待办",
  "description": "作者原话",
  "i18n": {
    "en": { "name": "Todo List", "description": "A calm todo list" },
    "zh-CN": { "name": "小清新待办", "description": "轻盈的待办清单", "safetyNotes": "只写自己的数据" }
  }
}

Rules:

  1. en and zh-CN are the contract locales. Every Chinese shell locale (zh, zh-CN, zh-Hans, zh-SG) reads zh-CN; every other locale reads en. A plugin is not required to translate itself into the other shipped shell locales, so zh-TW reads English rather than half a zh-CN guess (ADR 0182).
  2. Both locales and all three fields are required by the plugin repository's validator, but the host is permissive: a missing locale, a missing field, or an empty string falls back per field to the other contract locale, and from there to the author's flat name / description.
  3. Resolution happens in the host, against the language the desktop shell pushes down (settings.language, or the OS locale while it is auto). The stored row keeps the author's strings, so a language change only changes what is read and never rewrites the registry.
  4. The block is display metadata. A malformed one (not an object of locale → object) fails manifest validation; unknown locales and unknown fields inside an entry are ignored.
  5. The block is identity only (name, description, safetyNotes). Plugin-owned copy — panels, views, widgets, generated settings, toasts, runtime command titles — is not translated here. The host publishes the active language (pi.app.getLocale, appearance:changed); the plugin localizes itself (ADR 0280).

3.2 Renderer module (renderer) ​

renderer names an ES module inside the package that the host evaluates in its own window to mount UI slot components:

  • composerControl — additive controls in the composer toolbar
  • composerTrigger — the item list behind one of the composer's trigger symbols (data, not a component: the host draws the list)
  • userAction / assistantAction — additive items on a message's action bar
  • entryExtra — an additive block below an assistant reply
  • toolCard — the card for calls of one of the plugin's own Agent tools
  • blockRenderer — the renderer for a fenced block tagged <pluginId>:<lang>

pi.slots.register returns a disposer, and every registration is withdrawn when the plugin unloads. A self-drawn dialog is not a slot: the plugin opens a layer with pi.ui.openLayer and draws into it (docs/plugin-plan/ui/).

The module runs in the host's own realm, so this is a contract and not a sandbox boundary; the two whitelists are what keeps a component inside its own plugin. rendererActions lists the actions a component may dispatch, capped at 16, from the fixed vocabulary plugin.call, composer.insertText, composer.readDraft, composer.replaceDraft, attachments.add, attachments.list, attachments.remove — a word outside it is refused as PLUGIN_ACTION_UNKNOWN, and a word the manifest does not list as PLUGIN_ACTION_UNDECLARED. rendererCallMethods lists the method names the plugin's onRendererCall answers for plugin.call, capped at 32; the host adds the calling plugin's id, so a component only ever reaches its own plugin.

json
{
  "permissions": ["renderer.extension"],
  "renderer": "renderer/index.mjs",
  "rendererActions": ["plugin.call", "composer.insertText"],
  "rendererCallMethods": ["openWorkspace"]
}

Slot names, props and the caps are in packages/plugin-sdk/src/renderer.ts (PLUGIN_RENDERER_SLOTS, PLUGIN_RENDERER_ACTIONS, PLUGIN_SLOT_POSITIONS), and a worked example is examples/plugins/ui-slots-lab.

4. contributes ​

ts
type PluginContributes = {
 commands?: PluginCommandContrib[];
 agentTools?: PluginAgentToolContrib[];
 skills?: Array<string | PluginSkillContrib>; // relative paths, or metadata overrides
 agentExtensions?: string[]; // ExtensionAPI modules run in the agent sidecar; needs `agent.extension` (spec 16)
providers?: PluginProviderContrib[]; // Host-owned provider rows; needs `provider.register`; OAuth also needs `provider.oauth` (spec 13)
 settings?: PluginSettingContrib[];
 themes?: PluginThemeContrib[];
 scenicThemes?: PluginScenicThemesContrib;
 windowAppearance?: PluginWindowAppearanceContrib; // native window background and Windows corner request; needs `ui.window.appearance`
 composerTransforms?: PluginComposerTransformContrib[]; // explicit Composer text actions; needs `composer.transform`
 mcpServers?: PluginMcpServerContrib[];
  services?: PluginServiceContrib[];
  bus?: PluginBusContrib;
  views?: PluginViewContrib[];
  sessionSources?: PluginSessionSourceContrib[];
  globalShortcuts?: PluginGlobalShortcutContrib[]; // needs `keyboard.globalShortcut`
  launcherTriggers?: PluginLauncherTriggerContrib[]; // claims launcher input; needs `ui.panel` (spec 18)
};

type PluginCommandContrib = {
 id: string; // plugin-local or fully-qualified
 title: string;
 keywords?: string[];
 category?: string;
};

type PluginAgentToolContrib = {
 name: string; // tool name exposed to agent
 description: string;
 risk?: "low" | "medium" | "high";
 schema?: unknown; // JSON schema object
 planSafeActions?: readonly string[]; // read-only action names for Plan/Goal
};

type PluginComposerTransformContrib = {
 id: string; // plugin-local; [A-Za-z][A-Za-z0-9_-]{0,63}
 title: string | { en: string; "zh-CN": string };
 undoTitle?: string | { en: string; "zh-CN": string };
};

type PluginSettingContrib = {
 key: string;
 title: string; // author language; the generated sheet does not localize
 description?: string;
 type: "string" | "number" | "boolean" | "select" | "json" | "shortcut";
 default?: unknown;
 enum?: Array<{ label: string; value: string | number | boolean }>;
 /** Required for shortcut settings; invokes a declared plugin command. */
 command?: string;
 /** App-local setting scope; OS-global shortcuts use globalShortcuts instead. */
 scope?: "plugin";
 secret?: boolean;
};

type PluginViewContrib = {
 id: string; // ^[a-zA-Z][a-zA-Z0-9_-]{0,63}$, unique within the plugin
 title: string | { en: string; "zh-CN": string };
 icon?: string; // token from the host icon set; unknown tokens draw a letter tile
 entry: string; // relative path to the view's HTML entry
  order?: number; // ascending sort key in the plugin-views menu group, default 0
};

type PluginSessionSourceContrib = {
  id: string; // ^[a-zA-Z][a-zA-Z0-9._-]{0,63}$, unique within the plugin
  label?: string | { en: string; "zh-CN": string };
};

/** One system-wide accelerator a plugin declares (`keyboard.globalShortcut`). */
type PluginGlobalShortcutContrib = {
 id: string; // ^[a-zA-Z][a-zA-Z0-9._-]{0,63}$, unique within the plugin
 command: string; // must be declared in contributes.commands
 default?: string; // accelerator the host registers after load; omitted means `pi.keyboard` registers it later
};

/** One launcher input pattern a plugin claims (spec 18). */
type PluginLauncherTriggerContrib = {
 id: string; // [a-zA-Z][a-zA-Z0-9_-]{0,63}, unique within the plugin
 pattern: string; // 1..200 characters, matched against the whole launcher input
 flags?: string; // no repeats, only `i` / `s` / `m` / `u`
 title?: string | { en: string; "zh-CN": string }; // non-empty, at most 120 characters
};

`launcherTriggers` claims input in the plugin launcher window (`Alt+Space` /
`⌥+Space`; [18-plugin-launcher.md](18-plugin-launcher.md)). A pattern is matched
against the whole trimmed input, so a plugin that declares an arithmetic shape
never competes with a query that merely contains one of its characters. At most
8 entries are allowed; every non-empty list requires `ui.panel`, because a match
opens this plugin's panel. Patterns are refused when their structure can
backtrack catastrophically — a quantifier applied to a group whose body already
contains a quantifier or an alternation — and when they are unbalanced, empty,
longer than 200 characters, or end in a dangling escape. The host does not
compile the pattern, because Rust and JavaScript regex syntaxes differ; it only
proves the declaration cannot blow up, and the renderer treats an uncompilable
pattern as inert.

type PluginThemeContrib = {
 id: string; // ^[a-zA-Z][a-zA-Z0-9_-]{0,63}$
 label: string;
 path: string; // relative `.css` file
 base?: "light" | "dark"; // palette the overrides layer on, default `dark`
 assets?: string[]; // package-relative or absolute png/jpg/jpeg/webp/avif/svg/woff2, 4 MB summed;
                    // relative paths resolve inside plugin root; traversal/node_modules are rejected;
                    // each matching `url()` is rewritten to `plugin-asset://`
 variables?: PluginThemeVariableContrib[]; // typed values for `pi.themes.setVariables` (see Appearance extensions)
};

type PluginScenicThemesContrib = {
 id: string;
 label: { en: string; "zh-CN": string };
 description: { en: string; "zh-CN": string };
 keywords?: Array<{ en: string; "zh-CN": string }>;
 icon: "palette";
 themes: Array<{
   themeId: string;
   label: { en: string; "zh-CN": string };
   description: { en: string; "zh-CN": string };
   previewAsset: string;
 }>;
};

type PluginWindowAppearanceContrib = {
 backgroundColor?: { light?: string; dark?: string }; // #rrggbb | #rrggbbaa
 cornerRadius?: number; // integer 0..24 DIP, Windows main window only; default 12
};

cornerRadius belongs to the contributing plugin and applies while any of its declared themes is selected. It does not change macOS/Linux native corners. Removing the theme or its ui.window.appearance grant restores the Windows main-window default of 12 DIP (--radius-md). Invalid or fractional values reject the manifest. On Windows build 22000 and later, 0 requests square corners and every positive value requests the same native DWM rounded preference; Windows chooses the exact radius. Earlier Windows builds retain the requested DIP radius. On Windows 11, an 8-digit background is composited over the resolved built-in theme color so the top-level window remains opaque.

ts
type PluginSkillContrib = {
 id?: string; // defaults to the file name without its extension
 path: string; // relative path to the skill document
 name?: string; // overrides the front-matter `name`
 description?: string; // overrides the front-matter `description`
};

type PluginMcpServerContrib = {
 id: string; // ^[a-zA-Z][a-zA-Z0-9_-]{0,63}$
 label?: string;
 transport: "stdio" | "http";
 // stdio only
 command?: string; // bare PATH name, or plugin-relative executable
 args?: string[];
 env?: Record<string, string | { setting: string }>;
 // remote HTTP transport
 url?: string; // absolute http(s) endpoint; HTTP may target a trusted LAN host
 headers?: Record<string, string | { setting: string }>;
};

type PluginServiceContrib = {
 id: string; // ^[a-zA-Z][a-zA-Z0-9_-]{0,63}$
 label?: string;
 autoRestart?: boolean; // default true
};

type PluginBusContrib = {
 publish?: string[]; // concrete topics, e.g. `build.done`
 subscribe?: string[]; // patterns, e.g. `build.*` / `build.**`
};

type PluginProviderContrib = {
 id: string; // ^[a-zA-Z][a-zA-Z0-9_-]{0,63}$, unique within the plugin
 name: string; // display name in the native provider list
 category?: string | { en: string; "zh-CN": string }; // Add Service group; defaults to plugin name
 description?: string | { en: string; "zh-CN": string }; // Add Service hover/focus introduction, ≤280 chars
 vendorKey?: string; // models.dev vendor key, default `custom`
 baseUrl?: string; // absolute http(s) URL
 apiStyle?: PluginProviderApiStyle; // wire style, default `chat_completions`
 authKind?: "api_key" | "none" | "oauth"; // default `api_key`
 oauth?: { loginLabel?: string; isSubscription?: boolean }; // only with `authKind: "oauth"`
 models: PluginProviderModelContrib[]; // 1..64 entries
};

type PluginProviderApiStyle =
 | "chat_completions"
 | "opencode_go"
 | "responses"
 | "anthropic_messages"
 | "google_generative_ai"
 | "openai_codex_responses"
 | "pi_messages";

type PluginProviderModelContrib = {
 id: string; // 1..256 characters, unique within the provider
 name?: string; // display label for the model binding
 contextWindow?: number;
 maxTokens?: number;
 supportsImages?: boolean;
 /** Canonical thinking levels offered by this model, in declaration order. */
 thinkingLevels?: string[];
 /** New sessions use this level when it is present in `thinkingLevels`. */
 defaultThinkingLevel?: string;
};

To materialize these fields, the Host trims entries, drops unknown canonical names, removes duplicates, and preserves the remaining declaration order. An absent or unusable list becomes an empty binding. defaultThinkingLevel is kept only when it names a normalized level in that model's list; otherwise it is dropped and normal binding normalization selects the first available level. Manifest validation rejects a non-array thinkingLevels, any non-string entry, or an explicitly non-string defaultThinkingLevel; unknown string names are accepted and dropped during normalization.

5. permissions enum ​

ts
type PluginPermission =
 | "ui.panel"
 | "ui.view"
 | "ui.theme"
 | "ui.settings"
 | "ui.window.appearance"
 | "clipboard.read"
 | "clipboard.write"
 | "notify"
 | "fs.read"
 | "fs.write"
 | "fs.delete"
 | "agent.tool.register"
 | "agent.prompt.inject"
 | "agent.complete"
 | "agent.extension"
 | "composer.transform"
 | "composer.suggest"
 | "renderer.extension"
 | "provider.register"
 | "provider.oauth"
 | "models.list"
 | "net.fetch"
 | "net.anyHost"
 | "shell.openExternal"
 | "mcp.server.local"
 | "mcp.server.remote"
 | "background.service"
 | "bus.publish"
 | "bus.subscribe"
 | "browser.cdp"
 | "desktop.control"
 | "ui.microphone"
 | "project.create"
 | "session.read"
 | "session.import"
 | "session.read.own"
 | "session.update.own"
 | "session.delete.own"
 | "session.read.all"
 | "session.update.all"
 | "session.delete.all"
 | "session.autoTitle"
 | "usage.read"
 | "audio.capture.background"
 | "audio.playback.background"
 | "speech.adapter.register"
 | "keyboard.globalShortcut"
 | "net.websocket";

The authoritative list is PLUGIN_PERMISSIONS in @pi-desktop/plugin-sdk. An unknown permission name is an error in pi-plugin check (permission.unknown). validateManifest() and the host installer do not reject it; the string matches no capability check, so it grants nothing.

pi.workspace.get() reports the visible workspace; pi.projects.list() lists all durable project rows without selecting one. Both are read-only metadata and require no manifest permission. The list includes ungrouped projects with stable base-10 projects.id strings, display names, primary paths, and non-empty primary-first roots. Unavailable project lookup returns []. See the projects / workspace API for ordering, group-root enrichment, and the distinction between project and group ids.

fs.read.workspace, fs.write.workspace and fs.delete.workspace are the pre-scope names. They still validate, and the host rewrites them on load to the minimum safe equivalent (§5.2); new manifests must not use them.

5.2 fs — which paths a file permission may touch ​

ts
type PluginFsPolicy = {
 read?: PluginFsRule;
 write?: PluginFsRule;
 delete?: PluginFsRule;
};

type PluginFsRule = {
 root?: "workspace" | "userSelected"; // default `workspace`
 scope?: string[]; // globs relative to the root
 own?: boolean; // delete only: paths this plugin wrote
};

A permission answers "may this plugin touch files"; this answers "which files". Globs use * for one segment and ** across separators, matched case-insensitively against the root-relative path.

json
{
 "permissions": ["fs.read", "fs.write", "fs.delete"],
 "fs": {
 "read": { "root": "workspace", "scope": ["**/*"] },
 "write": { "root": "workspace", "scope": ["docs/**", "*.md"] },
 "delete": { "own": true, "scope": ["dist/**"] }
 }
}
  • An absent fs block, an absent mode, or an empty scope is valid and means no standing reach: every access falls to a runtime confirmation, so saying nothing grants nothing
  • root: "userSelected" needs no scope — the directory the user picks through pi.fs.requestDirectory() is the grant, it lives in memory only, and it dies with the plugin process
  • own is accepted on delete only

5.3 net — egress allowlist ​

ts
type PluginNetDomains = string[]; // "api.example.com" or "*.example.com"

Every host-owned outbound path — the panel session, pi.net.fetch, and remote HTTP MCP endpoints — is confined to these hostnames. An omitted, empty, or malformed list means no egress at all, whatever net.fetch says. Entries are bare hostnames: no scheme, no port, no path, and no bare *. A leading *. covers the domain and its subdomains.

pi.net.websocket answers to the same list (net.websocket, 03-plugin-api.md §3). The permission is implemented: a connect is confined to manifest.net.domains, and a host that is not declared is refused before the transport is asked to open anything.

5.3.1 net.anyHost — the escape hatch ​

"net.anyHost" lifts the allowlist for a plugin whose endpoints the user types in (a self-hosted server, a personal domain no manifest written ahead of time can name). With the grant, every egress path above admits any host over http(s)/ws(s) — except cloud metadata endpoints (169.254.169.254 and peers), which the grant never reaches: their answers are instance credentials. A host declared in net.domains keeps today's behavior, so existing manifests are unaffected; a plugin without the grant sees no change either. The grant is an install-time permission like any other: the user sees it in the review dialog and nothing prompts at request time.

5.1 Bus topic grammar ​

Topics are dot-separated segments matching [a-zA-Z0-9][a-zA-Z0-9_-]*, at most 8 segments and 128 characters. contributes.bus.publish lists concrete topics; contributes.bus.subscribe lists patterns where * matches exactly one segment and ** matches one or more trailing segments (final segment only).

json
{
 "bus": {
 "publish": ["build.done"],
 "subscribe": ["build.*", "deploy.**"]
 }
}

5.4 providers — provider rows the plugin declares ​

contributes.providers declares provider rows that the Host materializes in the native provider list, owned by the plugin (ADR 0259). There is no per-plugin provider-count cap; the existing 50 MiB plugin package limit remains the outer size bound:

  • the declaration id matches [a-zA-Z][a-zA-Z0-9_-]{0,63} and is unique within the plugin; the row id is plugin:<pluginId>:<declaredId>
  • name is required and is what Settings shows
  • category is optional Add Service group metadata. It accepts a non-empty plain string or both localized labels (en and zh-CN); each label is at most 128 characters. When omitted, the plugin name is the group label.
  • description is an optional short introduction shown in an Add Service tooltip on hover or keyboard focus. It accepts a non-empty string or both localized labels (en and zh-CN), each at most 280 characters.
  • baseUrl is optional, but must be an absolute http(s) URL
  • apiStyle is optional and defaults to chat_completions; the accepted values are the provider-config styles except auto
  • authKind is optional: api_key (default), none, or oauth
  • OAuth providers require baseUrl, the provider.oauth permission, and an onProviderOAuth module export. Optional oauth.loginLabel is a non-empty string of at most 128 characters; oauth.isSubscription is a boolean. The host stores one encrypted credential per provider contribution.
  • models accepts up to 64 entries with unique ids of 1..256 characters. An empty list is valid only for an API-key provider with baseUrl; after the user saves a key, the Host discovers and caches that endpoint's models and exposes the cached models for the plugin-owned row.

thinkingLevels is optional. The Host trims entries, drops unknown canonical names, removes duplicates, and preserves the remaining declaration order. An absent or unusable list becomes an empty binding. defaultThinkingLevel is kept only when it names a normalized level in that model's list; otherwise it is dropped and normal binding normalization selects the first available level.

A non-empty contributes.providers needs the high-risk provider.register permission (13-plugin-permissions-matrix.md). The declaration is re-read on every plugin load and is authoritative for its own fields; disabling the plugin keeps the rows and turns them off, while dropping a declaration or uninstalling the plugin deletes the row with its stored credentials.

An API-key provider with a baseUrl appears in the Host's Add Service chooser while its plugin is loaded with provider.register and the provider has no saved key. Entries are grouped by category, or by plugin name when it is omitted. Selecting an entry opens the Host-owned key form and saves through the existing provider secret path. Once configured, the provider stays in the provider list and is omitted from Add Service so the same row is not offered as a second add action. OAuth and no-auth contributions do not appear in this chooser. Existing plugin-owned row reconciliation and credential retention are unchanged; category is display metadata only and does not grant a capability.

OAuth contributions use the host-owned vendor-account UI. onProviderOAuth handles login and refresh; pi.providers.oauth.prompt and .notify provide host-rendered interaction. The callback can read only the credential for its own provider contribution and only when provider.oauth is granted. Its model requests receive the access token through the ordinary Host auth resolver; the refresh token never enters the renderer or Agent Runtime. Egress still requires net.fetch and the declared network domains when the callback uses the Host network API. Plugin entry code is not an OS sandbox and can use raw Node APIs; grant the permission only to code you trust. See 03-plugin-api.md and 13-plugin-permissions-matrix.md.

6. activationEvents (optional metadata) ​

activationEvents may document intent such as onStartup or onCommand:demo.hello.say. The current runtime eagerly loads enabled plugins at restore/enable/development-load time; it does not implement an activation-event scheduler. Do not rely on this field to defer initialization or constrain scope. Use the host's activation-scope control for project eligibility.

engines.piDesktop is author compatibility metadata; the marketplace catalog also carries host-version constraints. A local directory passing check does not prove runtime API availability. Feature-detect additive methods and test on the oldest host you claim. Export onLoad / onUnload from main; there is no entrypoints manifest contract.

7. Validation rules ​

  1. schemaVersion is required and must be a number. The current format is 1; the value itself is not checked

  2. id / name / version / main are required. The id names the plugin's install and data directories and its browser session partition, so the installer and loader refuse ids that could resolve to another plugin's directory and share its files, storage, and network policy: characters outside [A-Za-z0-9._-] (each of those locations maps them to _), . / .., a trailing . or a leading Windows device name (con, nul.x, ...), and an id whose storage name matches that of a different installed or development plugin. The storage name folds the id the way the platforms do (other characters to _, trailing dots dropped, letter case ignored), so it also covers plugins an older version stored under a sanitized name, such as e2e.foo!bar under e2e.foo_bar. Existing plugins keep their ids and directories. pi-plugin check still asks for the stricter shape in §1

  3. Opening ui.panel requires the explicit declared and granted ui.panel permission; a panel path does not implicitly grant it

  4. If agentTools are present, agent.tool.register must be declared. pi-plugin check reports a missing permission as an error (permission.tools-missing); the installer does not, and pi.agent.registerTool fails at runtime without the grant

  5. Path fields must not use absolute paths or ..

  6. main / ui.panel / skills / views[].entry paths must exist

  7. tool name should use only [a-zA-Z][a-zA-Z0-9_]*. This is not validated: the host replaces every other character with _ in the agent-facing tool name (plugin_<pluginId>_<name>), so two names that differ only in such characters collide

  8. Contribution ids (themes, mcpServers, services, views, composerTransforms) must match [a-zA-Z][a-zA-Z0-9_-]{0,63} and be unique within their own list; sessionSources uses the same rule with . additionally allowed

  9. themes[].path must exist and end in .css; themes[].base may only be light or dark

  10. mcpServers[] must set exactly one transport's fields: stdio requires command (bare PATH name or plugin-relative, never absolute) and rejects url/headers; http requires an absolute http or https url and rejects command/args/env. Non-loopback HTTP is unencrypted and must be declared in net.domains.

  11. bus.publish entries must be concrete topics and bus.subscribe entries valid patterns (§5.1)

  12. A contribution that needs a permission fails validation when the permission is missing: themes → ui.theme, views → ui.view, providers → provider.register, OAuth providers → provider.oauth, composerTransforms → composer.transform, agentExtensions → agent.extension, windowAppearance → ui.window.appearance, stdio servers → mcp.server.local, remote servers → mcp.server.remote, services → background.service, bus.publish → bus.publish, bus.subscribe → bus.subscribe. scenicThemes is not checked at validation: without both ui.settings and ui.theme the host simply does not show the Settings entry. skills is the exception — it predates the permission gate, so a manifest without agent.prompt.inject still validates and the runtime simply skips the skills

  13. JSON settings accept JSON-serializable values, including strings and null. The generated editor initializes JSON fields as serialized JSON text on open, settings refresh and reset. Saving parses that text once, preserving the supplied value's type across reopen; a string such as "42" remains a string. Missing JSON values/defaults use {}. Invalid edits retain the draft and prevent submission. Non-JSON setting types retain their controls. Settings keys are unique. shortcut settings require command, may only use the plugin scope, and are validated as modifier-plus-key or F-key bindings. Secrets are rejected until secure plugin-secret storage exists.

  14. fs.<mode> requires the matching fs.<mode> permission — a scope nobody can use is an authoring slip, not a silent no-op. Scope entries must be relative (no absolute path, drive letter, or ..), and fs.write / fs.delete must not use a whole-tree pattern (**, **/*, */**, ./*). own is accepted on delete only, and root only on workspace / userSelected

  15. net.domains entries must be bare hostnames, optionally prefixed *.; a bare * is refused

  16. sessionSources ids may also contain .; labels are optional, localized labels must provide both en and zh-CN, and duplicate ids are rejected

  17. views[].title is required and, when localized, must carry both en and zh-CN. views[].icon is not validated against the token list: an unknown token degrades to a letter tile, so refusing one would break a plugin over a cosmetic detail. The packaging check warns about it instead

  18. contributes.globalShortcuts allows at most 8 entries and needs keyboard.globalShortcut. Each id matches [a-zA-Z][a-zA-Z0-9._-]{0,63} and is unique; command must be declared in contributes.commands; default, when present, uses the same modifier-plus-key / F-key grammar as shortcut settings

  19. renderer must be a .js or .mjs file inside the package; rendererActions (at most 16) and rendererCallMethods (at most 32) are lists of non-empty names and require renderer. Declaring any of the three needs the renderer.extension permission (§3.2)

  20. contributes.launcherTriggers allows at most 8 entries and needs ui.panel. id matches [a-zA-Z][a-zA-Z0-9_-]{0,63} and is unique; pattern is non-empty and at most 200 characters; flags is a repeat-free subset of i / s / m / u; title, when present, is a non-empty string or both en and zh-CN, each at most 120 characters. A pattern with a quantified group that already contains a quantifier or an alternation, unbalanced parentheses, unbalanced brackets, or a dangling escape rejects the manifest

8. Example: minimal plugin ​

json
{
 "schemaVersion": 1,
 "id": "demo.hello",
 "name": "Hello",
 "version": "0.1.0",
 "main": "main.js",
 "ui": {
 "panel": "renderer/index.html"
 },
 "contributes": {
 "commands": [
 {
 "id": "hello.open",
 "title": "Open Hello Panel",
 "keywords": ["hello"]
 }
 ]
 },
 "permissions": ["ui.panel"]
}

9. Example: Agent Tool plugin ​

json
{
 "schemaVersion": 1,
 "id": "demo.echo-tool",
 "name": "Echo Tool",
 "version": "0.1.0",
 "main": "main.js",
 "contributes": {
 "agentTools": [
 {
 "name": "echo_text",
 "description": "Echo a text value",
 "risk": "low",
 "schema": {
 "type": "object",
 "properties": {
 "text": { "type": "string" }
 },
 "required": ["text"]
 }
 }
 ]
 },
 "permissions": ["agent.tool.register"]
}

9.1 Example: capability contributions ​

json
{
 "schemaVersion": 1,
 "id": "demo.capabilities",
 "name": "Capabilities",
 "version": "0.1.0",
 "main": "main.js",
 "contributes": {
 "skills": [{ "path": "skills/release.md", "id": "release-notes" }],
 "themes": [
 { "id": "midnight", "label": "Midnight", "path": "themes/midnight.css", "base": "dark" }
 ],
 "mcpServers": [
 {
 "id": "docs",
 "transport": "stdio",
 "command": "npx",
 "args": ["-y", "@example/docs-mcp"],
 "env": { "DOCS_TOKEN": { "setting": "docsToken" } }
 },
 {
 "id": "issues",
 "transport": "http",
 "url": "https://mcp.example.com/issues",
 "headers": { "Authorization": { "setting": "issuesAuth" } }
 }
 ],
 "services": [{ "id": "watcher", "label": "Repo watcher" }],
 "bus": { "publish": ["demo.build.done"], "subscribe": ["demo.**"] }
 },
 "permissions": [
 "agent.prompt.inject",
 "ui.theme",
 "mcp.server.local",
 "mcp.server.remote",
 "background.service",
 "bus.publish",
 "bus.subscribe"
 ]
}

{ "setting": "<key>" } reads the plugin's own settings; the host environment is never passed through (D018).

10. Compatibility strategy ​

  • A future schemaVersion: 2 needs a migrator
  • The host should reject a too-high major version; today neither the SDK nor the host compares schemaVersion against 1 (§7 rule 1)
  • Unknown optional fields may be ignored. Unknown permissions fail pi-plugin check but are not rejected at install (§5)

Local-first · Model-agnostic · Plugin-powered. AIUO.NET