plugin-builder · git:20260918.45676f4 · 2026-09-18 · sha256 73391a865ffe64df
plugin-builder git:20260918.45676f4A
Immutable. This exact content never changes and is served at /api/v1/blob/73391a865ffe64df.
---
name: plugin-builder
description: Author, package, and debug NextCoWork plugins end to end. Use when writing a plugin package.json manifest, contributing commands / menus / views / custom editors / agent tools, rendering result cards (declarative or iframe), building live and interactive tool cards, wiring inter-plugin communication (dependencies, exposed APIs, event bus), or fixing plugin activation, permissions, and packaging.
---
# NextCoWork Plugin Builder
Build plugins that extend the NextCoWork desktop app and its AI agent. A plugin is a self-contained package that runs **sandboxed** and talks to the host over a small, permissioned RPC surface. This Skill is the complete authoring reference: manifest, permissions, contribution points, the `nextcowork` runtime API, agent tools, result/live/interactive cards, inter-plugin communication, and packaging.
## Use this Skill when
- Scaffolding a new plugin `package.json` manifest and entry module.
- Contributing commands, menus, keybindings, sidebar views, custom editors, themes, skills, or configuration.
- Contributing **agent tools** and shaping how their calls and results look in chat.
- Returning **result cards** (declarative primitives or a plugin-owned iframe) from a tool.
- Pushing **live progress cards** while a tool runs, or **interactive cards** with buttons the user clicks.
- Wiring **inter-plugin communication**: declaring dependencies, exposing an API, calling another plugin, or using the event bus.
- Debugging activation, permission denials, tool naming, card sanitization, or packaging.
## The isolation model (read this first)
Every plugin runs with **no access to the host DOM or Node**. Two surfaces exist:
- **Background host window** — a hidden `BrowserWindow` (`sandbox`, `contextIsolation`, `nodeIntegration:false`) on a dedicated session partition, serving your `main` ESM entry from `ncw-plugin://<publisher>.<name>/`. This is where `activate()` and your tool/command handlers run.
- **View / card iframes** — your HTML (`contributes.views`, `contributes.customEditors`, `contributes.cardViews`) rendered in a cross-origin `ncw-plugin://` iframe inside the main window. Sandboxed: no popups, no modals, no top-navigation. Network `connect-src` is closed — all HTTP must go through `ncw.net.fetch`.
You reach the host **only** through the `nextcowork` module API. Every capability is gated by a permission the user granted, plus an argument gate (path inside the workspace, host in `hostPermissions`, command in `allowedCommands`).
Inspect the host implementation when behavior is unclear:
- Manifest schema + validation: `src/shared/plugin/manifest.ts`
- RPC methods + permissions: `src/shared/plugin/protocol.ts`
- Permission model: `src/shared/plugin/permission.ts`
- Contribution points (menus/icons/when): `src/shared/plugin/contribution.ts`
- Custom editor matching: `src/shared/plugin/custom-editor.ts`
- Result cards: `src/shared/agent/tool-card.ts`
- Lifecycle + RPC gate + broker: `src/main/plugin/manager.ts`
- Plugin tool bridge: `src/main/plugin/tools.ts`
- Runtime shim + protocol handler: `src/main/plugin/protocol.ts`
- Card rendering: `src/renderer/src/views/chat/CardRenderer.tsx`, `PluginCardFrame.tsx`
- Full typed API surface: `packages/plugin-api/nextcowork.d.ts`
## Package anatomy
```
<publisher>.<name>/
├── package.json # the manifest (required)
├── dist/
│ ├── extension.js # single-file ESM entry (manifest.main)
│ ├── views/*.html # view / cardView HTML (optional)
│ └── ...
├── l10n/ # zh-CN.json + en-US.json (required if you use %keys%)
│ ├── zh-CN.json
│ └── en-US.json
└── icon.png # optional
```
The entry must be a **single-file ESM `.js`** (bundle your dependencies). Install by ZIP (top-level single dir named `<publisher>.<name>`) or from a directory in dev.
## Manifest reference (`package.json`)
```jsonc
{
"publisher": "acme", // ^[a-z0-9][a-z0-9-]{0,63}$
"name": "tasks", // same shape; id = "acme.tasks"
"displayName": "Acme Tasks",
"description": "…",
"version": "1.2.0", // semver
"license": "MIT", // optional
"icon": "./icon.png", // optional, package-relative
"categories": ["Productivity"], // optional, ≤8
"keywords": ["tasks"], // optional, ≤16
"engines": { "nextcowork": "^0.2.0" }, // ^x.y.z | ~x.y.z | >=x.y.z | exact
"main": "./dist/extension.js", // single-file ESM
"l10n": "./l10n", // optional; needs zh-CN.json + en-US.json
"activationEvents": ["onCommand:acme.tasks.new"],
"permissions": ["workspace.read"], // required capabilities
"optionalPermissions": ["net"], // requestable at runtime
"hostPermissions": ["https://api.acme.com/*"], // net allow-list
"allowedCommands": ["git"], // process.exec allow-list (bare names)
"dependencies": { "acme.core": "^1.0.0" }, // other plugins you connect() to
"contributes": { /* see below */ }
}
```
Rules that bite:
- Every contributed `title` / `displayName` must be an `%l10nKey%` reference, never literal copy. Provide the key in **both** `l10n/zh-CN.json` and `l10n/en-US.json` (shipping only one locale fails install).
- `hostPermissions` with no `net`, or `allowedCommands` with no `process`, are warnings (no effect).
- `dependencies` values use the same range syntax as `engines`; self-dependency is an error. You can only `connect()` to plugins listed here.
- The manifest is the **capability ceiling**. `permissions.request()` for anything outside `permissions ∪ optionalPermissions` is silently denied.
## Permissions
| Permission | Grants |
|---|---|
| `workspace.read` | `workspace.fs.readFile/stat/findFiles`, folders |
| `workspace.write` | `workspace.fs.writeFile/delete` (delete goes to trash; write runs the host approval chain) |
| `process` | `process.exec` — `argv[0]` must be in `allowedCommands` |
| `net` | `net.fetch` — HTTPS only, each URL matched against `hostPermissions`, no intranet |
| `storage` | key-value store (5 MB/plugin) |
| `secrets` | encrypted key-value (safeStorage), keys auto-prefixed |
| `scm.read` / `scm.write` | git status/diff / commit/branch |
| `agent.intercept` | tool-call interceptor (can only tighten) |
| `agent.context` | inject per-turn context (length-capped) |
| `clipboard` | clipboard read/write |
| `window.notify` | system notifications |
| `plugins` | inter-plugin `connect()` + event bus (target must also be a declared dependency) |
Query/request at runtime: `permissions.contains(p)`, `permissions.request(perms, reasonKey)`, `permissions.remove(perms)`.
## Activation events
Declared plugins wake lazily on the first matching event. Waking a plugin **wakes its declared dependencies first**.
- `onStartup` — wakes at launch. **The market rejects this by default** (memory risk). Avoid unless essential.
- `onCommand:<commandId>`, `onView:<viewId>`, `onCustomEditor:<viewType>`, `onTool:<toolName>`, `onWorkspaceContains:<glob>`
Idle plugins sleep after ~5 minutes; the next event re-wakes them.
## Contribution points (`contributes`)
```jsonc
"contributes": {
"commands": [{ "command": "acme.tasks.new", "title": "%cmd.new%", "icon": "plus" }],
"menus": {
"tabBar/new": [{ "command": "acme.tasks.new", "group": "create@20", "when": "…" }]
},
"keybindings": [{ "command": "acme.tasks.new", "key": "cmd+shift+t" }],
"views": [{ "id": "acme.tasks.board", "title": "%view.board%", "path": "dist/views/board.html", "icon": "table" }],
"customEditors": [{
"viewType": "acme.tasks.editor",
"displayName": "%editor.tasks%",
"selector": [{ "filenamePattern": "*.tasks" }],
"priority": "default"
}],
"cardViews": [{ "viewType": "acme.tasks.card", "path": "dist/views/card.html" }],
"tools": [{
"name": "create_task",
"title": "%tool.createTask%",
"icon": "plus",
"shape": "mutate", // folded-card shape (see below)
"card": { "title": "%tool.createTask.card%", "summary": "%tool.createTask.sum%" }
}],
"themes": [{ "path": "dist/themes/dark.json" }],
"skills": [{ "path": "dist/skills/my-skill" }],
"configuration": {
"title": "%config.title%",
"properties": { "acme.tasks.autoAssign": { "type": "boolean", "title": "%config.autoAssign%", "default": false } }
}
}
```
- **Icons** are a fixed whitelist (see `contribution.ts` `MENU_ICON_NAMES`): file, folder, image, pen-tool, pencil, eye, search, terminal, globe, git-branch, clock, settings, play, square, plus, download, upload, package, puzzle, sparkles, wrench, bug, chart, table, link, bookmark, shield, …
- **Menu ids**: `tabBar/new`, `tabBar/context`, `explorer/context`, `explorer/new`, `sidebar/nav`, `chat/composer`, `commandPalette`. Plugin items always sort after built-ins in the same group; ≤3 show inline, the rest collapse.
- **`configuration` is read-only** to the plugin (`configuration.get()`); the user owns the switches.
- **`tool.shape`** picks the folded-card icon/renderer: `reasoning | read | mutate | search | command | network | orchestration | external` (default `external`). `tool.card.title/summary` are `%l10nKey%` templates interpolated with the tool's input; when a param is missing (streaming), the host falls back to the static title — never leak `{param}`.
## The `nextcowork` runtime API
Import from the bare module `nextcowork` (mapped by the host):
```ts
import * as ncw from 'nextcowork'
export function activate(context) { /* register things; push disposables to context.subscriptions */ }
export function deactivate() {}
```
Namespaces:
- `env` — `appInfo()`, `openExternal(url)`, `clipboard.readText/writeText` (needs `clipboard`).
- `appearance` — `get()`, `onDidChange(cb)` (light/dark; no permission).
- `permissions` — `contains`, `request`, `remove`.
- `workspace` — `folders()`, `fs.readFile/writeFile/delete/stat`, `findFiles(glob, limit)`. Paths are workspace-relative; writes carry an optimistic `revision`.
- `process.exec(command, args?, options?)` — non-interactive; `command` must be in `allowedCommands`.
- `net.fetch(url, init?)` — HTTPS only, per-URL `hostPermissions` check.
- `storage.global` / `storage.workspace` — `get/set/keys`. `secrets.get/set`.
- `window` — `showMessage(kind, messageKey, params?)`, `showQuickPick(items, placeholderKey?)`, `setStatusBarItem(id, textKey, options?)`. All use l10n keys, not literals.
- `commands` — `registerCommand(id, handler)` (id must be declared), `executeCommand(id, args?)` (own commands only).
- `tabs.openCustomEditor(viewType, path)`; `customEditors.setDirty(documentId, path, dirty)`.
- `tools.registerTool(name, tool)` — contribute an agent tool (name must be declared).
- `diagnostics.log(level, message)` — write to the plugin's activity log.
- `plugins` — inter-plugin comm (see below).
## Authoring an agent tool
```ts
ncw.tools.registerTool('create_task', {
description: 'Create a task in the current board.',
inputSchema: { type: 'object', properties: { title: { type: 'string' } }, required: ['title'] },
readOnly: false,
destructive: false,
needsNetwork: false,
interactive: false, // set true if you await user input (see interactive cards)
async invoke({ input, callId, progress, onAction }) {
// return a string, or { content: [{ text }], isError?, card? }
return { content: [{ text: `Created "${input.title}"` }] }
}
})
```
- `inputSchema` is JSON Schema, passed to the model **verbatim** — the host does not validate it; you validate `input` yourself.
- Tools join the same registry as built-in and MCP tools; the host prefixes the name (`plugin__…`) to avoid collisions.
- The model sees only `content` text. The result **card is UI-only and never sent to the model**.
### Result cards
Return a `card` to render a rich result instead of a JSON dump:
```ts
return {
content: [{ text: 'Task created' }],
card: {
kind: 'declarative',
blocks: [
{ type: 'keyValue', rows: [{ label: 'Title', value: input.title }, { label: 'Status', value: 'open', tone: 'ok' }] },
{ type: 'status', label: 'Assigned to you', tone: 'info' },
{ type: 'progress', fraction: 0.2, label: '1/5 done' }
]
}
}
```
Declarative `CardBlock` primitives (all sanitized by the host; unknown blocks are dropped, oversize cards are dropped, images/links are scheme-checked):
- `keyValue` `{ rows: [{ label, value, tone? }] }`
- `table` `{ columns: string[], rows: string[][] }`
- `status` `{ label, tone? }` — tone: `neutral | info | ok | warn | danger`
- `text` `{ value }`, `code` `{ value, language? }`
- `image` `{ dataRef, alt? }` — `dataRef` must be `data:` or `ncw://`
- `progress` `{ fraction, label? }`
- `link` `{ href, label? }` — `https` only, opened via the system browser
- `button` `{ actionId, label, tone? }` — interactive (see below)
**Frame card** — render arbitrary UI in your own iframe (declare it in `contributes.cardViews`):
```ts
card: { kind: 'frame', viewType: 'acme.tasks.card', data: { taskId: 42 } }
```
The card HTML is served from `ncw-plugin://<id>/<path>`. The host pushes `{ type:'ncw:card:data', data, callId, pluginId }` once ready; you post `{ type:'ncw:card:height', height }` to negotiate size and `{ type:'ncw:card:action', actionId, value }` to send an action back. Data is one-way and read-only. Only mounted when the card is expanded.
### Live progress cards
While a tool runs, push a card via `progress`:
```ts
async invoke({ input, progress }) {
progress({ message: 'Fetching…', card: { kind: 'declarative', blocks: [{ type: 'progress', fraction: 0.5 }] } })
// …
return { content: [{ text: 'done' }] }
}
```
`progress.card` is volatile (not persisted) and auto-expands the tool card in chat. It is replaced by the final result `card` when the tool returns.
### Interactive cards (buttons the user clicks)
Set `interactive: true` (relaxes the tool timeout to a host cap; the user can always cancel = abort), push a card with `button` blocks, and await the action:
```ts
async invoke({ input, progress, onAction }) {
const action = await new Promise((resolve) => {
onAction(resolve)
progress({
message: 'Waiting for approval',
card: { kind: 'declarative', blocks: [
{ type: 'text', value: `Delete ${input.count} tasks?` },
{ type: 'button', actionId: 'approve', label: 'Approve', tone: 'ok' },
{ type: 'button', actionId: 'cancel', label: 'Cancel', tone: 'danger' }
] }
})
})
if (action.actionId !== 'approve') return { content: [{ text: 'Cancelled by user' }] }
return { content: [{ text: 'Deleted' }] }
}
```
The click routes host-side back to your still-running tool. Buttons on a finished tool's card are inert.
## Inter-plugin communication
Requires the `plugins` permission. You can only reach plugins you list in `dependencies`.
**Expose an API** (no permission needed to offer):
```ts
ncw.plugins.exposeApi({
getBoard() { return currentBoard },
addTask(title) { return createTask(title) }
})
```
**Call another plugin** (needs `plugins` + a declared dependency on it; the target is auto-woken):
```ts
const core = ncw.plugins.connect('acme.core') // proxy
const board = await core.getBoard() // routed through the host broker
```
**Event bus** (loose coupling; namespace your topics):
```ts
ncw.plugins.events.emit('acme.tasks.updated', { id: 42 })
const sub = ncw.plugins.events.on('acme.tasks.updated', (payload, from) => { /* … */ })
```
Events fan out only to **running** subscribers and never echo to the sender. Subscriptions and exposed APIs are cleared when the plugin is disabled.
## Custom editors & views
- A `customEditor` binds a `filenamePattern` to a `viewType`; opening a matching file mounts your `views/<file>.html` iframe as a Tab. The host proxies the document over `ncw:doc:ready` / `ncw:doc:open` / `ncw:doc:save` — **the path is never in the message** (the host uses the Tab-bound file), and saves carry a `revision` for conflict detection.
- Sidebar `views` mount similarly. Theme is synced to iframes via `ncw:theme` postMessage (24 color tokens + `data-theme`).
## Packaging & install
- ZIP: a single top-level dir named exactly `<publisher>.<name>`, containing `package.json`. Limits: ≤20 MB transfer, ≤50 MB unpacked, ≤2000 files, ≤12 dirs deep, **no symlinks**.
- The installer validates that every file referenced by `contributes` (view/cardView/theme/skill paths, `icon`, `main`, both `l10n` locales) exists.
- Market installs verify a `sha256`; local ZIP/dir installs (dev) do not. Updates only replace market-sourced plugins.
## Gotchas
- Titles/messages are **l10n keys**, not text — provide both locales.
- The **manifest is the capability ceiling**; runtime requests can't exceed it.
- Tool `inputSchema` is not validated by the host — validate input yourself.
- Result/live/interactive cards are **UI-only**; the model only ever sees `content` text.
- `connect()` needs both the `plugins` permission and a declared `dependencies` entry, or it returns `null`.
- Prefer specific `activationEvents` over `onStartup`.
## Minimal working plugin
`package.json`:
```jsonc
{
"publisher": "acme", "name": "hello", "displayName": "Hello", "description": "demo",
"version": "0.1.0", "engines": { "nextcowork": "^0.2.0" }, "main": "./dist/extension.js",
"l10n": "./l10n", "activationEvents": ["onTool:say_hello"],
"permissions": [],
"contributes": { "tools": [{ "name": "say_hello", "title": "%tool.hello%", "shape": "orchestration" }] }
}
```
`dist/extension.js`:
```ts
import * as ncw from 'nextcowork'
export function activate(context) {
context.subscriptions.push(
ncw.tools.registerTool('say_hello', {
description: 'Greet someone.',
inputSchema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] },
readOnly: true, destructive: false, needsNetwork: false,
async invoke({ input }) {
return {
content: [{ text: `Hello, ${input.name}!` }],
card: { kind: 'declarative', blocks: [{ type: 'status', label: `Greeted ${input.name}`, tone: 'ok' }] }
}
}
})
)
}
```
`l10n/zh-CN.json` → `{ "plugin.acme.hello.tool.hello": "打个招呼" }`, `l10n/en-US.json` → `{ "plugin.acme.hello.tool.hello": "Say hello" }`.