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.
Freeze the plugin manifest fields to guarantee:
- The host can validate
- Developers can depend on it
- Future versions can migrate
Schema Version: 1
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; // 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
};
entrypoints?: {
onInstall?: string;
onLoad?: string;
onEnable?: string;
onDisable?: string;
onUnload?: string;
onUninstall?: string;
};
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;
};type PluginUiConfig = {
panel?: string; // html entry
width?: number;
height?: number;
resizable?: boolean;
title?: string | {
en: string;
"zh-CN": string;
}; // localized native panel identity; both locales are required for an object
};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:
{
"name": "小清新待办",
"description": "作者原话",
"i18n": {
"en": { "name": "Todo List", "description": "A calm todo list" },
"zh-CN": { "name": "小清新待办", "description": "轻盈的待办清单", "safetyNotes": "只写自己的数据" }
}
}Rules:
enandzh-CNare the contract locales. Every Chinese shell locale (zh,zh-CN,zh-Hans,zh-SG) readszh-CN; every other locale readsen. A plugin is not required to translate itself into the other shipped shell locales, sozh-TWreads English rather than half azh-CNguess (ADR 0182).- 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. - Resolution happens in the host, against the language the desktop shell
pushes down (
settings.language, or the OS locale while it isauto). The stored row keeps the author's strings, so a language change only changes what is read and never rewrites the registry. - 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.
- 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).
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 toolbarcomposerTrigger— 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 barentryExtra— an additive block below an assistant replytoolCard— the card for calls of one of the plugin's own Agent toolsblockRenderer— 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.
{
"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.
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`
};
type PluginCommandContrib = {
id: string; // plugin-local or fully-qualified
title: string;
keywords?: string[];
category?: string;
icon?: string;
requires?: PluginPermission[]; // extra per-command perms
};
type PluginAgentToolContrib = {
name: string; // tool name exposed to agent
description: string;
risk: "low" | "medium" | "high";
schema: Record<string, unknown>; // JSON schema object
timeoutMs?: number;
permissions?: PluginPermission[];
};
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;
/** Fixed to plugin for now; global shortcut registration is not supported. */
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
};
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://`
};
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.
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
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.
type PluginPermission =
| "ui.panel"
| "ui.view"
| "ui.theme"
| "ui.window.appearance"
| "clipboard.read"
| "clipboard.write"
| "notify"
| "fs.read"
| "fs.write"
| "fs.delete"
| "agent.tool.register"
| "agent.prompt.inject"
| "agent.complete"
| "composer.transform"
| "composer.suggest"
| "renderer.extension"
| "provider.register"
| "provider.oauth"
| "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.import"
| "session.read.own"
| "session.update.own"
| "session.delete.own"
| "usage.read"
| "audio.capture.background"
| "audio.playback.background"
| "speech.adapter.register"
| "keyboard.globalShortcut"
| "net.websocket";Unknown permission = validation failure.
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.
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.
{
"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
fsblock, an absent mode, or an emptyscopeis 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 throughpi.fs.requestDirectory()is the grant, it lives in memory only, and it dies with the plugin processownis accepted ondeleteonly
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.
"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.
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).
{
"bus": {
"publish": ["build.done"],
"subscribe": ["build.*", "deploy.**"]
}
}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
idmatches[a-zA-Z][a-zA-Z0-9_-]{0,63}and is unique within the plugin; the row id isplugin:<pluginId>:<declaredId> nameis required and is what Settings showscategoryis optional Add Service group metadata. It accepts a non-empty plain string or both localized labels (enandzh-CN); each label is at most 128 characters. When omitted, the plugin name is the group label.descriptionis 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 (enandzh-CN), each at most 280 characters.baseUrlis optional, but must be an absolutehttp(s)URLapiStyleis optional and defaults tochat_completions; the accepted values are the provider-config styles exceptautoauthKindis optional:api_key(default),none, oroauth- OAuth providers require
baseUrl, theprovider.oauthpermission, and anonProviderOAuthmodule export. Optionaloauth.loginLabelis a non-empty string of at most 128 characters;oauth.isSubscriptionis a boolean. The host stores one encrypted credential per provider contribution. modelsaccepts up to 64 entries with unique ids of 1..256 characters. An empty list is valid only for an API-key provider withbaseUrl; 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.
Examples:
onStartuponCommand:demo.hello.sayonAgentModeonWorkspaceOpen
MVP may implement only:
onStartuponCommand:*
-
schemaVersionmust be1 -
id/name/versionare required -
Whether a manifest that declares
ui.panelneeds theui.panelpermission implicitly (auto-filled) or by explicit declaration is an open question (tracked in 08-meta/open-questions.md) -
If
agentToolsare present,agent.tool.registermust be declared -
Path fields must not use absolute paths or
.. -
main/ui.panel/ skills /views[].entrypaths must exist -
tool
nameallows only[a-zA-Z][a-zA-Z0-9_]* -
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;sessionSourcesuses the same rule with.additionally allowed -
themes[].pathmust exist and end in.css;themes[].basemay only belightordark -
mcpServers[]must set exactly one transport's fields:stdiorequirescommand(bare PATH name or plugin-relative, never absolute) and rejectsurl/headers;httprequires an absolutehttporhttpsurland rejectscommand/args/env. Non-loopback HTTP is unencrypted and must be declared innet.domains. -
bus.publishentries must be concrete topics andbus.subscribeentries valid patterns (§5.1) -
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, stdio servers →mcp.server.local, remote servers →mcp.server.remote,services→background.service,bus.publish→bus.publish,bus.subscribe→bus.subscribe.skillsis the exception — it predates the permission gate, so a manifest withoutagent.prompt.injectstill validates and the runtime simply skips the skills -
Settings keys are unique.
shortcutsettings requirecommand, may only use thepluginscope, and are validated as modifier-plus-key or F-key bindings. Secrets are rejected until secure plugin-secret storage exists. -
fs.<mode>requires the matchingfs.<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..), andfs.write/fs.deletemust not use a whole-tree pattern (**,**/*,*/**,./*).ownis accepted ondeleteonly, androotonly onworkspace/userSelected -
net.domainsentries must be bare hostnames, optionally prefixed*.; a bare*is refused -
sessionSourcesids may also contain.; labels are optional, localized labels must provide bothenandzh-CN, and duplicate ids are rejected -
views[].titleis required and, when localized, must carry bothenandzh-CN.views[].iconis 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 -
contributes.globalShortcutsallows at most 8 entries and needskeyboard.globalShortcut. Eachidmatches[a-zA-Z][a-zA-Z0-9._-]{0,63}and is unique;commandmust be declared incontributes.commands;default, when present, uses the same modifier-plus-key / F-key grammar asshortcutsettings -
renderermust be a.jsor.mjsfile inside the package;rendererActions(at most 16) andrendererCallMethods(at most 32) are lists of non-empty names and requirerenderer. Declaring any of the three needs therenderer.extensionpermission (§3.2)
{
"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"]
}{
"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"]
}{
"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).
- A future
schemaVersion: 2needs a migrator - The host should reject a too-high major version
- Unknown optional fields may be ignored; unknown required permissions must fail