diff --git a/packages/chord/CHANGELOG.md b/packages/chord/CHANGELOG.md
index ed7af6305..39a18568b 100644
--- a/packages/chord/CHANGELOG.md
+++ b/packages/chord/CHANGELOG.md
@@ -2,6 +2,14 @@
## [Unreleased]
+### Added
+
+- Chord v2's plugins component gains the plugin model (slice 12): `createPlugins({ api: { name, versions } })` checks every import of the app's versioned API (`@acme/app/v3`, per-kind modules such as `@acme/app/v3/web`): an unlisted version, another kind's module, two versions in one facet and a direct import of Chord are build errors naming the file and line. Roots may be `{ path, required }`: a required root's plugin failing at `load` rejects it with a `PluginError` after the load settled. Files named `..` (`contract.web.tsx`) are of that kind, refused in facets of another kind with the chain of imports named. Single-file plugins (`hello.server.ts` at a root's top, grouped by name, host packages only). `installPlugin({ git, ref } | { npm, version }, root, ctx)` installs plugins from git (a shallow clone of a branch, tag or commit; a repository of several plugins too) or npm; a package lists the plugin folders it provides in its `package.json` (`"chord": { "plugins": ["./notes", "plugins/*", "!plugins/draft"] }`), used by discovery and `installPlugin` (without it: one plugin when the package's top has facet files, else its subfolders with a `package.json`). `testPlugin(dir, { kind, modules, stubs, principal }, ctx)` builds a plugin as production does and loads it on an in-memory host beside stub facets, collecting its contributions to registries nobody serves and listing the contracts it waits for. Agents' guide: `docs/next/plugin-authoring.md`.
+
+### Changed
+
+- Chord v2 plugins import each other by relative path within a root (copied into the importer's script) and across repositories by package name through the package's `exports`, pinned in `dependencies`; slice 10's imports by plugin name (`@app/notes/contract`) are removed, and a relative path leaving the root is a build error.
+
## [1.5.0] - 2026-11-02
### Breaking Changes
diff --git a/packages/chord/docs/next/benchmarks.md b/packages/chord/docs/next/benchmarks.md
index ea2de1efa..801a30c66 100644
--- a/packages/chord/docs/next/benchmarks.md
+++ b/packages/chord/docs/next/benchmarks.md
@@ -957,3 +957,18 @@ to frame and encode/decode alike; reconnect of 1 / 100 references 0.94-1.15 / 80
1,000 parked calls 24.4-25.2 → 24.3-24.6 ms. `reload.ts`: swap of 50 / 1,000 objects 1.44-1.55 / 30.78-31.42 →
1.32-1.56 / 31.20-31.83 ms, restart 0.96-0.98 / 15.75-16.34 → 0.95-1.10 / 15.91-15.94 ms until the reload resolves.
`registries.ts` alike (one of 100 contributions set 1.47-2.45 → 1.48-3.41 µs, run to run).
+
+## Slice 12: the plugin model (2026-11-01)
+
+**The build's checks** (`test/next/bench/plugins.ts`, the 7c set: 20 plugins, one worker facet each, a copied contract
+by relative path), Node 24.4.1, CPU 8, medians of seven runs, three alternating runs. HEAD `ebce9debd` (its bench
+importing the contract by plugin name, which HEAD resolves) → this build: cold build of the 20 facets 257.6-257.8 →
+257.2-258.8 ms, rebuild of one facet 13.2-13.5 → 13.3-13.9 ms. With the app's API set and the kinds named (every
+import checked by version and kind, every resolved file by its kind segment; the plugins importing `@bench/app/v1`):
+257.4-258.8 ms cold, 12.9-14.3 ms rebuild, against 258.2-259.7 ms without. The checks cost nothing measurable; no hot
+path changed (the kernel gains two accessors read only by `testPlugin`).
+
+Fix round 1: the bench itself now builds with the app's API and the kinds (its plugins import `@bench/app/v1`; Opus
+L5), and every package file's real path is checked against the plugin's `node_modules`. CPU 8, three runs: cold
+build 261.1-270.2 ms, rebuild 13.6-14.4 ms (the first two runs as before, the third with a slower cold build on a busy
+machine).
diff --git a/packages/chord/docs/next/design.md b/packages/chord/docs/next/design.md
index 413674592..4c56352f2 100644
--- a/packages/chord/docs/next/design.md
+++ b/packages/chord/docs/next/design.md
@@ -1964,8 +1964,8 @@ type ReloadReport = {
duplicate name). Dependencies between plugins resolve by contract name at runtime; names are prefixed
(`.`; tools `_`). A plugin carries the contracts it was written against (decision "Contracts come from where the
plugin's code came from"): plugins that change together import each other by relative path within a root
- (copied into the importer's script); others take contracts as an npm dependency at a fixed version (slice 10 still
- resolves imports by plugin name; the plugin slice changes it).
+ (copied into the importer's script); others take contracts as an npm dependency at a fixed version (built in slice 12,
+ which removed slice 10's imports by plugin name).
- **Loader D:** one esbuild script per facet (its own files, copied files of other plugins, its npm `dependencies`
inlined; host packages, `chord.external` packages and Node builtins external, passed in at evaluation); each facet kind is defined once, `kinds: { : { platform: "node" | "browser", hostPackages } }` (slice 11); evaluated with
`vm.Script` under a stable filename per (plugin, kind, digest); no module map: old generations are reclaimed by the
@@ -1990,8 +1990,9 @@ rule underneath: a plugin carries what it was written against, never the running
not a host package plugins may name; the API re-exports the Chord surface the app supports); its npm `dependencies`
(bundled at the installed version); other plugins' contract files: by relative path within one repository
(`../notes/contract.ts`, the same commit holds both sides), by package name across repositories (`@bob/notes`,
- `@bob/notes/web`, pinned by an npm version or a git ref `github:bob/notes#`; a subfolder of a multi-plugin
- repository is cloned and linked by the plugins component). One facet imports one API version only.
+ `@bob/notes/web`, pinned by an npm version or a git ref `github:bob/notes#`; a plugin inside a
+ multi-plugin repository through a dependency on the whole repository, by path into it or its `exports`). A package
+ lists the plugins it provides in `chord.plugins` (as pi's manifest). One facet imports one API version only.
- **Versions:** the app lists the API versions it provides; an unlisted one is a build error naming the available ones.
The process runs one Chord: an older API version is a layer over today's, re-exporting Chord's compatibility module
for its previous API while Chord keeps one; contracts bind through `accepts` whatever Chord runs.
diff --git a/packages/chord/docs/next/guide-plugins-hosts/book.md b/packages/chord/docs/next/guide-plugins-hosts/book.md
index 37388c890..747cc4e32 100644
--- a/packages/chord/docs/next/guide-plugins-hosts/book.md
+++ b/packages/chord/docs/next/guide-plugins-hosts/book.md
@@ -13,7 +13,7 @@ note: |
`slice-10.md`, the decision log and the code in `packages/chord/src/next` and `src/next-node`. API names are the
current ones; where slice 10's fix round or the app kit (`chord/next/app`, being designed) will change what app code
writes, a note says so.
-colophon: Hosts and Plugins · first edition · 2026-10-31 · Chord v2 slices 1-10
+colophon: Hosts and Plugins · second edition · 2026-11-01 · Chord v2 slices 1-10 and 12
---
# Before we begin {.preface}
@@ -39,8 +39,8 @@ This book builds the app in that order. Each chapter adds one concept and shows
ask for an object by type and params, and Chord finds where it lives.
3. **A facet declares, an object acts.** A facet is a plugin's code for one host kind: it says which object types this
host serves. The objects hold the state and do the work.
-4. **A plugin is a directory.** A `package.json` with the plugin's name, and one file per host kind: `server.ts`,
- `worker.ts`, `web.tsx`.
+4. **A plugin is a directory** (or a single file). A `package.json` with the plugin's name, and one file per host kind:
+ `server.ts`, `worker.ts`, `web.tsx`. It imports its host only through the app's versioned API (`@app/api/v1`).
5. **The server builds, every host evaluates.** The plugins component builds each facet into a small script; the
server, workers and tabs load the scripts for their kind.
6. **Plugins meet through types and registries.** One plugin uses another's objects through its contract, or
@@ -52,7 +52,7 @@ This book builds the app in that order. Each chapter adds one concept and shows
:::flow wide animate | How it all fits: plugins on disk are built once on the server; each host evaluates the facets of its kind; tabs and the TUI are clients that reach workers' objects through the server.
row "On disk" muted
box disk "plugins/notes/" "`server.ts`, `worker.ts`, `web.tsx`, `tui.ts`, `contract.ts`" muted
-box core "src/ (base code)" "`@app/core`: host kinds, principals" muted
+box core "src/ (base code)" "`@app/api/v1`: Chord's surface, host kinds, principals" muted
row "Server process" accent
box comp "plugins component" "discover, build every facet, watch, roll out" accent
box server "server host" "server facets; routes Project → workers"
@@ -333,7 +333,7 @@ default export a `defineFacet(...)`. A plugin has only the files it needs: a plu
```ts
// plugins/notes/worker.ts
-import { defineFacet } from "@app/core";
+import { defineFacet } from "@app/api/v1";
import { Notes } from "./contract.ts";
export default defineFacet((host) => {
@@ -344,9 +344,10 @@ export default defineFacet((host) => {
});
```
-`@app/core` is your app's **base code**, imported by every plugin as a host package (the plugin's build leaves it
-out; every host passes its own copy in). It holds what plugins share and never reloads: host kinds, the principal
-type, a `defineFacet` bound to it.
+`@app/api/v1` is your app's **API**, version 1: the part of its base code plugins import, as a host package (the
+plugin's build leaves it out; every host passes its own copy in). It holds what plugins share and never reloads:
+Chord's builders re-exported (`local`, `remote`, `obj`, `str`, ...), host kinds, the principal type, a `defineFacet`
+bound to it. A plugin never imports Chord itself; the version in the path says what it was written against.
# Loading Plugins {#loading lede="Two steps in two places: building a plugin into a script happens once, on the server; evaluating it happens in every process that runs it."}
@@ -369,31 +370,31 @@ The two steps need different things, and that is why the API has two calls.
Look at what `notes/worker.ts` imports:
```ts
-import { defineFacet, Session } from "@app/core"; // the app's base code
-import { obj, str, remote } from "@earendil-works/chord/next"; // Chord itself
-import { Notes } from "./contract.ts"; // the plugin's own file
-import slugify from "slugify"; // an npm package in the plugin's dependencies
+import { defineFacet, obj, remote, Session, str } from "@app/api/v1"; // the app's API: its own part and Chord's
+import { Notes } from "./contract.ts"; // the plugin's own file
+import slugify from "slugify"; // an npm package in its dependencies
```
-The last two go into the script: the plugin's own files and its npm dependencies are copied in. The first two must
-not. If the script carried its own copy of Chord, the worker would run two Chords: the plugin's `ChordError` would not
-be the worker's `ChordError`, and the plugin's `defineFacet` would make a facet the worker's host does not recognize.
-The same holds for `@app/core`: the plugin must see the very `Session` host kind and the very `Panels` registry the
-process already has, not a copy.
+The last two go into the script: the plugin's own files and its npm dependencies are copied in. The first must not.
+If the script carried its own copy of the API, and with it of Chord, the worker would run two Chords: the plugin's
+`ChordError` would not be the worker's `ChordError`, and the plugin's `defineFacet` would make a facet the worker's host
+does not recognize. The plugin must see the very `Session` host kind and the very `Panels` registry the process
+already has, not a copy.
Packages the process already has, and that every plugin must share, are its **host packages**. A script leaves them
-out and asks for them by name when it runs. Building therefore needs only their **names**: "do not copy
-`@earendil-works/chord/next` or `@app/core` into this script".
+out and asks for them by name when it runs. Building therefore needs only their **names**: "do not copy `@app/api/v1`
+into this script".
## Building: `createPlugins`, on the server
```ts
const plugins = createPlugins({
- roots: ["./plugins"], // where the plugin directories are
+ roots: [{ path: "./plugins", required: true }], // where the plugin directories are
+ api: { name: "@app/api", versions: ["v1"] }, // the app's API and the versions it provides
kinds: {
- server: { platform: "node", hostPackages: ["@earendil-works/chord/next", "@app/core"] },
- worker: { platform: "node", hostPackages: ["@earendil-works/chord/next", "@app/core"] },
- web: { platform: "browser", hostPackages: ["@earendil-works/chord/next", "@app/core", "react", "react/jsx-runtime"] },
+ server: { platform: "node", hostPackages: ["@app/api/v1", "@app/api/v1/server"] },
+ worker: { platform: "node", hostPackages: ["@app/api/v1", "@app/api/v1/worker"] },
+ web: { platform: "browser", hostPackages: ["@app/api/v1", "@app/api/v1/web", "react", "react/jsx-runtime"] },
},
onError,
}, ctx);
@@ -402,22 +403,26 @@ const plugins = createPlugins({
`createPlugins` is the builder, and it builds for every kind, because only the server has the sources. For each kind
it needs two facts. The **platform** says where the scripts run: a `node` script may import `node:fs`, a `browser`
script may import a stylesheet but not `node:fs`. The **host package names** say what to leave out; they differ per
-kind, because a tab also shares React with its plugins and a worker does not. Nothing here runs a plugin.
+kind, because a tab also shares React with its plugins and a worker does not, and the API's per-kind modules
+(`/v1/web`) belong to their kind only. With `api` set, the build checks every import of the API: a version the app
+does not list, another kind's module, two versions in one facet, or Chord imported directly is a build error naming
+the file and line. Nothing here runs a plugin.
## Evaluating: `plugins.load`, in the process that hosts
-Evaluating is where the names become things. When the script runs and asks for `@app/core`, someone has to hand it
-the actual module, the one this process imported when it started. Only the process itself has it:
+Evaluating is where the names become things. When the script runs and asks for `@app/api/v1`, someone has to hand
+it the actual module, the one this process imported when it started. Only the process itself has it:
```ts
// server.ts
-import * as chord from "@earendil-works/chord/next";
-import * as core from "./core.ts"; // the app's base code, the same module plugins import as "@app/core"
+import * as api from "./api/v1/index.ts"; // the app's API, the module plugins import as "@app/api/v1"
+import * as apiServer from "./api/v1/server.ts";
+import { serverFacet } from "./core.ts";
const report = await plugins.load(server, {
kind: "server", // evaluate the server kind's scripts
- modules: { "@earendil-works/chord/next": chord, "@app/core": core }, // this process's own instances
- core: [{ name: "@app/core", facet: core.serverFacet }], // base-code facets, loaded beside the plugins
+ modules: { "@app/api/v1": api, "@app/api/v1/server": apiServer }, // this process's own instances
+ core: [{ name: "@app/core", facet: serverFacet }], // base-code facets, loaded beside the plugins
}, ctx);
```
@@ -446,11 +451,11 @@ in every process.
:::flow wide | Building on the server needs names; evaluating in each process needs that process's modules.
row "Server: build (once per edit)" accent
-box src "plugin sources" "`notes/worker.ts`, imports `@app/core`" muted
+box src "plugin sources" "`notes/worker.ts`, imports `@app/api/v1`" muted
box build "createPlugins" "copies own files and npm deps; leaves host packages out" accent
-box script "worker script" "`require(\"@app/core\")` left as a name"
+box script "worker script" "`require(\"@app/api/v1\")` left as a name"
row "Each worker: evaluate" blue
-box mods "the worker's modules" "its own `@app/core`, its own Chord" muted
+box mods "the worker's modules" "its own `@app/api/v1`, its own Chord" muted
box load "plugins.load(worker, …)" "runs the script, answers each name" blue
box facet "the facet" "uses the worker's `Session`, the worker's `defineFacet`"
arrow src -> build
@@ -464,9 +469,9 @@ arrow load -> facet
:::steps | `plugins.load(server, { kind: "server", … })`
+++ Discover
-The component lists `./plugins` one level deep and reads each `package.json`. Its `name` is the plugin's name; the
-files named after a kind are its facets. A directory without a `package.json`, or with two `server` files, is reported,
-not loaded.
+The component lists `./plugins` and reads each `package.json`. Its `name` is the plugin's name; the files named after
+a kind are its facets. A directory without a `package.json`, or with two `server` files, is reported, not loaded. Files
+at the root's top named `..ts` (`hello.server.ts`) are single-file plugins, grouped by name.
+++ Build
For each plugin with a `server` facet it builds one script, if no current one exists. A syntax error or an import the
rules do not allow (a path into base code, a package not in the plugin's dependencies) is a failed build with the file
@@ -490,7 +495,7 @@ A search plugin wants to search the notes. It **imports the contract** and asks
```ts
// plugins/search/worker.ts
-import { Notes } from "@app/notes/contract"; // another plugin, by its name: copied into search's script
+import { Notes } from "../notes/contract.ts"; // a plugin of the same root, by relative path: copied into search's script
host.serve(Search, ({ project }, principal, self) => {
const notes = self.get(Notes, { project }); // a dependency: Search goes live once Notes is
@@ -499,19 +504,36 @@ host.serve(Search, ({ project }, principal, self) => {
});
```
+Which file a plugin imports decides which version of the contract it claims, so the rule is: **a plugin carries the
+contract it was written against.** Two cases:
-A plugin imports another plugin by its **name**, never by a path: `@app/notes/contract` is the file `contract.ts` of
-whichever discovered plugin is named `@app/notes`, in any root. The file is copied into the importer's script, so only
-contracts and other side-effect-free files belong there; a missing plugin is a build error naming it.
+- **Plugins that change together** (the app's own, in its repository; a user's, in one root) import each other by
+ relative path, `../notes/contract.ts`: the same commit holds both sides. The file is copied into the importer's
+ script, so only contracts and other side-effect-free files belong there (a copied file may import only the API and
+ files of its own plugin). A relative path leaving the root (into base code, into another root) is a build error.
+- **Plugins from another repository** take contracts as a dependency at a fixed version, by package name:
+ `@bob/notes` (its `contract.ts`, through the package's `exports`), `@bob/notes/web` (its `contract.web.tsx`), pinned
+ in `package.json` by an npm version or a git ref (`"@bob/notes": "github:bob/notes#"`). The build inlines
+ the installed version: when Bob's notes move to contract version 2, a consumer built against version 1 keeps
+ claiming version 1, and the provider serves it through `accepts` or the consumer waits "contract mismatch", visible,
+ never silently wrong. A plugin inside a repository holding several (which lists them in its own `package.json`,
+ `"chord": { "plugins": ["./notes", "./search"] }`) is a dependency on the whole repository, imported by path into it:
+ `@bob/plugins/notes/contract.ts`.
+
+A contract file may be for one kind: `contract.web.tsx` (React components in a type, a panel's registry token) is a
+**web file**. Importing it, directly or through other files, from a facet of another kind is a build error naming
+the chain (`server.ts → view.ts → ../notes/contract.web.tsx`). The shared `contract.ts` mentions native things only
+through `import type`.
`self.get` in a factory declares a dependency: Chord makes `Search` live only once `Notes` is, and if the notes
plugin is missing, `Search` waits with a clear reason ("waiting for notes: not served here") instead of failing.
-The other way is a **registry**: an extension point one plugin defines and others contribute to. The app's core
-defines `Panels`, and every plugin with a UI contributes one:
+The other way is a **registry**: an extension point one plugin defines and others contribute to. The app's API
+defines `Panels` in its web module, and every plugin with a UI contributes one:
```tsx
// plugins/notes/web.tsx
+import { Panels } from "@app/api/v1/web";
export default defineFacet((host, self) => self.contribute(Panels, { title: "Notes", Component: NotesPanel }));
```
@@ -551,46 +573,65 @@ working if the provider maps the old version (`accepts`); otherwise it waits wit
Every app has two kinds of code, and the line between them decides what can change while the app runs.
-**Base code** is the app's own program: `src/`, started by `node server.ts`. It is what every plugin shares: the host
-kinds (`Server`, `Project`), the principal type, the app's `defineFacet`, the registries plugins contribute to
-(`Panels`), and the startup that creates hosts and loads plugins. Plugins import it as a host package (`@app/core`),
-so inside one process there is exactly one of each. Base code changes only with a restart: if it reloaded, the
-process would hold two `Panels` registries, and old and new plugins would no longer meet.
+**Base code** is the app's own program: `src/`, started by `node server.ts`. It shrinks to three things: the **API**
+(the versioned modules plugins import), the **bootstrap** (creating hosts, opening the store, components such as
+`createHttp` and `createWorkers`) and one small **start-data facet** serving process values (start data, the opened
+store) as core objects. Base code changes only with a restart: if it reloaded, the process would hold two `Panels`
+registries, and old and new plugins would no longer meet.
-**Plugins** are features: notes, search, export, a terminal. They reload on an edit, a broken one is left out alone,
-and an agent can write one while the app runs.
+**Plugins** are everything else: notes, search, export, a terminal, the harness, the providers of the app's core
+registries. They reload on an edit, a broken one is left out alone, and an agent can write one while the app runs.
-The rule: **base code is what everything shares, not features.** A feature in base code cannot be edited live, and a
-bug in it needs a restart. Keep base code to host kinds, principals, shared registries and startup.
+## The API, by version and by kind
+
+Plugins import their host only through the API, a version in the path:
+
+```ts
+// src/api/v1/index.ts: "@app/api/v1", for every kind
+export { defineRegistry, local, obj, remote, str } from "@earendil-works/chord/next"; // the Chord surface the app supports
+export { defineFacet, Project, Server } from "../../core.ts"; // bound to the app's principal
+// src/api/v1/web.ts: "@app/api/v1/web", for pages only
+export const Panels = defineRegistry("app.panels", { version: 1 });
+```
+
+The app lists the versions it provides, newest first (`api: { name: "@app/api", versions: ["v2", "v1"] }`), and each
+kind's modules in `kinds..hostPackages`. When the app changes its API in a way that breaks plugins, it adds
+`/v2` and keeps `/v1` as a thin layer over it for a while; once `/v1` goes, a plugin importing it fails to build with
+"@app/api/v1 is not available: this app provides v2", the plugin alone, the message saying what to change. One facet
+uses one version. Contracts the app promises others are re-exported in the API (`export { Notes } from
+"../../plugins/notes/contract.ts"`), so a plugin built against `/v1` keeps v1's contract files.
## Plugins the app ships
-An app usually comes with plugins of its own: notes, search, settings. They are still plugins, kept in a root inside
-the app, next to the root where users and agents add theirs:
+An app comes with plugins of its own. They follow the same rules as anyone's, in a root inside the app, next to the
+root where users and agents add theirs:
```ts
const plugins = createPlugins({
- roots: ["./src/plugins", join(dataDir, "plugins")], // the app's own plugins, then the user's
+ roots: [{ path: "./plugins", required: true }, join(dataDir, "plugins")], // the app's own, then the user's
+ api: { name: "@app/api", versions: ["v1"] },
kinds,
onError,
}, ctx);
```
-They are built at startup like any plugin (a quarter of a second for twenty), reload like any plugin, and are left
-out alone when broken. If two roots have a plugin with the same name, the first root wins, and the other is reported.
+They are built at startup like any plugin (a quarter of a second for twenty), reload like any plugin, and are left out
+alone when broken at a reload. The app's root is **required**: a plugin of it failing at startup makes
+`plugins.load` reject with a `PluginError` naming it (after the load settled), so the app stops instead of running
+without its core. A failure in the user's root is reported and the app runs on. If two roots have a plugin with the
+same name, the first root wins, and the other is reported.
-## Subpaths of host packages
+## Small plugins, installed plugins, tested plugins
-A host package is matched by its exact name. If base code offers several entry points (`@app/core` for every host,
-`@app/core/web` for pages), each is its own host package, listed by name for the build and given as a module when
-evaluating, as `react` and `react/jsx-runtime` are. A plugin importing a subpath that is not listed gets a build
-error naming it.
-
-## What the app kit will change
-
-The app kit being designed next (`chord/next/app`) will make the base code nearly empty: `startApp` and `startWorker`
-with Chord's conventions for hosts, users, authentication and storage. An app is then its plugins, its own ones in
-`src/plugins` and the user's beside them.
+- **A single file** at a root's top is a plugin: `plugins/hello.server.ts` and `plugins/hello.web.tsx` are the plugin
+ `hello` with two facets. It has no `package.json` and imports only the API; when it needs a second file or a
+ package, it becomes the directory `hello/`, which a reload treats as the same plugin.
+- **Installing** from git or npm: `plugins.installPlugin({ git: url, ref }, root, ctx)` clones the ref (a branch, tag
+ or commit) into the root, `{ npm: name, version }` extracts the package; then its dependencies are installed. Removal
+ is deleting the directory.
+- **Testing** a plugin alone: `testPlugin("./plugins/stats", { kind: "worker", api, modules, stubs }, ctx)` builds it
+ as production does, loads it on an in-memory host beside stub facets serving what it uses, collects what it
+ contributes to registries nobody serves, and lists the contracts it waits for (`docs/next/plugin-authoring.md`).
# Reference {.appendix}
@@ -602,6 +643,7 @@ with Chord's conventions for hosts, users, authentication and storage. An app is
| `local(name, { version, params, members })` / `remote(name, { version, params, hostAddress, variants })` | contracts |
| `defineFacet((host, self) => …)`, `host.serve(Type, factory)`, `self.contribute(R, value)` | a facet |
| factory `self`: `state`, `derive`, `get`, `registry`, `open`, `on`, `onLive`, `job`, `tx` | an object's parts |
-| `createPlugins({ roots, kinds, onError }, ctx)` | the plugins component |
+| `createPlugins({ roots, api, kinds, onError }, ctx)` | the plugins component |
| `plugins.load(host, { kind, core, modules }, ctx)`, `build(names)`, `rollout(steps, built)` | load, build, roll out |
+| `plugins.installPlugin(source, root, ctx)`, `testPlugin(dir, options, ctx)` | install a plugin, test one alone |
| `hostKind(name, params?)`, `Kind(params)` | host kinds and addresses |
diff --git a/packages/chord/docs/next/guide-plugins-hosts/index.html b/packages/chord/docs/next/guide-plugins-hosts/index.html
index 9d2f815af..9b30188ea 100644
--- a/packages/chord/docs/next/guide-plugins-hosts/index.html
+++ b/packages/chord/docs/next/guide-plugins-hosts/index.html
@@ -152,8 +152,8 @@ connect over links. Tabs and terminal UIs are clients: no address, they connect
ask for an object by type and params, and Chord finds where it lives.
A facet declares, an object acts. A facet is a plugin's code for one host kind: it says which object types this
host serves. The objects hold the state and do the work.
-
A plugin is a directory. A package.json with the plugin's name, and one file per host kind: server.ts,
-worker.ts, web.tsx.
+
A plugin is a directory (or a single file). A package.json with the plugin's name, and one file per host kind:
+server.ts, worker.ts, web.tsx. It imports its host only through the app's versioned API (@app/api/v1).
The server builds, every host evaluates. The plugins component builds each facet into a small script; the
server, workers and tabs load the scripts for their kind.
Plugins meet through types and registries. One plugin uses another's objects through its contract, or
@@ -161,7 +161,7 @@ contributes to a registry another defines. Nothing else crosses between plugins.
An edit is a rollout. Change a file, build, roll out: the server, then every worker, swap the plugin's objects in
place; a broken edit keeps the old code running and reports why.
-Figure 1. How it all fits: plugins on disk are built once on the server; each host evaluates the facets of its kind; tabs and the TUI are clients that reach workers' objects through the server.
+Figure 1. How it all fits: plugins on disk are built once on the server; each host evaluates the facets of its kind; tabs and the TUI are clients that reach workers' objects through the server.
Words used throughout
A host is one participant of the app, made with createHost. A host kind (Server, Session) describes
addressable hosts; an address (Session({ id: "p1" })) names one. An object type is a contract: local (used
@@ -357,7 +357,7 @@ worker.
default export a defineFacet(...). A plugin has only the files it needs: a plugin that only adds a route has a
server.ts and nothing else.
// plugins/notes/worker.ts
-import { defineFacet } from"@app/core";
+import { defineFacet } from"@app/api/v1";
import { Notes } from"./contract.ts";
exportdefaultdefineFacet((host) => {
@@ -366,9 +366,10 @@ default export a defineFacet(...). A plugin has only the files it n
return { variant: "editor", members: { list: doc, add: async ({ text }, ctx) => /* … */ } };
});
});
-
@app/core is your app's base code, imported by every plugin as a host package (the plugin's build leaves it
-out; every host passes its own copy in). It holds what plugins share and never reloads: host kinds, the principal
-type, a defineFacet bound to it.
+
@app/api/v1 is your app's API, version 1: the part of its base code plugins import, as a host package (the
+plugin's build leaves it out; every host passes its own copy in). It holds what plugins share and never reloads:
+Chord's builders re-exported (local, remote, obj, str, ...), host kinds, the principal type, a defineFacet
+bound to it. A plugin never imports Chord itself; the version in the path says what it was written against.
VI
Loading Plugins
Two steps in two places: building a plugin into a script happens once, on the server; evaluating it happens in every process that runs it.
@@ -387,43 +388,47 @@ the web facets.
The two steps need different things, and that is why the API has two calls.
Host packages: what a script leaves out
Look at what notes/worker.ts imports:
-
import { defineFacet, Session } from"@app/core"; // the app's base code
-import { obj, str, remote } from"@earendil-works/chord/next"; // Chord itself
-import { Notes } from"./contract.ts"; // the plugin's own file
-import slugify from"slugify"; // an npm package in the plugin's dependencies
-
The last two go into the script: the plugin's own files and its npm dependencies are copied in. The first two must
-not. If the script carried its own copy of Chord, the worker would run two Chords: the plugin's ChordError would not
-be the worker's ChordError, and the plugin's defineFacet would make a facet the worker's host does not recognize.
-The same holds for @app/core: the plugin must see the very Session host kind and the very Panels registry the
-process already has, not a copy.
+
import { defineFacet, obj, remote, Session, str } from"@app/api/v1"; // the app's API: its own part and Chord's
+import { Notes } from"./contract.ts"; // the plugin's own file
+import slugify from"slugify"; // an npm package in its dependencies
+
The last two go into the script: the plugin's own files and its npm dependencies are copied in. The first must not.
+If the script carried its own copy of the API, and with it of Chord, the worker would run two Chords: the plugin's
+ChordError would not be the worker's ChordError, and the plugin's defineFacet would make a facet the worker's host
+does not recognize. The plugin must see the very Session host kind and the very Panels registry the process
+already has, not a copy.
Packages the process already has, and that every plugin must share, are its host packages. A script leaves them
-out and asks for them by name when it runs. Building therefore needs only their names: "do not copy
-@earendil-works/chord/next or @app/core into this script".
+out and asks for them by name when it runs. Building therefore needs only their names: "do not copy @app/api/v1
+into this script".
Building: createPlugins, on the server
const plugins = createPlugins({
- roots: ["./plugins"], // where the plugin directories are
+ roots: [{ path: "./plugins", required: true }], // where the plugin directories are
+ api: { name: "@app/api", versions: ["v1"] }, // the app's API and the versions it provides
kinds: {
- server: { platform: "node", hostPackages: ["@earendil-works/chord/next", "@app/core"] },
- worker: { platform: "node", hostPackages: ["@earendil-works/chord/next", "@app/core"] },
- web: { platform: "browser", hostPackages: ["@earendil-works/chord/next", "@app/core", "react", "react/jsx-runtime"] },
+ server: { platform: "node", hostPackages: ["@app/api/v1", "@app/api/v1/server"] },
+ worker: { platform: "node", hostPackages: ["@app/api/v1", "@app/api/v1/worker"] },
+ web: { platform: "browser", hostPackages: ["@app/api/v1", "@app/api/v1/web", "react", "react/jsx-runtime"] },
},
onError,
}, ctx);
createPlugins is the builder, and it builds for every kind, because only the server has the sources. For each kind
it needs two facts. The platform says where the scripts run: a node script may import node:fs, a browser
script may import a stylesheet but not node:fs. The host package names say what to leave out; they differ per
-kind, because a tab also shares React with its plugins and a worker does not. Nothing here runs a plugin.
+kind, because a tab also shares React with its plugins and a worker does not, and the API's per-kind modules
+(/v1/web) belong to their kind only. With api set, the build checks every import of the API: a version the app
+does not list, another kind's module, two versions in one facet, or Chord imported directly is a build error naming
+the file and line. Nothing here runs a plugin.
Evaluating: plugins.load, in the process that hosts
-
Evaluating is where the names become things. When the script runs and asks for @app/core, someone has to hand it
-the actual module, the one this process imported when it started. Only the process itself has it:
+
Evaluating is where the names become things. When the script runs and asks for @app/api/v1, someone has to hand
+it the actual module, the one this process imported when it started. Only the process itself has it:
// server.ts
-import * as chord from"@earendil-works/chord/next";
-import * as core from"./core.ts"; // the app's base code, the same module plugins import as "@app/core"
+import * as api from"./api/v1/index.ts"; // the app's API, the module plugins import as "@app/api/v1"
+import * as apiServer from"./api/v1/server.ts";
+import { serverFacet } from"./core.ts";
const report = await plugins.load(server, {
kind: "server", // evaluate the server kind's scripts
- modules: { "@earendil-works/chord/next": chord, "@app/core": core }, // this process's own instances
- core: [{ name: "@app/core", facet: core.serverFacet }], // base-code facets, loaded beside the plugins
+ modules: { "@app/api/v1": api, "@app/api/v1/server": apiServer }, // this process's own instances
+ core: [{ name: "@app/core", facet: serverFacet }], // base-code facets, loaded beside the plugins
}, ctx);
modules is this process's answer to every host package name. The server passes its own; a worker passes its own in
workerMain; a page passes its own in startPage. Each process has different instances, which is exactly the point:
@@ -443,11 +448,11 @@ plugin runs, so that a later reload can hand the host its complete new plugin se
same objects. That memory is why the host is passed to it: plugins.load(server, …) on the server,
plugins.load(worker, …) in a worker (where workerMain gives it a plugins that only evaluates), the same call
in every process.
-Figure 3. Building on the server needs names; evaluating in each process needs that process's modules.
+Figure 3. Building on the server needs names; evaluating in each process needs that process's modules.
What happens, step by step
-
plugins.load(server, { kind: "server", … })
Discover
The component lists ./plugins one level deep and reads each package.json. Its name is the plugin's name; the
-files named after a kind are its facets. A directory without a package.json, or with two server files, is reported,
-not loaded.
+
plugins.load(server, { kind: "server", … })
Discover
The component lists ./plugins and reads each package.json. Its name is the plugin's name; the files named after
+a kind are its facets. A directory without a package.json, or with two server files, is reported, not loaded. Files
+at the root's top named <name>.<kind>.ts (hello.server.ts) are single-file plugins, grouped by name.
Build
For each plugin with a server facet it builds one script, if no current one exists. A syntax error or an import the
rules do not allow (a path into base code, a package not in the plugin's dependencies) is a failed build with the file
and line, never a crash.
@@ -465,21 +470,39 @@ evaluates them with its own modules (a worker's from workerMain
VII
Plugins Using Plugins
Two ways one plugin uses another, and the one it may not.
A search plugin wants to search the notes. It imports the contract and asks for the object:
// plugins/search/worker.ts
-import { Notes } from"@app/notes/contract"; // another plugin, by its name: copied into search's script
+import { Notes } from"../notes/contract.ts"; // a plugin of the same root, by relative path: copied into search's script
host.serve(Search, ({ project }, principal, self) => {
const notes = self.get(Notes, { project }); // a dependency: Search goes live once Notes isconst index = self.derive([notes.list], ([list]) => buildIndex(list.items));
return { variant: "main", members: { find: async ({ q }) => lookup(index.value, q) } };
});
-
A plugin imports another plugin by its name, never by a path: @app/notes/contract is the file contract.ts of
-whichever discovered plugin is named @app/notes, in any root. The file is copied into the importer's script, so only
-contracts and other side-effect-free files belong there; a missing plugin is a build error naming it.
+
Which file a plugin imports decides which version of the contract it claims, so the rule is: a plugin carries the
+contract it was written against. Two cases:
+
+
Plugins that change together (the app's own, in its repository; a user's, in one root) import each other by
+relative path, ../notes/contract.ts: the same commit holds both sides. The file is copied into the importer's
+script, so only contracts and other side-effect-free files belong there (a copied file may import only the API and
+files of its own plugin). A relative path leaving the root (into base code, into another root) is a build error.
+
Plugins from another repository take contracts as a dependency at a fixed version, by package name:
+@bob/notes (its contract.ts, through the package's exports), @bob/notes/web (its contract.web.tsx), pinned
+in package.json by an npm version or a git ref ("@bob/notes": "github:bob/notes#<commit>"). The build inlines
+the installed version: when Bob's notes move to contract version 2, a consumer built against version 1 keeps
+claiming version 1, and the provider serves it through accepts or the consumer waits "contract mismatch", visible,
+never silently wrong. A plugin inside a repository holding several (which lists them in its own package.json,
+"chord": { "plugins": ["./notes", "./search"] }) is a dependency on the whole repository, imported by path into it:
+@bob/plugins/notes/contract.ts.
+
+
A contract file may be for one kind: contract.web.tsx (React components in a type, a panel's registry token) is a
+web file. Importing it, directly or through other files, from a facet of another kind is a build error naming
+the chain (server.ts → view.ts → ../notes/contract.web.tsx). The shared contract.ts mentions native things only
+through import type.
self.get in a factory declares a dependency: Chord makes Search live only once Notes is, and if the notes
plugin is missing, Search waits with a clear reason ("waiting for notes: not served here") instead of failing.
-
The other way is a registry: an extension point one plugin defines and others contribute to. The app's core
-defines Panels, and every plugin with a UI contributes one:
+
The other way is a registry: an extension point one plugin defines and others contribute to. The app's API
+defines Panels in its web module, and every plugin with a UI contributes one:
What a plugin may not do is reach into another's data. Search never reads notes' documents; it uses the Notes
object's state. That rule is what lets the notes plugin change its storage without breaking search.
@@ -523,34 +546,51 @@ working if the provider maps the old version (accepts); otherwise i
IX
Base Code and Plugins
What goes into the app itself, what goes into plugins, and how an app ships plugins of its own.
Every app has two kinds of code, and the line between them decides what can change while the app runs.
-
Base code is the app's own program: src/, started by node server.ts. It is what every plugin shares: the host
-kinds (Server, Project), the principal type, the app's defineFacet, the registries plugins contribute to
-(Panels), and the startup that creates hosts and loads plugins. Plugins import it as a host package (@app/core),
-so inside one process there is exactly one of each. Base code changes only with a restart: if it reloaded, the
-process would hold two Panels registries, and old and new plugins would no longer meet.
-
Plugins are features: notes, search, export, a terminal. They reload on an edit, a broken one is left out alone,
-and an agent can write one while the app runs.
-
The rule: base code is what everything shares, not features. A feature in base code cannot be edited live, and a
-bug in it needs a restart. Keep base code to host kinds, principals, shared registries and startup.
+
Base code is the app's own program: src/, started by node server.ts. It shrinks to three things: the API
+(the versioned modules plugins import), the bootstrap (creating hosts, opening the store, components such as
+createHttp and createWorkers) and one small start-data facet serving process values (start data, the opened
+store) as core objects. Base code changes only with a restart: if it reloaded, the process would hold two Panels
+registries, and old and new plugins would no longer meet.
+
Plugins are everything else: notes, search, export, a terminal, the harness, the providers of the app's core
+registries. They reload on an edit, a broken one is left out alone, and an agent can write one while the app runs.
+
The API, by version and by kind
+
Plugins import their host only through the API, a version in the path:
+
// src/api/v1/index.ts: "@app/api/v1", for every kind
+export { defineRegistry, local, obj, remote, str } from"@earendil-works/chord/next"; // the Chord surface the app supports
+export { defineFacet, Project, Server } from"../../core.ts"; // bound to the app's principal
+// src/api/v1/web.ts: "@app/api/v1/web", for pages only
+exportconstPanels = defineRegistry<Panel, readonlyPanel[]>("app.panels", { version: 1 });
+
The app lists the versions it provides, newest first (api: { name: "@app/api", versions: ["v2", "v1"] }), and each
+kind's modules in kinds.<kind>.hostPackages. When the app changes its API in a way that breaks plugins, it adds
+/v2 and keeps /v1 as a thin layer over it for a while; once /v1 goes, a plugin importing it fails to build with
+"@app/api/v1 is not available: this app provides v2", the plugin alone, the message saying what to change. One facet
+uses one version. Contracts the app promises others are re-exported in the API (export { Notes } from "../../plugins/notes/contract.ts"), so a plugin built against /v1 keeps v1's contract files.
Plugins the app ships
-
An app usually comes with plugins of its own: notes, search, settings. They are still plugins, kept in a root inside
-the app, next to the root where users and agents add theirs:
+
An app comes with plugins of its own. They follow the same rules as anyone's, in a root inside the app, next to the
+root where users and agents add theirs:
const plugins = createPlugins({
- roots: ["./src/plugins", join(dataDir, "plugins")], // the app's own plugins, then the user's
+ roots: [{ path: "./plugins", required: true }, join(dataDir, "plugins")], // the app's own, then the user's
+ api: { name: "@app/api", versions: ["v1"] },
kinds,
onError,
}, ctx);
-
They are built at startup like any plugin (a quarter of a second for twenty), reload like any plugin, and are left
-out alone when broken. If two roots have a plugin with the same name, the first root wins, and the other is reported.
-
Subpaths of host packages
-
A host package is matched by its exact name. If base code offers several entry points (@app/core for every host,
-@app/core/web for pages), each is its own host package, listed by name for the build and given as a module when
-evaluating, as react and react/jsx-runtime are. A plugin importing a subpath that is not listed gets a build
-error naming it.
-
What the app kit will change
-
The app kit being designed next (chord/next/app) will make the base code nearly empty: startApp and startWorker
-with Chord's conventions for hosts, users, authentication and storage. An app is then its plugins, its own ones in
-src/plugins and the user's beside them.
+
They are built at startup like any plugin (a quarter of a second for twenty), reload like any plugin, and are left out
+alone when broken at a reload. The app's root is required: a plugin of it failing at startup makes
+plugins.load reject with a PluginError naming it (after the load settled), so the app stops instead of running
+without its core. A failure in the user's root is reported and the app runs on. If two roots have a plugin with the
+same name, the first root wins, and the other is reported.
+
Small plugins, installed plugins, tested plugins
+
+
A single file at a root's top is a plugin: plugins/hello.server.ts and plugins/hello.web.tsx are the plugin
+hello with two facets. It has no package.json and imports only the API; when it needs a second file or a
+package, it becomes the directory hello/, which a reload treats as the same plugin.
+
Installing from git or npm: plugins.installPlugin({ git: url, ref }, root, ctx) clones the ref (a branch, tag
+or commit) into the root, { npm: name, version } extracts the package; then its dependencies are installed. Removal
+is deleting the directory.
+
Testing a plugin alone: testPlugin("./plugins/stats", { kind: "worker", api, modules, stubs }, ctx) builds it
+as production does, loads it on an in-memory host beside stub facets serving what it uses, collects what it
+contributes to registries nobody serves, and lists the contracts it waits for (docs/next/plugin-authoring.md).
+
A
Reference
@@ -586,7 +626,7 @@ with Chord's conventions for hosts, users, authentication and storage. An ap