git:20260918.45676f4 to git:20260920.1831b6a

66 added, 299 removed. Audit A to A.

---
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.
+ description: Author, package, and debug NextCoWork plugins — the single entry point. Covers the manifest (package.json) and the permission model with per-call capability gates, custom editors / views / the ncw:doc document channel, agent tools with their result / live-progress / interactive cards, the nextcowork runtime API namespaces, the sandbox and isolation model, build + ZIP + market publishing rules, and the shipped examples. Use for scaffolding a plugin package, choosing which resource type to contribute, writing or reviewing a manifest, picking or debugging a permission, taking over a file type, giving the agent new tools, calling host capabilities, or fixing a plugin that builds, installs, activates, renders, or publishes wrong. The detailed material lives in references/ next to this file.
---
# 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.
+ 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.
- ## Use this Skill when
+ ## Read the reference you need — this page is the map
- - 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 details live in `references/`, next to this `SKILL.md`. Read the file that matches the task; this page alone is not enough to write a working plugin.
+ | Task | Reference |
+ |---|---|
+ | Manifest fields, the permission model, what needs which capability | `references/manifest-permissions.md` |
+ | Method → permission, one row per RPC | `references/permission-map.md` |
+ | Taking over file types (custom editors), views, the document channel | `references/custom-editors.md` |
+ | Contributing agent tools, result / live / interactive cards | `references/agent-tools.md` |
+ | Runtime API namespaces (workspace / net / process / storage / secrets / window / tabs / …) | `references/runtime-api.md` |
+ | Build, ZIP layout, installer checks, market publish | `references/packaging.md` |
+
+ How to open them: the paths above are relative to **this Skill's own package directory** — the directory holding this `SKILL.md`. Bundled Skills are installed into the app's global Skill root (`<userData>/skills/plugin-builder/`), which differs between dev and packaged builds; if you don't know it, locate the package first (for example a Glob search for `**/skills/plugin-builder/SKILL.md`) and read the reference by its absolute path. Do **not** look for them relative to the user's workspace — they are not part of it.
+
## 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:
+ - **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. Views **cannot import `nextcowork`** — that shim only loads in the host window; views talk to the host exclusively through the document channel and the theme shim (see `references/custom-editors.md`).
- - 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`
+ You reach the host **only** through the `nextcowork` module API (host window side). Every capability is gated by a permission the user granted, plus an argument gate (path inside the workspace, host in `hostPermissions`, command in `allowedCommands`).
## 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 */ }
- }
+ └── icon.png # optional (≤256 KB)
```
- 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>`
+ Install by ZIP (top-level single dir named `<publisher>.<name>`), from a directory in dev, or from the market.
- Idle plugins sleep after ~5 minutes; the next event re-wakes them.
+ ## The smallest plugin is zero code
- ## Contribution points (`contributes`)
+ If all you want is to bring a website in ("open Bilibili in a tab"), you do **not** need an
+ entry module, a bundler, or a host process. Declare `"kind": "webapp"` and one `webApps`
+ entry — that's the whole package:
```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' }] }
+ {
+ "publisher": "ncw", "name": "bilibili", "kind": "webapp",
+ "displayName": "哔哩哔哩", "description": "…", "version": "0.1.0",
+ "engines": { "nextcowork": "^0.3.0" }, "l10n": "./l10n",
+ "permissions": [],
+ "hostPermissions": ["https://www.bilibili.com/*"],
+ "contributes": { "webApps": [
+ { "id": "home", "title": "%app.home%", "icon": "tv", "url": "https://www.bilibili.com/" }
+ ] }
}
```
- 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`.
+ Installing it puts an entry in the sidebar; clicking it opens the site in a workspace tab,
+ signed in already (it shares the workspace browser session). Full rules —
+ including why `main` in a webapp manifest is an error rather than an ignored field —
+ in `references/manifest-permissions.md`. Working example: `examples/ncw.bilibili`.
- ## Minimal working plugin
+ ## Minimal working plugin (with code)
`package.json`:
```jsonc
{
"publisher": "acme", "name": "hello", "displayName": "Hello", "description": "demo",
- "version": "0.1.0", "engines": { "nextcowork": "^0.2.0" }, "main": "./dist/extension.js",
+ "version": "0.1.0", "engines": { "nextcowork": "^0.3.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' }] }
- }
+ return { content: [{ text: `Hello, ${input.name}!` }] }
}
})
)
}
```
`l10n/zh-CN.json` → `{ "plugin.acme.hello.tool.hello": "打个招呼" }`, `l10n/en-US.json` → `{ "plugin.acme.hello.tool.hello": "Say hello" }`.
+
+ ## Activation model
+
+ 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>`, `onCustomEditor:<viewType>`, `onTool:<toolName>`, `onView:<viewId>`, `onWorkspaceContains:<glob>`
+
+ ★ The event you declare must be one the host actually dispatches: `onCommand:` fires when the user runs the command; `onCustomEditor:` fires when a tab opens for that viewType — the host wakes the plugin **before** mounting the view iframe, because view files are only servable for plugins that have been spawned at least once (an unwoken plugin's iframe is a bare 403 "forbidden"). Prefer these specific events over `onStartup`.
+
+ Idle plugins sleep after ~5 minutes; the next event re-wakes them. Sleeping stops your background code but does **not** stop serving your view files.
+
+ ## Gotchas that bite every new author
+
+ - Titles/messages are **l10n keys**, not text — ship both locales or install fails.
+ - The **manifest is the capability ceiling**; `permissions.request()` for anything outside `permissions ∪ optionalPermissions` is silently denied. See `references/manifest-permissions.md` for what each capability unlocks, per API call.
+ - Contributing a resource (view / custom editor / command / tool registration) itself needs **no permission** — permissions gate the runtime RPCs you call, not the contribution points. A pure custom editor that saves via the document channel needs **zero** permissions.
+ - 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.
+ - `nextcowork` must be **external** in the host-side bundle; views must bundle **everything** (CSP gives no network). See `references/packaging.md`.
+
+ Inspect the host implementation when behavior is unclear: manifest schema `src/shared/plugin/manifest.ts`, RPC + permissions `src/shared/plugin/protocol.ts`, permission model `src/shared/plugin/permission.ts`, contribution points `src/shared/plugin/contribution.ts`, custom editor matching `src/shared/plugin/custom-editor.ts`, lifecycle `src/main/plugin/manager.ts`, protocol/CSP `src/main/plugin/protocol.ts`, cards `src/renderer/src/views/chat/CardRenderer.tsx`, full typed API `packages/plugin-api/nextcowork.d.ts`.
+
+ Plugin views (custom editors) are React-ready: the host serves `react`, `react-dom` and its own control kit as `nextcowork/ui` over an injected import map, so a view bundle carries neither. Mark them external, import `Button` / `Dialog` / `TextArea` from `nextcowork/ui` and the document channel from `nextcowork/view`. See `references/custom-editors.md`.
+
+ Working examples live in `examples/` — `ncw.bilibili` (zero-code web app: three files, no JavaScript), `acme.note-editor` (React view on the host kit, zero dependencies), `acme.excalidraw` (custom editor + command + `onStartup`), `acme.image-studio` (image editor over the document channel's base64 branch, zero permissions), `acme.markdown-studio` (editor + rich split view, zero permissions, code-split chunks).